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_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') 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 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 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 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 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 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 = 5566 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) self.assertEqual(res.json["canceled"], False) 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 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_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(self): from billy.models.subscription import SubscriptionModel from billy.renderers import subscription_adapter subscription_model = SubscriptionModel(self.testapp.session) subscription = subscription_model.get(self.subscription_guid) json_data = subscription_adapter(subscription, self.dummy_request) expected = dict( guid=subscription.guid, amount=None, payment_uri=subscription.payment_uri, period=subscription.period, canceled=subscription.canceled, next_transaction_at=subscription.next_transaction_at.isoformat(), created_at=subscription.created_at.isoformat(), updated_at=subscription.updated_at.isoformat(), started_at=subscription.started_at.isoformat(), canceled_at=None, customer_guid=subscription.customer_guid, plan_guid=subscription.plan_guid, ) self.assertEqual(json_data, expected) def assert_amount(amount, expected_amount): subscription.amount = amount json_data = subscription_adapter(subscription, self.dummy_request) self.assertEqual(json_data['amount'], expected_amount) assert_amount(None, None) assert_amount(1234, 1234) def assert_canceled_at(canceled_at, expected_canceled_at): subscription.canceled_at = canceled_at json_data = subscription_adapter(subscription, self.dummy_request) self.assertEqual(json_data['canceled_at'], expected_canceled_at) now = datetime.datetime.utcnow() assert_canceled_at(None, None) assert_canceled_at(now, now.isoformat())
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 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]))