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_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_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_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 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_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_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 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_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_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 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 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