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 super(TestTransactionModel, 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) 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/cards/tester', )
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_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 test_create_subscription(self): from billy.models.subscription import SubscriptionModel from billy.models.transaction import TransactionModel customer_guid = self.customer_guid plan_guid = self.plan_guid amount = '55.66' payment_uri = 'MOCK_CARD_URI' now = datetime.datetime.utcnow() now_iso = now.isoformat() # next week next_transaction_at = datetime.datetime(2013, 8, 23) next_iso = next_transaction_at.isoformat() def mock_charge(transaction): self.assertEqual(transaction.subscription.customer_guid, customer_guid) self.assertEqual(transaction.subscription.plan_guid, plan_guid) return 'MOCK_PROCESSOR_TRANSACTION_ID' mock_processor = flexmock(DummyProcessor) (mock_processor.should_receive('create_customer').once()) (mock_processor.should_receive('charge').replace_with( mock_charge).once()) res = self.testapp.post( '/v1/subscriptions', dict( customer_guid=customer_guid, plan_guid=plan_guid, amount=amount, payment_uri=payment_uri, ), extra_environ=dict(REMOTE_USER=self.api_key), status=200, ) self.failUnless('guid' in res.json) self.assertEqual(res.json['created_at'], now_iso) self.assertEqual(res.json['updated_at'], now_iso) self.assertEqual(res.json['canceled_at'], None) self.assertEqual(res.json['next_transaction_at'], next_iso) self.assertEqual(res.json['period'], 1) self.assertEqual(res.json['amount'], amount) self.assertEqual(res.json['customer_guid'], customer_guid) self.assertEqual(res.json['plan_guid'], plan_guid) self.assertEqual(res.json['payment_uri'], payment_uri) subscription_model = SubscriptionModel(self.testapp.session) subscription = subscription_model.get(res.json['guid']) self.assertEqual(len(subscription.transactions), 1) transaction = subscription.transactions[0] self.assertEqual(transaction.external_id, 'MOCK_PROCESSOR_TRANSACTION_ID') self.assertEqual(transaction.status, TransactionModel.STATUS_DONE)
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 get_and_check_subscription(request, company, guid): """Get and check permission to access a subscription """ model = SubscriptionModel(request.session) subscription = model.get(guid) if subscription is None: raise HTTPNotFound('No such subscription {}'.format(guid)) if subscription.customer.company_guid != company.guid: raise HTTPForbidden('You have no permission to access subscription {}' .format(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 test_transaction_list_by_subscription_with_bad_api_key(self): from billy.models.subscription import SubscriptionModel subscription_model = SubscriptionModel(self.testapp.session) with db_transaction.manager: subscription_guid = subscription_model.create( customer_guid=self.customer_guid, plan_guid=self.plan_guid, ) self.testapp.get( '/v1/subscriptions/{}/transactions'.format(subscription_guid), extra_environ=dict(REMOTE_USER=b'BAD_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 test_subscription_list(self): from billy.models.subscription import SubscriptionModel subscription_model = SubscriptionModel(self.testapp.session) with db_transaction.manager: guids = [] for i in range(4): with freeze_time('2013-08-16 00:00:{:02}'.format(i + 1)): guid = subscription_model.create( customer_guid=self.customer_guid, plan_guid=self.plan_guid, ) guids.append(guid) guids = list(reversed(guids)) res = self.testapp.get( '/v1/subscriptions', 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, guids)
def test_cancel_subscription_to_other_company(self): from billy.models.subscription import SubscriptionModel from billy.models.company import CompanyModel subscription_model = SubscriptionModel(self.testapp.session) company_model = CompanyModel(self.testapp.session) with db_transaction.manager: subscription_guid = subscription_model.create( customer_guid=self.customer_guid, plan_guid=self.plan_guid, ) other_company_guid = company_model.create( processor_key='MOCK_PROCESSOR_KEY', ) other_company = company_model.get(other_company_guid) other_api_key = str(other_company.api_key) self.testapp.post( '/v1/subscriptions/{}/cancel'.format(subscription_guid), extra_environ=dict(REMOTE_USER=other_api_key), status=403, )
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_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 test_main_with_crash(self): from pyramid.paster import get_appsettings from billy.models import setup_database 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.scripts import initializedb from billy.scripts import process_transactions class MockProcessor(object): def __init__(self): self.charges = {} self.tx_sn = 0 self.called_times = 0 def create_customer(self, customer): return 'MOCK_PROCESSOR_CUSTOMER_ID' def prepare_customer(self, customer, payment_uri=None): pass def charge(self, transaction): self.called_times += 1 if self.called_times == 2: raise KeyboardInterrupt guid = transaction.guid if guid in self.charges: return self.charges[guid] self.charges[guid] = self.tx_sn self.tx_sn += 1 mock_processor = MockProcessor() cfg_path = os.path.join(self.temp_dir, 'config.ini') with open(cfg_path, 'wt') as f: f.write( textwrap.dedent("""\ [app:main] use = egg:billy sqlalchemy.url = sqlite:///%(here)s/billy.sqlite """)) initializedb.main([initializedb.__file__, cfg_path]) settings = get_appsettings(cfg_path) settings = setup_database({}, **settings) session = settings['session'] company_model = CompanyModel(session) customer_model = CustomerModel(session) plan_model = PlanModel(session) subscription_model = SubscriptionModel(session) with db_transaction.manager: company_guid = company_model.create('my_secret_key') plan_guid = plan_model.create( company_guid=company_guid, plan_type=plan_model.TYPE_CHARGE, amount=10, frequency=plan_model.FREQ_MONTHLY, ) customer_guid = customer_model.create(company_guid=company_guid, ) subscription_model.create( customer_guid=customer_guid, plan_guid=plan_guid, payment_uri='/v1/cards/tester', ) subscription_model.create( customer_guid=customer_guid, plan_guid=plan_guid, payment_uri='/v1/cards/tester', ) with self.assertRaises(KeyboardInterrupt): process_transactions.main( [process_transactions.__file__, cfg_path], processor=mock_processor) process_transactions.main([process_transactions.__file__, cfg_path], processor=mock_processor) # here is the story, we have two subscriptions here # # Subscription1 # Subscription2 # # And the time is not advanced, so we should only have two transactions # to be yielded and processed. However, we assume bad thing happens # durring the process. We let the second call to charge of processor # raises a KeyboardInterrupt error. So, it would look like this # # charge for transaction from Subscription1 # charge for transaction from Subscription2 (Crash) # # Then, we perform the process_transactions again, if it works # correctly, the first transaction is already yield and processed. # # charge for transaction from Subscription2 # # So, there would only be two charges in processor. This is mainly # for making sure we won't duplicate charges/payouts self.assertEqual(len(mock_processor.charges), 2)
def create_subscription_model(self): """Create a subscription model """ return SubscriptionModel(self)
def make_one(self, *args, **kwargs): from billy.models.subscription import SubscriptionModel return SubscriptionModel(*args, **kwargs)