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_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 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_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_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_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(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_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) # 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 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 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_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 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 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 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_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 make_one(self, *args, **kwargs): from billy.models.transaction import TransactionModel return TransactionModel(*args, **kwargs)
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)