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)
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]))
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)
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')
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]))
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]))
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())
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'))
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'))
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'))
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'))
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)
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)
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, )
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)
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
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
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
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]))
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
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
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
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, )
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', )
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, ])
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())
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')
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, )
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'))
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()
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'))
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
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)
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')
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
def make_one(self, *args, **kwargs): from billy.models.transaction import TransactionModel return TransactionModel(*args, **kwargs)
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]))
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)
def create_transaction_model(self): """Create a transaction model """ return TransactionModel(self)
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]))
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