예제 #1
0
    def test_subscription_cancel_with_prorated_refund_and_amount_overwrite(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with freeze_time('2013-06-01'):
            with db_transaction.manager:
                guid = model.create(
                    customer_guid=self.customer_tom_guid,
                    plan_guid=self.monthly_plan_guid,
                    amount=10000,
                )
                tx_guids = model.yield_transactions()
                transaction = tx_model.get(tx_guids[0])
                transaction.status = tx_model.STATUS_DONE
                transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
                self.session.add(transaction)

        # it is a monthly plan, there is 30 days in June, and only
        # 6 days are elapsed, so 6 / 30 days, the rate should be 1 - 0.2 = 0.8
        # and we have 100 dollars as the amount, we should return 80 dollars to 
        # customer
        with freeze_time('2013-06-07'):
            with db_transaction.manager:
                refund_guid = model.cancel(guid, prorated_refund=True)

        transaction = tx_model.get(refund_guid)
        self.assertEqual(transaction.amount, 8000)
예제 #2
0
 def test_transaction_list_by_company(self):
     from billy.models.transaction import TransactionModel
     transaction_model = TransactionModel(self.testapp.session)
     guids = [self.transaction_guid]
     with db_transaction.manager:
         for i in range(9):
             with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)):
                 guid = transaction_model.create(
                     subscription_guid=self.subscription_guid,
                     transaction_type=transaction_model.TYPE_CHARGE,
                     amount=10 * i,
                     payment_uri='/v1/cards/tester',
                     scheduled_at=datetime.datetime.utcnow(),
                 )
                 guids.append(guid)
     guids = list(reversed(guids))
     res = self.testapp.get(
         '/v1/transactions?offset=5&limit=3',
         extra_environ=dict(REMOTE_USER=self.api_key), 
         status=200,
     )
     self.assertEqual(res.json['offset'], 5)
     self.assertEqual(res.json['limit'], 3)
     items = res.json['items']
     result_guids = [item['guid'] for item in items]
     self.assertEqual(set(result_guids), set(guids[5:8]))
예제 #3
0
    def test_subscription_cancel_with_prorated_refund_rounding(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with freeze_time('2013-06-01'):
            with db_transaction.manager:
                guid = model.create(
                    customer_guid=self.customer_tom_guid,
                    plan_guid=self.monthly_plan_guid,
                )
                tx_guids = model.yield_transactions()
                transaction = tx_model.get(tx_guids[0])
                transaction.status = tx_model.STATUS_DONE
                transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
                self.session.add(transaction)

        # 17 / 30 days, the rate should be 1 - 0.56666..., which is
        # 0.43333...
        with freeze_time('2013-06-18'):
            with db_transaction.manager:
                refund_guid = model.cancel(guid, prorated_refund=True)

        transaction = tx_model.get(refund_guid)
        self.assertEqual(transaction.amount, 433)
예제 #4
0
def main(argv=sys.argv, processor=None):
    logger = logging.getLogger(__name__)

    if len(argv) != 2:
        usage(argv)
    config_uri = argv[1]
    setup_logging(config_uri)
    settings = get_appsettings(config_uri)
    settings = setup_database({}, **settings)

    session = settings['session']
    subscription_model = SubscriptionModel(session)
    tx_model = TransactionModel(session)

    maximum_retry = int(settings.get(
        'billy.transaction.maximum_retry', 
        TransactionModel.DEFAULT_MAXIMUM_RETRY,
    ))

    resolver = DottedNameResolver()
    if processor is None:
        processor_factory = settings['billy.processor_factory']
        processor_factory = resolver.maybe_resolve(processor_factory)
        processor = processor_factory()

    # yield all transactions and commit before we process them, so that
    # we won't double process them. 
    with db_transaction.manager:
        logger.info('Yielding transaction ...')
        subscription_model.yield_transactions()

    with db_transaction.manager:
        logger.info('Processing transaction ...')
        tx_model.process_transactions(processor, maximum_retry=maximum_retry)
    logger.info('Done')
예제 #5
0
    def test_yield_transactions_for_specific_subscriptions(self):
        from billy.models.transaction import TransactionModel

        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid1 = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            guid2 = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions([guid1, guid2])

        self.assertEqual(len(tx_guids), 2)
        subscription_guids = [tx_model.get(tx_guid).subscription_guid 
                              for tx_guid in tx_guids]
        self.assertEqual(set(subscription_guids), set([guid1, guid2]))
예제 #6
0
    def test_yield_transactions_for_specific_subscriptions(self):
        from billy.models.transaction import TransactionModel

        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid1 = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            guid2 = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions([guid1, guid2])

        self.assertEqual(len(tx_guids), 2)
        subscription_guids = [
            tx_model.get(tx_guid).subscription_guid for tx_guid in tx_guids
        ]
        self.assertEqual(set(subscription_guids), set([guid1, guid2]))
예제 #7
0
    def test_cancel_subscription(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
            tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=100,
                scheduled_at=now,
            )

        with freeze_time('2013-08-16 07:00:00'):
            canceled_at = datetime.datetime.utcnow()
            res = self.testapp.post(
                '/v1/subscriptions/{}/cancel'.format(subscription_guid),
                extra_environ=dict(REMOTE_USER=self.api_key),
                status=200,
            )

        subscription = res.json
        self.assertEqual(subscription['canceled'], True)
        self.assertEqual(subscription['canceled_at'], canceled_at.isoformat())
예제 #8
0
    def test_subscription_cancel_with_prorated_refund_rounding(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with freeze_time('2013-06-01'):
            with db_transaction.manager:
                guid = model.create(
                    customer_guid=self.customer_tom_guid,
                    plan_guid=self.monthly_plan_guid,
                )
                tx_guids = model.yield_transactions()
                transaction = tx_model.get(tx_guids[0])
                transaction.status = tx_model.STATUS_DONE
                transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
                self.session.add(transaction)

        # 17 / 30 days, the rate should be 1 - 0.56666..., which is
        # 0.43333...
        with freeze_time('2013-06-18'):
            with db_transaction.manager:
                refund_guid = model.cancel(guid, prorated_refund=True)

        transaction = tx_model.get(refund_guid)
        self.assertEqual(transaction.amount, decimal.Decimal('4.33'))
예제 #9
0
    def test_cancel_subscription(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
            tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=100, 
                scheduled_at=now,
            )

        with freeze_time('2013-08-16 07:00:00'):
            canceled_at = datetime.datetime.utcnow()
            res = self.testapp.post(
                '/v1/subscriptions/{}/cancel'.format(subscription_guid), 
                extra_environ=dict(REMOTE_USER=self.api_key), 
                status=200,
            )

        subscription = res.json
        self.assertEqual(subscription['canceled'], True)
        self.assertEqual(subscription['canceled_at'], canceled_at.isoformat())
예제 #10
0
    def test_subscription_cancel_with_refund_amount(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions()
            transaction = tx_model.get(tx_guids[0])
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.session.add(transaction)

        # let's cancel and refund the latest transaction with amount 5.66
        with db_transaction.manager:
            refund_guid = model.cancel(guid, refund_amount=5.66)

        transaction = tx_model.get(refund_guid)
        self.assertEqual(transaction.refund_to_guid, tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND)
        self.assertEqual(transaction.amount, decimal.Decimal('5.66'))
예제 #11
0
    def test_subscription_cancel_with_prorated_refund_and_amount_overwrite(
            self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with freeze_time('2013-06-01'):
            with db_transaction.manager:
                guid = model.create(
                    customer_guid=self.customer_tom_guid,
                    plan_guid=self.monthly_plan_guid,
                    amount=100,
                )
                tx_guids = model.yield_transactions()
                transaction = tx_model.get(tx_guids[0])
                transaction.status = tx_model.STATUS_DONE
                transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
                self.session.add(transaction)

        # it is a monthly plan, there is 30 days in June, and only
        # 6 days are elapsed, so 6 / 30 days, the rate should be 1 - 0.2 = 0.8
        # and we have 100 as the amount, we should return 80 to customer
        with freeze_time('2013-06-07'):
            with db_transaction.manager:
                refund_guid = model.cancel(guid, prorated_refund=True)

        transaction = tx_model.get(refund_guid)
        # the orignal price is 10, then overwritten by subscription as 100
        # and we refund half, then the refund amount should be 50
        self.assertEqual(transaction.amount, decimal.Decimal('80'))
예제 #12
0
    def test_subscription_cancel_with_prorated_refund(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with freeze_time('2013-06-01'):
            with db_transaction.manager:
                guid = model.create(
                    customer_guid=self.customer_tom_guid,
                    plan_guid=self.monthly_plan_guid,
                )
                tx_guids = model.yield_transactions()
                transaction = tx_model.get(tx_guids[0])
                transaction.status = tx_model.STATUS_DONE
                transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
                self.session.add(transaction)

        # it is a monthly plan, there is 30 days in June, and only
        # 6 days are elapsed, so 6 / 30 days, the rate should be 1 - 0.2 = 0.8
        # and we have 10 as the amount, we should return 8 to customer
        with freeze_time('2013-06-07'):
            with db_transaction.manager:
                refund_guid = model.cancel(guid, prorated_refund=True)

        transaction = tx_model.get(refund_guid)
        self.assertEqual(transaction.refund_to_guid, tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND)
        self.assertEqual(transaction.amount, decimal.Decimal('8'))
예제 #13
0
    def test_subscription_cancel_with_refund_amount(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions()
            transaction = tx_model.get(tx_guids[0])
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.session.add(transaction)

        # let's cancel and refund the latest transaction with amount 566 cent
        with db_transaction.manager:
            refund_guid = model.cancel(guid, refund_amount=566)

        transaction = tx_model.get(refund_guid)
        self.assertEqual(transaction.refund_to_guid, tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.transaction_type, tx_model.TYPE_REFUND)
        self.assertEqual(transaction.amount, 566)
예제 #14
0
 def test_get_transaction(self):
     from billy.models.transaction import TransactionModel
     transaction_model = TransactionModel(self.testapp.session)
     res = self.testapp.get(
         '/v1/transactions/{}'.format(self.transaction_guid), 
         extra_environ=dict(REMOTE_USER=self.api_key), 
         status=200,
     )
     transaction = transaction_model.get(self.transaction_guid)
     self.assertEqual(res.json['guid'], transaction.guid)
     self.assertEqual(res.json['created_at'], 
                      transaction.created_at.isoformat())
     self.assertEqual(res.json['updated_at'], 
                      transaction.updated_at.isoformat())
     self.assertEqual(res.json['scheduled_at'], 
                      transaction.scheduled_at.isoformat())
     self.assertEqual(res.json['amount'], str(transaction.amount))
     self.assertEqual(res.json['payment_uri'], transaction.payment_uri)
     self.assertEqual(res.json['transaction_type'], 'charge')
     self.assertEqual(res.json['status'], 'init')
     self.assertEqual(res.json['error_message'], None)
     self.assertEqual(res.json['failure_count'], 0)
     self.assertEqual(res.json['external_id'], None)
     self.assertEqual(res.json['subscription_guid'], 
                      transaction.subscription_guid)
예제 #15
0
    def test_cancel_a_canceled_subscription(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
            tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=100, 
                scheduled_at=now,
            )

        self.testapp.post(
            '/v1/subscriptions/{}/cancel'.format(subscription_guid), 
            extra_environ=dict(REMOTE_USER=self.api_key), 
            status=200,
        )
        self.testapp.post(
            '/v1/subscriptions/{}/cancel'.format(subscription_guid), 
            extra_environ=dict(REMOTE_USER=self.api_key), 
            status=400,
        )
예제 #16
0
    def test_transaction_list_by_subscription(self):
        from billy.models.transaction import TransactionModel
        from billy.models.subscription import SubscriptionModel
        subscription_model = SubscriptionModel(self.testapp.session)
        transaction_model = TransactionModel(self.testapp.session)
        with db_transaction.manager:
            subscription_guid1 = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
            subscription_guid2 = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
        guids1 = []
        guids2 = []
        with db_transaction.manager:
            for i in range(10):
                with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)):
                    guid = transaction_model.create(
                        subscription_guid=subscription_guid1,
                        transaction_type=transaction_model.TYPE_CHARGE,
                        amount=10 * i,
                        payment_uri='/v1/cards/tester',
                        scheduled_at=datetime.datetime.utcnow(),
                    )
                    guids1.append(guid)
            for i in range(20):
                with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)):
                    guid = transaction_model.create(
                        subscription_guid=subscription_guid2,
                        transaction_type=transaction_model.TYPE_CHARGE,
                        amount=10 * i,
                        payment_uri='/v1/cards/tester',
                        scheduled_at=datetime.datetime.utcnow(),
                    )
                    guids2.append(guid)
        guids1 = list(reversed(guids1))
        guids2 = list(reversed(guids2))

        res = self.testapp.get(
            '/v1/subscriptions/{}/transactions'.format(subscription_guid1),
            extra_environ=dict(REMOTE_USER=self.api_key), 
            status=200,
        )
        items = res.json['items']
        result_guids = [item['guid'] for item in items]
        self.assertEqual(result_guids, guids1)

        res = self.testapp.get(
            '/v1/subscriptions/{}/transactions'.format(subscription_guid2),
            extra_environ=dict(REMOTE_USER=self.api_key), 
            status=200,
        )
        items = res.json['items']
        result_guids = [item['guid'] for item in items]
        self.assertEqual(result_guids, guids2)
예제 #17
0
    def test_transaction_list_by_subscription(self):
        from billy.models.transaction import TransactionModel
        from billy.models.subscription import SubscriptionModel
        subscription_model = SubscriptionModel(self.testapp.session)
        transaction_model = TransactionModel(self.testapp.session)
        with db_transaction.manager:
            subscription_guid1 = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
            subscription_guid2 = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
        guids1 = []
        guids2 = []
        with db_transaction.manager:
            for i in range(10):
                with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)):
                    guid = transaction_model.create(
                        subscription_guid=subscription_guid1,
                        transaction_type=transaction_model.TYPE_CHARGE,
                        amount=10 * i,
                        payment_uri='/v1/cards/tester',
                        scheduled_at=datetime.datetime.utcnow(),
                    )
                    guids1.append(guid)
            for i in range(20):
                with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)):
                    guid = transaction_model.create(
                        subscription_guid=subscription_guid2,
                        transaction_type=transaction_model.TYPE_CHARGE,
                        amount=10 * i,
                        payment_uri='/v1/cards/tester',
                        scheduled_at=datetime.datetime.utcnow(),
                    )
                    guids2.append(guid)
        guids1 = list(reversed(guids1))
        guids2 = list(reversed(guids2))

        res = self.testapp.get(
            '/v1/subscriptions/{}/transactions'.format(subscription_guid1),
            extra_environ=dict(REMOTE_USER=self.api_key),
            status=200,
        )
        items = res.json['items']
        result_guids = [item['guid'] for item in items]
        self.assertEqual(result_guids, guids1)

        res = self.testapp.get(
            '/v1/subscriptions/{}/transactions'.format(subscription_guid2),
            extra_environ=dict(REMOTE_USER=self.api_key),
            status=200,
        )
        items = res.json['items']
        result_guids = [item['guid'] for item in items]
        self.assertEqual(result_guids, guids2)
예제 #18
0
파일: views.py 프로젝트: MDrollette/billy
def subscription_cancel(request):
    """Cancel a subscription

    """
    # TODO: it appears a DELETE request with body is not a good idea
    # for HTTP protocol as many server doesn't support this, this is why
    # we use another view with post method, maybe we should use a better 
    # approach later
    company = auth_api_key(request)
    form = validate_form(SubscriptionCancelForm, request)

    guid = request.matchdict['subscription_guid']
    prorated_refund = asbool(form.data.get('prorated_refund', False))
    refund_amount = form.data.get('refund_amount')
    maximum_retry = int(request.registry.settings.get(
        'billy.transaction.maximum_retry', 
        TransactionModel.DEFAULT_MAXIMUM_RETRY,
    ))

    model = SubscriptionModel(request.session)
    tx_model = TransactionModel(request.session)
    get_and_check_subscription(request, company, guid)
    subscription = model.get(guid)

    # TODO: maybe we can find a better way to integrate this with the 
    # form validation?
    if refund_amount is not None:
        if subscription.amount is not None:
            amount = subscription.amount
        else:
            amount = subscription.plan.amount
        if refund_amount > amount:
            return form_errors_to_bad_request(dict(
                refund_amount=['refund_amount cannot be greater than '
                               'subscription amount {}'.format(amount)]
            ))

    if subscription.canceled:
        return HTTPBadRequest('Cannot cancel a canceled subscription')

    with db_transaction.manager:
        tx_guid = model.cancel(
            guid, 
            prorated_refund=prorated_refund,
            refund_amount=refund_amount, 
        )
    if tx_guid is not None:
        with db_transaction.manager:
            tx_model.process_transactions(
                processor=request.processor, 
                guids=[tx_guid],
                maximum_retry=maximum_retry,
            )

    subscription = model.get(guid)
    return subscription 
예제 #19
0
파일: views.py 프로젝트: MDrollette/billy
def subscription_list_post(request):
    """Create a new subscription 

    """
    company = auth_api_key(request)
    form = validate_form(SubscriptionCreateForm, request)

    customer_guid = form.data['customer_guid']
    plan_guid = form.data['plan_guid']
    amount = form.data.get('amount')
    payment_uri = form.data.get('payment_uri')
    if not payment_uri:
        payment_uri = None
    started_at = form.data.get('started_at')
    maximum_retry = int(request.registry.settings.get(
        'billy.transaction.maximum_retry', 
        TransactionModel.DEFAULT_MAXIMUM_RETRY,
    ))

    model = SubscriptionModel(request.session)
    plan_model = PlanModel(request.session)
    customer_model = CustomerModel(request.session)
    tx_model = TransactionModel(request.session)

    customer = customer_model.get(customer_guid)
    if customer.company_guid != company.guid:
        return HTTPForbidden('Can only subscribe to your own customer')
    if customer.deleted:
        return HTTPBadRequest('Cannot subscript to a deleted customer')
    plan = plan_model.get(plan_guid)
    if plan.company_guid != company.guid:
        return HTTPForbidden('Can only subscribe to your own plan')
    if plan.deleted:
        return HTTPBadRequest('Cannot subscript to a deleted plan')

    # create subscription and yield transactions
    with db_transaction.manager:
        guid = model.create(
            customer_guid=customer_guid, 
            plan_guid=plan_guid, 
            amount=amount, 
            payment_uri=payment_uri,
            started_at=started_at, 
        )
        tx_guids = model.yield_transactions([guid])
    # this is not a deferred subscription, just process transactions right away
    if started_at is None:
        with db_transaction.manager:
            tx_model.process_transactions(
                processor=request.processor, 
                guids=tx_guids,
                maximum_retry=maximum_retry,
            )

    subscription = model.get(guid)
    return subscription
예제 #20
0
def subscription_cancel(request):
    """Cancel a subscription

    """
    # TODO: it appears a DELETE request with body is not a good idea
    # for HTTP protocol as many server doesn't support this, this is why
    # we use another view with post method, maybe we should use a better 
    # approach later
    company = auth_api_key(request)
    form = validate_form(SubscriptionCancelForm, request)

    guid = request.matchdict['subscription_guid']
    prorated_refund = asbool(form.data.get('prorated_refund', False))
    refund_amount = form.data.get('refund_amount')
    maximum_retry = int(request.registry.settings.get(
        'billy.transaction.maximum_retry', 
        TransactionModel.DEFAULT_MAXIMUM_RETRY,
    ))

    model = SubscriptionModel(request.session)
    tx_model = TransactionModel(request.session)
    get_and_check_subscription(request, company, guid)

    # TODO: maybe we can find a better way to integrate this with the 
    # form validation?
    if refund_amount is not None:
        subscription = model.get(guid)
        if subscription.amount is not None:
            amount = subscription.amount
        else:
            amount = subscription.plan.amount
        if refund_amount > amount:
            return form_errors_to_bad_request(dict(
                refund_amount=['refund_amount cannot be greater than '
                               'subscription amount {}'.format(amount)]
            ))

    # TODO: make sure the subscription is not already canceled

    with db_transaction.manager:
        tx_guid = model.cancel(
            guid, 
            prorated_refund=prorated_refund,
            refund_amount=refund_amount, 
        )
    if tx_guid is not None:
        with db_transaction.manager:
            tx_model.process_transactions(
                processor=request.processor, 
                guids=[tx_guid],
                maximum_retry=maximum_retry,
            )

    subscription = model.get(guid)
    return subscription 
예제 #21
0
    def test_cancel_subscription_with_refund_amount(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(customer_guid=self.customer_guid, plan_guid=self.plan_guid)
            tx_guid = tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=1000,
                scheduled_at=now,
            )
            subscription = subscription_model.get(subscription_guid)
            subscription.period = 1
            subscription.next_transaction_at = datetime.datetime(2013, 8, 23)
            self.testapp.session.add(subscription)

            transaction = tx_model.get(tx_guid)
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = "MOCK_BALANCED_DEBIT_URI"
            self.testapp.session.add(transaction)

        refund_called = []

        def mock_refund(transaction):
            refund_called.append(transaction)
            return "MOCK_PROCESSOR_REFUND_URI"

        mock_processor = flexmock(DummyProcessor)
        (mock_processor.should_receive("refund").replace_with(mock_refund).once())

        res = self.testapp.post(
            "/v1/subscriptions/{}/cancel".format(subscription_guid),
            dict(refund_amount=234),
            extra_environ=dict(REMOTE_USER=self.api_key),
            status=200,
        )
        subscription = res.json

        transaction = refund_called[0]
        self.testapp.session.add(transaction)
        self.assertEqual(transaction.refund_to.guid, tx_guid)
        self.assertEqual(transaction.subscription_guid, subscription_guid)
        self.assertEqual(transaction.amount, 234)
        self.assertEqual(transaction.status, tx_model.STATUS_DONE)

        res = self.testapp.get("/v1/transactions", extra_environ=dict(REMOTE_USER=self.api_key), status=200)
        guids = [item["guid"] for item in res.json["items"]]
        self.assertEqual(set(guids), set([tx_guid, transaction.guid]))
예제 #22
0
def subscription_list_post(request):
    """Create a new subscription 

    """
    company = auth_api_key(request)
    form = validate_form(SubscriptionCreateForm, request)

    customer_guid = form.data['customer_guid']
    plan_guid = form.data['plan_guid']
    amount = form.data.get('amount')
    payment_uri = form.data.get('payment_uri')
    started_at = form.data.get('started_at')
    maximum_retry = int(request.registry.settings.get(
        'billy.transaction.maximum_retry', 
        TransactionModel.DEFAULT_MAXIMUM_RETRY,
    ))

    model = SubscriptionModel(request.session)
    plan_model = PlanModel(request.session)
    customer_model = CustomerModel(request.session)
    tx_model = TransactionModel(request.session)

    customer = customer_model.get(customer_guid)
    if customer.company_guid != company.guid:
        return HTTPForbidden('Can only subscribe to your own customer')
    plan = plan_model.get(plan_guid)
    if plan.company_guid != company.guid:
        return HTTPForbidden('Can only subscribe to your own plan')
    # TODO: make sure user cannot subscribe to a deleted plan or customer

    # create subscription and yield transactions
    with db_transaction.manager:
        guid = model.create(
            customer_guid=customer_guid, 
            plan_guid=plan_guid, 
            amount=amount, 
            payment_uri=payment_uri,
            started_at=started_at, 
        )
        tx_guids = model.yield_transactions([guid])
    # this is not a deferred subscription, just process transactions right away
    if started_at is None:
        with db_transaction.manager:
            tx_model.process_transactions(
                processor=request.processor, 
                guids=tx_guids,
                maximum_retry=maximum_retry,
            )

    subscription = model.get(guid)
    return subscription
예제 #23
0
파일: views.py 프로젝트: JeffersonK/billy
def transaction_get(request):
    """Get and return a transaction 

    """
    company = auth_api_key(request)
    model = TransactionModel(request.session)
    guid = request.matchdict['transaction_guid']
    transaction = model.get(guid)
    if transaction is None:
        return HTTPNotFound('No such transaction {}'.format(guid))
    if transaction.subscription.customer.company_guid != company.guid:
        return HTTPForbidden('You have no permission to access transaction {}'
                             .format(guid))
    return transaction 
예제 #24
0
def transaction_get(request):
    """Get and return a transaction 

    """
    company = auth_api_key(request)
    model = TransactionModel(request.session)
    guid = request.matchdict['transaction_guid']
    transaction = model.get(guid)
    if transaction is None:
        return HTTPNotFound('No such transaction {}'.format(guid))
    if transaction.subscription.customer.company_guid != company.guid:
        return HTTPForbidden(
            'You have no permission to access transaction {}'.format(guid))
    return transaction
예제 #25
0
def server_info(request):
    """Get server information

    """
    tx_model = TransactionModel(request.session)
    last_transaction = tx_model.get_last_transaction()
    last_transaction_dt = None
    if last_transaction is not None:
        last_transaction_dt = last_transaction.created_at.isoformat()
    return dict(
        server='Billy - The recurring payment server',
        powered_by='BalancedPayments.com',
        revision=get_git_rev(),
        last_transaction_created_at=last_transaction_dt,
    )
예제 #26
0
 def setUp(self):
     from billy.models.company import CompanyModel
     from billy.models.customer import CustomerModel
     from billy.models.plan import PlanModel
     from billy.models.subscription import SubscriptionModel
     from billy.models.transaction import TransactionModel
     super(TestBalancedProcessorModel, self).setUp()
     # build the basic scenario for transaction model
     self.company_model = CompanyModel(self.session)
     self.customer_model = CustomerModel(self.session)
     self.plan_model = PlanModel(self.session)
     self.subscription_model = SubscriptionModel(self.session)
     self.transaction_model = TransactionModel(self.session)
     with db_transaction.manager:
         self.company_guid = self.company_model.create('my_secret_key')
         self.plan_guid = self.plan_model.create(
             company_guid=self.company_guid,
             plan_type=self.plan_model.TYPE_CHARGE,
             amount=10,
             frequency=self.plan_model.FREQ_MONTHLY,
         )
         self.customer_guid = self.customer_model.create(
             company_guid=self.company_guid,
         )
         self.subscription_guid = self.subscription_model.create(
             customer_guid=self.customer_guid,
             plan_guid=self.plan_guid,
             payment_uri='/v1/credit_card/tester',
         )
예제 #27
0
    def test_subscription_cancel_not_done_transactions(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )

        # okay, 08-16, 09-16, 10-16, 11-16, so we should have 4 new transactions
        # and we assume the transactions status as shown as following:
        # 
        #     [DONE, RETRYING, INIT, FAILED]
        #
        # when we cancel the subscription, the status should be
        # 
        #     [DONE, CANCELED, CANCELED, FAILED]
        #
        init_status = [
            tx_model.STATUS_DONE,
            tx_model.STATUS_RETRYING,
            tx_model.STATUS_INIT,
            tx_model.STATUS_FAILED,
        ]
        with freeze_time('2013-11-16'):
            with db_transaction.manager:
                tx_guids = model.yield_transactions()
                for tx_guid, status in zip(tx_guids, init_status):
                    transaction = tx_model.get(tx_guid)
                    transaction.status = status
                    self.session.add(transaction)
                self.session.add(transaction)
            with db_transaction.manager:
                model.cancel(guid)

        transactions = [tx_model.get(tx_guid) for tx_guid in tx_guids]
        status_list = [tx.status for tx in transactions]
        self.assertEqual(status_list, [
            tx_model.STATUS_DONE, 
            tx_model.STATUS_CANCELED,
            tx_model.STATUS_CANCELED,
            tx_model.STATUS_FAILED,
        ])
예제 #28
0
    def test_subscription_cancel_not_done_transactions(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )

        # okay, 08-16, 09-16, 10-16, 11-16, so we should have 4 new transactions
        # and we assume the transactions status as shown as following:
        #
        #     [DONE, RETRYING, INIT, FAILED]
        #
        # when we cancel the subscription, the status should be
        #
        #     [DONE, CANCELED, CANCELED, FAILED]
        #
        init_status = [
            tx_model.STATUS_DONE,
            tx_model.STATUS_RETRYING,
            tx_model.STATUS_INIT,
            tx_model.STATUS_FAILED,
        ]
        with freeze_time('2013-11-16'):
            with db_transaction.manager:
                tx_guids = model.yield_transactions()
                for tx_guid, status in zip(tx_guids, init_status):
                    transaction = tx_model.get(tx_guid)
                    transaction.status = status
                    self.session.add(transaction)
                self.session.add(transaction)
            with db_transaction.manager:
                model.cancel(guid)

        transactions = [tx_model.get(tx_guid) for tx_guid in tx_guids]
        status_list = [tx.status for tx in transactions]
        self.assertEqual(status_list, [
            tx_model.STATUS_DONE,
            tx_model.STATUS_CANCELED,
            tx_model.STATUS_CANCELED,
            tx_model.STATUS_FAILED,
        ])
예제 #29
0
    def test_server_info_with_transaction(self):
        from billy.models.company import CompanyModel
        from billy.models.customer import CustomerModel
        from billy.models.plan import PlanModel
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        company_model = CompanyModel(self.testapp.session)
        customer_model = CustomerModel(self.testapp.session)
        plan_model = PlanModel(self.testapp.session)
        subscription_model = SubscriptionModel(self.testapp.session)
        transaction_model = TransactionModel(self.testapp.session)

        with db_transaction.manager:
            company_guid = company_model.create(
                processor_key='MOCK_PROCESSOR_KEY',
            )
            customer_guid = customer_model.create(
                company_guid=company_guid
            )
            plan_guid = plan_model.create(
                company_guid=company_guid,
                frequency=plan_model.FREQ_WEEKLY,
                plan_type=plan_model.TYPE_CHARGE,
                amount=10,
            )
            subscription_guid = subscription_model.create(
                customer_guid=customer_guid,
                plan_guid=plan_guid,
            )
            transaction_guid = transaction_model.create(
                subscription_guid=subscription_guid,
                transaction_type=transaction_model.TYPE_CHARGE,
                amount=10,
                payment_uri='/v1/cards/tester',
                scheduled_at=datetime.datetime.utcnow(),
            )

        transaction = transaction_model.get(transaction_guid)

        res = self.testapp.get('/', status=200)
        self.assertEqual(res.json['last_transaction_created_at'], 
                         transaction.created_at.isoformat())
예제 #30
0
    def test_transaction(self):
        from billy.models.transaction import TransactionModel
        from billy.renderers import transaction_adapter
        transaction_model = TransactionModel(self.testapp.session)
        transaction = transaction_model.get(self.transaction_guid)
        json_data = transaction_adapter(transaction, self.dummy_request)
        expected = dict(
            guid=transaction.guid, 
            transaction_type='charge',
            status='init',
            amount=transaction.amount,
            payment_uri=transaction.payment_uri,
            external_id=transaction.external_id,
            failure_count=transaction.failure_count,
            error_message=transaction.error_message,
            created_at=transaction.created_at.isoformat(),
            updated_at=transaction.updated_at.isoformat(),
            scheduled_at=transaction.scheduled_at.isoformat(),
            subscription_guid=transaction.subscription_guid,
        )
        self.assertEqual(json_data, expected)

        def assert_type(transaction_type, expected_type):
            transaction.transaction_type = transaction_type
            json_data = transaction_adapter(transaction, self.dummy_request)
            self.assertEqual(json_data['transaction_type'], expected_type)

        assert_type(TransactionModel.TYPE_CHARGE, 'charge')
        assert_type(TransactionModel.TYPE_PAYOUT, 'payout')
        assert_type(TransactionModel.TYPE_REFUND, 'refund')

        def assert_status(transaction_status, expected_status):
            transaction.status = transaction_status
            json_data = transaction_adapter(transaction, self.dummy_request)
            self.assertEqual(json_data['status'], expected_status)

        assert_status(TransactionModel.STATUS_INIT, 'init')
        assert_status(TransactionModel.STATUS_RETRYING, 'retrying')
        assert_status(TransactionModel.STATUS_FAILED, 'failed')
        assert_status(TransactionModel.STATUS_DONE, 'done')
        assert_status(TransactionModel.STATUS_CANCELED, 'canceled')
예제 #31
0
 def test_get_transaction_of_other_company(self):
     from billy.models.company import CompanyModel
     from billy.models.customer import CustomerModel
     from billy.models.plan import PlanModel
     from billy.models.subscription import SubscriptionModel
     from billy.models.transaction import TransactionModel
     company_model = CompanyModel(self.testapp.session)
     customer_model = CustomerModel(self.testapp.session)
     plan_model = PlanModel(self.testapp.session)
     subscription_model = SubscriptionModel(self.testapp.session)
     transaction_model = TransactionModel(self.testapp.session)
     with db_transaction.manager:
         other_company_guid = company_model.create(
             processor_key='MOCK_PROCESSOR_KEY',
         )
         other_customer_guid = customer_model.create(
             company_guid=other_company_guid
         )
         other_plan_guid = plan_model.create(
             company_guid=other_company_guid,
             frequency=plan_model.FREQ_WEEKLY,
             plan_type=plan_model.TYPE_CHARGE,
             amount=10,
         )
         other_subscription_guid = subscription_model.create(
             customer_guid=other_customer_guid,
             plan_guid=other_plan_guid,
         )
         other_transaction_guid = transaction_model.create(
             subscription_guid=other_subscription_guid,
             transaction_type=transaction_model.TYPE_CHARGE,
             amount=10,
             payment_uri='/v1/cards/tester',
             scheduled_at=datetime.datetime.utcnow(),
         )
     self.testapp.get(
         '/v1/transactions/{}'.format(other_transaction_guid), 
         extra_environ=dict(REMOTE_USER=self.api_key), 
         status=403,
     )
예제 #32
0
    def test_cancel_subscription_with_bad_arguments(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
                amount=100,
            )
            tx_guid = tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=100,
                scheduled_at=now,
            )
            subscription = subscription_model.get(subscription_guid)
            subscription.period = 1
            subscription.next_transaction_at = datetime.datetime(2013, 8, 23)
            self.testapp.session.add(subscription)

            transaction = tx_model.get(tx_guid)
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.testapp.session.add(transaction)

        def assert_bad_parameters(kwargs):
            self.testapp.post(
                '/v1/subscriptions/{}/cancel'.format(subscription_guid),
                kwargs,
                extra_environ=dict(REMOTE_USER=self.api_key),
                status=400,
            )

        assert_bad_parameters(dict(prorated_refund=True, refund_amount=10))
        assert_bad_parameters(dict(refund_amount='100.01'))
예제 #33
0
    def test_cancel_subscription_with_bad_arguments(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
                amount=100,
            )
            tx_guid = tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=100, 
                scheduled_at=now,
            )
            subscription = subscription_model.get(subscription_guid)
            subscription.period = 1
            subscription.next_transaction_at = datetime.datetime(2013, 8, 23)
            self.testapp.session.add(subscription)

            transaction = tx_model.get(tx_guid)
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.testapp.session.add(transaction)

        def assert_bad_parameters(kwargs):
            self.testapp.post(
                '/v1/subscriptions/{}/cancel'.format(subscription_guid), 
                kwargs,
                extra_environ=dict(REMOTE_USER=self.api_key), 
                status=400,
            )
        assert_bad_parameters(dict(prorated_refund=True, refund_amount=10))
        assert_bad_parameters(dict(refund_amount='100.01'))
예제 #34
0
 def setUp(self):
     from pyramid.testing import DummyRequest
     from billy.models.company import CompanyModel
     from billy.models.customer import CustomerModel
     from billy.models.plan import PlanModel
     from billy.models.subscription import SubscriptionModel
     from billy.models.transaction import TransactionModel
     super(TestRenderer, self).setUp()
     company_model = CompanyModel(self.testapp.session)
     customer_model = CustomerModel(self.testapp.session)
     plan_model = PlanModel(self.testapp.session)
     subscription_model = SubscriptionModel(self.testapp.session)
     transaction_model = TransactionModel(self.testapp.session)
     with db_transaction.manager:
         self.company_guid = company_model.create(
             processor_key='MOCK_PROCESSOR_KEY',
         )
         self.customer_guid = customer_model.create(
             company_guid=self.company_guid
         )
         self.plan_guid = plan_model.create(
             company_guid=self.company_guid,
             frequency=plan_model.FREQ_WEEKLY,
             plan_type=plan_model.TYPE_CHARGE,
             amount=10,
         )
         self.subscription_guid = subscription_model.create(
             customer_guid=self.customer_guid,
             plan_guid=self.plan_guid,
         )
         self.transaction_guid = transaction_model.create(
             subscription_guid=self.subscription_guid,
             transaction_type=transaction_model.TYPE_CHARGE,
             amount=10,
             payment_uri='/v1/cards/tester',
             scheduled_at=datetime.datetime.utcnow(),
         )
     self.dummy_request = DummyRequest()
예제 #35
0
    def test_subscription_cancel_with_wrong_arguments(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions()
            transaction = tx_model.get(tx_guids[0])
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.session.add(transaction)

        # we should not allow both prorated_refund and refund_amount to
        # be set
        with self.assertRaises(ValueError):
            model.cancel(guid, prorated_refund=True, refund_amount=10)
        # we should not allow refunding amount that grather than original
        # subscription amount
        with self.assertRaises(ValueError):
            model.cancel(guid, refund_amount=decimal.Decimal('10.01'))
예제 #36
0
def subscription_transaction_list(request):
    """Get and return transactions of subscription

    """
    company = auth_api_key(request)

    offset = int(request.params.get('offset', 0))
    limit = int(request.params.get('limit', 20))
    guid = request.matchdict['subscription_guid']

    tx_model = TransactionModel(request.session)
    subscription = get_and_check_subscription(request, company, guid)

    transactions = tx_model.list_by_subscription_guid(
        subscription_guid=subscription.guid,
        offset=offset,
        limit=limit,
    )
    result = dict(
        items=list(transactions),
        offset=offset,
        limit=limit,
    )
    return result
예제 #37
0
파일: views.py 프로젝트: MDrollette/billy
def subscription_transaction_list(request):
    """Get and return transactions of subscription

    """
    company = auth_api_key(request)

    offset = int(request.params.get('offset', 0))
    limit = int(request.params.get('limit', 20))
    guid = request.matchdict['subscription_guid']

    tx_model = TransactionModel(request.session)
    subscription = get_and_check_subscription(request, company, guid)

    transactions = tx_model.list_by_subscription_guid(
        subscription_guid=subscription.guid,
        offset=offset,
        limit=limit,
    )
    result = dict(
        items=list(transactions),
        offset=offset,
        limit=limit,
    )
    return result
예제 #38
0
    def test_subscription_cancel_with_wrong_arguments(self):
        from billy.models.transaction import TransactionModel
        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions()
            transaction = tx_model.get(tx_guids[0])
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.session.add(transaction)

        # we should not allow both prorated_refund and refund_amount to 
        # be set
        with self.assertRaises(ValueError):
            model.cancel(guid, prorated_refund=True, refund_amount=10)
        # we should not allow refunding amount that grather than original
        # subscription amount
        with self.assertRaises(ValueError):
            model.cancel(guid, refund_amount=1001)
예제 #39
0
def main(argv=sys.argv, processor=None):
    logger = logging.getLogger(__name__)

    if len(argv) != 2:
        usage(argv)
    config_uri = argv[1]
    setup_logging(config_uri)
    settings = get_appsettings(config_uri)
    settings = setup_database({}, **settings)

    session = settings['session']
    subscription_model = SubscriptionModel(session)
    tx_model = TransactionModel(session)

    maximum_retry = int(
        settings.get(
            'billy.transaction.maximum_retry',
            TransactionModel.DEFAULT_MAXIMUM_RETRY,
        ))

    resolver = DottedNameResolver()
    if processor is None:
        processor_factory = settings['billy.processor_factory']
        processor_factory = resolver.maybe_resolve(processor_factory)
        processor = processor_factory()

    # yield all transactions and commit before we process them, so that
    # we won't double process them.
    with db_transaction.manager:
        logger.info('Yielding transaction ...')
        subscription_model.yield_transactions()

    with db_transaction.manager:
        logger.info('Processing transaction ...')
        tx_model.process_transactions(processor, maximum_retry=maximum_retry)
    logger.info('Done')
예제 #40
0
    def cancel(self, guid, prorated_refund=False, refund_amount=None):
        """Cancel a subscription

        :param guid: the guid of subscription to cancel
        :param prorated_refund: Should we generate a prorated refund 
            transaction according to remaining time of subscription period?
        :param refund_amount: if refund_amount is given, it will be used 
            to refund customer, you cannot set prorated_refund with 
            refund_amount
        :return: if prorated_refund is True, and the subscription is 
            refundable, the refund transaction guid will be returned
        """
        if prorated_refund and refund_amount is not None:
            raise ValueError('You cannot set refund_amount when '
                             'prorated_refund is True')

        tx_model = TransactionModel(self.session)

        subscription = self.get(guid, raise_error=True)
        if subscription.canceled:
            raise SubscriptionCanceledError('Subscription {} is already '
                                            'canceled'.format(guid))
        now = tables.now_func()
        subscription.canceled = True
        subscription.canceled_at = now
        tx_guid = None

        # should we do refund
        do_refund = False
        # we want to do a prorated refund here, however, if there is no any 
        # issued transaction, then no need to do a refund, just skip
        if (
            (prorated_refund or refund_amount is not None) and 
            subscription.period
        ):
            previous_transaction = (
                self.session.query(tables.Transaction)
                .filter_by(subscription_guid=subscription.guid)
                .order_by(tables.Transaction.scheduled_at.desc())
                .first()
            )
            # it is possible the previous transaction is failed or retrying,
            # so that we should only refund finished transaction
            if previous_transaction.status == TransactionModel.STATUS_DONE:
                do_refund = True

        if do_refund:
            if prorated_refund:
                previous_datetime = previous_transaction.scheduled_at
                # the total time delta in the period
                total_delta = (
                    subscription.next_transaction_at - previous_datetime
                )
                total_seconds = decimal.Decimal(total_delta.total_seconds())
                # the passed time so far since last transaction
                elapsed_delta = now - previous_datetime
                elapsed_seconds = decimal.Decimal(elapsed_delta.total_seconds())

                # TODO: what about calculate in different granularity here?
                #       such as day or hour granularity?
                rate = 1 - (elapsed_seconds / total_seconds)
                amount = previous_transaction.amount * rate
                amount = round_down_cent(amount)
            else:
                amount = round_down_cent(decimal.Decimal(refund_amount))
                if amount > previous_transaction.amount:
                    raise ValueError('refund_amount cannot be grather than '
                                     'subscription amount {}'
                                     .format(previous_transaction.amount))

            # make sure we will not refund zero dollar
            # TODO: or... should we?
            if amount:
                tx_guid = tx_model.create(
                    subscription_guid=subscription.guid, 
                    amount=amount, 
                    transaction_type=tx_model.TYPE_REFUND, 
                    scheduled_at=subscription.next_transaction_at, 
                    refund_to_guid=previous_transaction.guid, 
                )

        # cancel not done transactions (exclude refund transaction)
        Transaction = tables.Transaction
        not_done_transactions = (
            self.session.query(Transaction)
            .filter_by(subscription_guid=guid)
            .filter(Transaction.transaction_type != TransactionModel.TYPE_REFUND)
            .filter(Transaction.status.in_([
                tx_model.STATUS_INIT,
                tx_model.STATUS_RETRYING,
            ]))
        )
        not_done_transactions.update(dict(
            status=tx_model.STATUS_CANCELED,
            updated_at=now, 
        ), synchronize_session='fetch')

        self.session.add(subscription)
        self.session.flush()
        return tx_guid 
예제 #41
0
 def make_one(self, *args, **kwargs):
     from billy.models.transaction import TransactionModel
     return TransactionModel(*args, **kwargs)
예제 #42
0
    def test_cancel_subscription_with_prorated_refund(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
                amount=100,
            )
            tx_guid = tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=100, 
                scheduled_at=now,
            )
            subscription = subscription_model.get(subscription_guid)
            subscription.period = 1
            subscription.next_transaction_at = datetime.datetime(2013, 8, 23)
            self.testapp.session.add(subscription)

            transaction = tx_model.get(tx_guid)
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.testapp.session.add(transaction)

        refund_called = []

        def mock_refund(transaction):
            refund_called.append(transaction)
            return 'MOCK_PROCESSOR_REFUND_URI'

        mock_processor = flexmock(DummyProcessor)
        (
            mock_processor
            .should_receive('refund')
            .replace_with(mock_refund)
            .once()
        )

        with freeze_time('2013-08-17'):
            canceled_at = datetime.datetime.utcnow()
            res = self.testapp.post(
                '/v1/subscriptions/{}/cancel'.format(subscription_guid), 
                dict(prorated_refund=True),
                extra_environ=dict(REMOTE_USER=self.api_key), 
                status=200,
            )

        subscription = res.json
        self.assertEqual(subscription['canceled'], True)
        self.assertEqual(subscription['canceled_at'], canceled_at.isoformat())

        transaction = refund_called[0]
        self.testapp.session.add(transaction)
        self.assertEqual(transaction.refund_to.guid, tx_guid)
        self.assertEqual(transaction.subscription_guid, subscription_guid)
        # only one day is elapsed, and it is a weekly plan, so
        # it should be 100 - (100 / 7) and round to cent, 85.71
        self.assertEqual(transaction.amount, decimal.Decimal('85.71'))
        self.assertEqual(transaction.status, tx_model.STATUS_DONE)

        res = self.testapp.get(
            '/v1/transactions', 
            extra_environ=dict(REMOTE_USER=self.api_key), 
            status=200,
        )
        guids = [item['guid'] for item in res.json['items']]
        self.assertEqual(set(guids), set([tx_guid, transaction.guid]))
예제 #43
0
    def test_yield_transactions(self):
        from billy.models.transaction import TransactionModel

        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions()

        self.assertEqual(len(tx_guids), 1)

        subscription = model.get(guid)
        transactions = subscription.transactions
        self.assertEqual(len(transactions), 1)

        transaction = transactions[0]
        self.assertEqual(transaction.guid, tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.amount, subscription.plan.amount)
        self.assertEqual(transaction.transaction_type,
                         TransactionModel.TYPE_CHARGE)
        self.assertEqual(transaction.scheduled_at, now)
        self.assertEqual(transaction.created_at, now)
        self.assertEqual(transaction.updated_at, now)
        self.assertEqual(transaction.status, TransactionModel.STATUS_INIT)

        # we should not yield new transaction as the datetime is the same
        with db_transaction.manager:
            tx_guids = model.yield_transactions()
        self.assertFalse(tx_guids)
        subscription = model.get(guid)
        self.assertEqual(len(subscription.transactions), 1)

        # should not yield new transaction as 09-16 is the date
        with freeze_time('2013-09-15'):
            with db_transaction.manager:
                tx_guids = model.yield_transactions()
        self.assertFalse(tx_guids)
        subscription = model.get(guid)
        self.assertEqual(len(subscription.transactions), 1)

        # okay, should yield new transaction now
        with freeze_time('2013-09-16'):
            with db_transaction.manager:
                tx_guids = model.yield_transactions()
            scheduled_at = datetime.datetime.utcnow()
        self.assertEqual(len(tx_guids), 1)
        subscription = model.get(guid)
        self.assertEqual(len(subscription.transactions), 2)

        transaction = tx_model.get(tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.amount, subscription.plan.amount)
        self.assertEqual(transaction.transaction_type,
                         TransactionModel.TYPE_CHARGE)
        self.assertEqual(transaction.scheduled_at, scheduled_at)
        self.assertEqual(transaction.created_at, scheduled_at)
        self.assertEqual(transaction.updated_at, scheduled_at)
        self.assertEqual(transaction.status, TransactionModel.STATUS_INIT)
예제 #44
0
    def create_transaction_model(self):
        """Create a transaction model

        """
        return TransactionModel(self)
예제 #45
0
    def test_cancel_subscription_with_refund_amount(self):
        from billy.models.subscription import SubscriptionModel
        from billy.models.transaction import TransactionModel

        subscription_model = SubscriptionModel(self.testapp.session)
        tx_model = TransactionModel(self.testapp.session)
        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            subscription_guid = subscription_model.create(
                customer_guid=self.customer_guid,
                plan_guid=self.plan_guid,
            )
            tx_guid = tx_model.create(
                subscription_guid=subscription_guid,
                transaction_type=tx_model.TYPE_CHARGE,
                amount=10,
                scheduled_at=now,
            )
            subscription = subscription_model.get(subscription_guid)
            subscription.period = 1
            subscription.next_transaction_at = datetime.datetime(2013, 8, 23)
            self.testapp.session.add(subscription)

            transaction = tx_model.get(tx_guid)
            transaction.status = tx_model.STATUS_DONE
            transaction.external_id = 'MOCK_BALANCED_DEBIT_URI'
            self.testapp.session.add(transaction)

        refund_called = []

        def mock_refund(transaction):
            refund_called.append(transaction)
            return 'MOCK_PROCESSOR_REFUND_URI'

        mock_processor = flexmock(DummyProcessor)
        (mock_processor.should_receive('refund').replace_with(
            mock_refund).once())

        res = self.testapp.post(
            '/v1/subscriptions/{}/cancel'.format(subscription_guid),
            dict(refund_amount='2.34'),
            extra_environ=dict(REMOTE_USER=self.api_key),
            status=200,
        )
        subscription = res.json

        transaction = refund_called[0]
        self.testapp.session.add(transaction)
        self.assertEqual(transaction.refund_to.guid, tx_guid)
        self.assertEqual(transaction.subscription_guid, subscription_guid)
        self.assertEqual(transaction.amount, decimal.Decimal('2.34'))
        self.assertEqual(transaction.status, tx_model.STATUS_DONE)

        res = self.testapp.get(
            '/v1/transactions',
            extra_environ=dict(REMOTE_USER=self.api_key),
            status=200,
        )
        guids = [item['guid'] for item in res.json['items']]
        self.assertEqual(set(guids), set([tx_guid, transaction.guid]))
예제 #46
0
    def test_yield_transactions(self):
        from billy.models.transaction import TransactionModel

        model = self.make_one(self.session)
        tx_model = TransactionModel(self.session)

        now = datetime.datetime.utcnow()

        with db_transaction.manager:
            guid = model.create(
                customer_guid=self.customer_tom_guid,
                plan_guid=self.monthly_plan_guid,
            )
            tx_guids = model.yield_transactions()

        self.assertEqual(len(tx_guids), 1)

        subscription = model.get(guid)
        transactions = subscription.transactions
        self.assertEqual(len(transactions), 1)

        transaction = transactions[0]
        self.assertEqual(transaction.guid, tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.amount, subscription.plan.amount)
        self.assertEqual(transaction.transaction_type, 
                         TransactionModel.TYPE_CHARGE)
        self.assertEqual(transaction.scheduled_at, now)
        self.assertEqual(transaction.created_at, now)
        self.assertEqual(transaction.updated_at, now)
        self.assertEqual(transaction.status, TransactionModel.STATUS_INIT)

        # we should not yield new transaction as the datetime is the same
        with db_transaction.manager:
            tx_guids = model.yield_transactions()
        self.assertFalse(tx_guids)
        subscription = model.get(guid)
        self.assertEqual(len(subscription.transactions), 1)

        # should not yield new transaction as 09-16 is the date
        with freeze_time('2013-09-15'):
            with db_transaction.manager:
                tx_guids = model.yield_transactions()
        self.assertFalse(tx_guids)
        subscription = model.get(guid)
        self.assertEqual(len(subscription.transactions), 1)

        # okay, should yield new transaction now
        with freeze_time('2013-09-16'):
            with db_transaction.manager:
                tx_guids = model.yield_transactions()
            scheduled_at = datetime.datetime.utcnow()
        self.assertEqual(len(tx_guids), 1)
        subscription = model.get(guid)
        self.assertEqual(len(subscription.transactions), 2)

        transaction = tx_model.get(tx_guids[0])
        self.assertEqual(transaction.subscription_guid, guid)
        self.assertEqual(transaction.amount, subscription.plan.amount)
        self.assertEqual(transaction.transaction_type, 
                         TransactionModel.TYPE_CHARGE)
        self.assertEqual(transaction.scheduled_at, scheduled_at)
        self.assertEqual(transaction.created_at, scheduled_at)
        self.assertEqual(transaction.updated_at, scheduled_at)
        self.assertEqual(transaction.status, TransactionModel.STATUS_INIT)
예제 #47
0
    def yield_transactions(self, subscription_guids=None, now=None):
        """Generate new necessary transactions according to subscriptions we 
        had return guid list

        :param subscription_guids: A list subscription guid to yield 
            transaction_type from, if None is given, all subscriptions
            in the database will be the yielding source
        :param now: the current date time to use, now_func() will be used by 
            default
        :return: a generated transaction guid list
        """
        from sqlalchemy.sql.expression import not_

        if now is None:
            now = tables.now_func()

        tx_model = TransactionModel(self.session)
        Subscription = tables.Subscription

        transaction_guids = []

        # as we may have multiple new transactions for one subscription to
        # process, for example, we didn't run this method for a long while,
        # in this case, we need to make sure all transactions are yielded
        while True:
            # find subscriptions which should yield new transactions
            query = (
                self.session.query(Subscription)
                .filter(Subscription.next_transaction_at <= now)
                .filter(not_(Subscription.canceled))
            )
            if subscription_guids is not None:
                query = query.filter(Subscription.guid.in_(subscription_guids))
            subscriptions = query.all()

            # okay, we have no more transaction to process, just break
            if not subscriptions:
                self.logger.info('No more subscriptions to process')
                break

            for subscription in subscriptions:
                if subscription.plan.plan_type == PlanModel.TYPE_CHARGE:
                    transaction_type = tx_model.TYPE_CHARGE
                elif subscription.plan.plan_type == PlanModel.TYPE_PAYOUT:
                    transaction_type = tx_model.TYPE_PAYOUT
                else:
                    raise ValueError('Unknown plan type {} to process'
                                     .format(subscription.plan.plan_type))
                # when amount of subscription is given, we should use it
                # instead the one from plan
                if subscription.amount is None:
                    amount = subscription.plan.amount 
                else:
                    amount = subscription.amount
                type_map = {
                    tx_model.TYPE_CHARGE: 'charge',
                    tx_model.TYPE_PAYOUT: 'payout',
                }
                self.logger.debug(
                    'Creating transaction for %s, transaction_type=%s, '
                    'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', 
                    subscription.guid, 
                    type_map[transaction_type],
                    subscription.payment_uri,
                    amount,
                    subscription.next_transaction_at, 
                    subscription.period, 
                )
                # create the new transaction for this subscription
                guid = tx_model.create(
                    subscription_guid=subscription.guid, 
                    payment_uri=subscription.payment_uri, 
                    amount=amount, 
                    transaction_type=transaction_type, 
                    scheduled_at=subscription.next_transaction_at, 
                )
                self.logger.info(
                    'Created transaction for %s, guid=%s, transaction_type=%s, '
                    'payment_uri=%s, amount=%s, scheduled_at=%s, period=%s', 
                    subscription.guid, 
                    guid,
                    type_map[transaction_type],
                    subscription.payment_uri,
                    amount,
                    subscription.next_transaction_at, 
                    subscription.period, 
                )
                # advance the next transaction time
                subscription.period += 1
                subscription.next_transaction_at = next_transaction_datetime(
                    started_at=subscription.started_at,
                    frequency=subscription.plan.frequency, 
                    period=subscription.period,
                    interval=subscription.plan.interval, 
                )
                self.session.add(subscription)
                self.session.flush()
                transaction_guids.append(guid)

        self.session.flush()
        return transaction_guids