From 99506b486a40e029a3f0ea7fd7b9a65692a9832c Mon Sep 17 00:00:00 2001 From: Rishi Gupta Date: Fri, 7 Sep 2018 15:49:54 -0700 Subject: [PATCH] billing: Add backend for paying by invoice. --- corporate/lib/stripe.py | 27 +++-- ..._billing_by_invoice:Customer.create.1.json | Bin 0 -> 782 bytes ...illing_by_invoice:Customer.retrieve.1.json | Bin 0 -> 3440 bytes ...illing_by_invoice:Customer.retrieve.2.json | Bin 0 -> 6216 bytes ...ade_billing_by_invoice:Invoice.list.1.json | Bin 0 -> 2880 bytes ...ling_by_invoice:Subscription.create.1.json | Bin 0 -> 2154 bytes ...illing_by_invoice:Subscription.save.1.json | Bin 0 -> 2150 bytes corporate/tests/test_stripe.py | 99 +++++++++++++++++- corporate/views.py | 20 +++- stubs/stripe/__init__.pyi | 21 +++- 10 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Invoice.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.save.1.json diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 1e73392d49..c68b2bd4e1 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -34,6 +34,9 @@ log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH) CallableT = TypeVar('CallableT', bound=Callable[..., Any]) +MIN_INVOICED_SEAT_COUNT = 30 +DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30 + def get_seat_count(realm: Realm) -> int: return UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False).count() @@ -202,7 +205,7 @@ def do_replace_coupon(user: UserProfile, coupon: Coupon) -> stripe.Customer: @catch_stripe_errors def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Customer, stripe_plan_id: str, - seat_count: int, tax_percent: float) -> None: + seat_count: int, tax_percent: float, charge_automatically: bool) -> None: if extract_current_subscription(stripe_customer) is not None: # Most likely due to two people in the org going to the billing page, # and then both upgrading their plan. We don't send clients @@ -212,6 +215,12 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus "but has an active subscription" % (stripe_customer.id, stripe_plan_id)) raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING) customer = Customer.objects.get(stripe_customer_id=stripe_customer.id) + if charge_automatically: + billing_method = 'charge_automatically' + days_until_due = None + else: + billing_method = 'send_invoice' + days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE # Note that there is a race condition here, where if two users upgrade at exactly the # same time, they will have two subscriptions, and get charged twice. We could try to # reduce the chance of it with a well-designed idempotency_key, but it's not easy since @@ -222,7 +231,8 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus # Otherwise we should expect it to throw a stripe.error. stripe_subscription = stripe.Subscription.create( customer=stripe_customer.id, - billing='charge_automatically', + billing=billing_method, + days_until_due=days_until_due, items=[{ 'plan': stripe_plan_id, 'quantity': seat_count, @@ -239,7 +249,8 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus acting_user=user, event_type=RealmAuditLog.STRIPE_PLAN_CHANGED, event_time=timestamp_to_datetime(stripe_subscription.created), - extra_data=ujson.dumps({'plan': stripe_plan_id, 'quantity': seat_count})) + extra_data=ujson.dumps({'plan': stripe_plan_id, 'quantity': seat_count, + 'billing_method': billing_method})) current_seat_count = get_seat_count(customer.realm) if seat_count != current_seat_count: @@ -250,11 +261,14 @@ def do_subscribe_customer_to_plan(user: UserProfile, stripe_customer: stripe.Cus requires_billing_update=True, extra_data=ujson.dumps({'quantity': current_seat_count})) -def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int, stripe_token: str) -> None: +def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int, + stripe_token: Optional[str]) -> None: customer = Customer.objects.filter(realm=user.realm).first() if customer is None: stripe_customer = do_create_customer(user, stripe_token=stripe_token) - else: + # elif instead of if since we want to avoid doing two round trips to + # stripe if we can + elif stripe_token is not None: stripe_customer = do_replace_payment_source(user, stripe_token) do_subscribe_customer_to_plan( user=user, @@ -263,7 +277,8 @@ def process_initial_upgrade(user: UserProfile, plan: Plan, seat_count: int, stri seat_count=seat_count, # TODO: billing address details are passed to us in the request; # use that to calculate taxes. - tax_percent=0) + tax_percent=0, + charge_automatically=(stripe_token is not None)) do_change_plan_type(user, Realm.STANDARD) def attach_discount_to_realm(user: UserProfile, percent_off: int) -> None: diff --git a/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.create.1.json b/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..745bca88978b5b29786fd8d77841d2cf7e1902f9 GIT binary patch literal 782 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 RjL*2cY`K-!D?Ix8=s&kCgy;YO literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Invoice.list.1.json b/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Invoice.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..c855e7ecd2b40b43edea5c98506561def4c15c60 GIT binary patch literal 2880 zcmb7GOK;ma5WeSE2*e&*VB1KE)7ZTQNwHX<+bptsDB3LuTB2<>6shvz#trh{cSfQ_ zy_}*KL*~3^zIi;JBnc^)W8`h}8NQR`vHpcuR%)*tEj$O>YPm?$^!E|1Vxj=zRr zvhjmht_mvDUJGFBgaZRLoLQ|voLIlMxe->|tF^o>D=AX#+HWXR<}b|Vy%;s-A%!_o|BJ8c~hLVBq;a?DX9 zg$PBnn~(Qb*&n;~y8QU9RNp^bRona9yIwY0Emg4@Yk^{N3y&>I$LK`_M+`Ur0+K;$ zh~hv!R=hwSr8qk~PrT(O`Ol|mahZJbQdG&c)V@gm2HXo@CNG#vep{xm(=<6v4xE{n zU13_Kp*(CY$B+#iO}eZU$1oH4q}cOP7pP^>6@grG$3lBQKDD3zG015z}FKG#q1RDuCRa9hN(Y5Geh& z?u@YQ*6=Mf5A~K!`ZBWBqYRWRdqb?Fwj;piJ6@tG0%23Hgd3#;qH5+r6Z-jTd7iB< zvte41>Bu|UNOsIZ`pwMLArS~t*nS5Q zryV4_T}wTbl4f0y(|vXtX1|4xqnqNi*#g0{Zj!(lo1caA@Ft2sI9jno$>Gdl&3Q3G z98tbpH|smB?pMRb9iyFG7U4J=RGzfvu&wI@r;lEoJ1}l(J6k8mK%h@YYg-y(c+TN; zp`G6Y{k7n5!^hhVh_C@L7wyW^c(;rdIFC(23d1(By1xQfn_?KwV40zzMDBhI3kT!?<$f^+b6oUxbjgkW#2Enuz79q(berV4UPm z$_`9&3X1Kt!N}rfF}s;9rp0ZHV(8$Kf(!YDE-B{0s0~QVvjARVNPaal92^g!rwP3= zSY|(6adW$vzM6KI_G7FA6Os=yS*I%=iYClk`sM!N^Se(UzkgUwF;mlI2?tBPazfJ5 zbBvr%W7Q?!fR~P1?Qv>o?`BpC%QT+IZP@dmK@oZ*)~`qJ6^>UH1D+5D1fm27`0Gdf zHA&X!IX9=^#DnP;F?LA`sknob|LHk2m+X4|i?h&1DAF<`>PVGPS)5G@t0kze&}0MV z#6C>L86QzjIgxbRt>IflmU;`@d~nB*Dvj?BVK~-vq$9O#$E$FbAg2%=;j)4-E}zMK zc{eYXw?!`zi~L}Rkiy%tf4Ui2Xp64&{PF#M{rCQEd2b(1!j$7?4>II9a4_Ru+H#>^ zfW+}sx(q#ZO6Ue~1`upa3HDn-(>f^S@%CO{N5~>7Jzs5f=^H%5*Vx-)x%kT6w^jYv z&*KC)fqgBhyV9bArpkw21Ili+sm_Dzi~!kcH;8NOmPifi4t2yBmlRhPS3yV3oTYy} zpgLWEvY5?}9rs9!&M~E>gwOHwXLFY9z+!{u1R8Q2trWK98*$oUJ0})< zI`(aDdNZPbtR^-{#z;a2OGsoWp~f^64{;Y{gU(BW!R>`xbW|!0hlcVX pXI?`zJ0IlO(j@4CIC#wd7))O_uU}JC^OCn)AXVCC59X`qtA97hRd4_R literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.save.1.json b/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.save.1.json new file mode 100644 index 0000000000000000000000000000000000000000..479fd1e0b2af4aa2b3bcbb3eab18d3849ac39043 GIT binary patch literal 2150 zcma)8-;WY85Ps*cAbmC-9GrlOF~;-3M7`wnNn@JQEnKzRuKhtka{s$C-R{z57vd8z zGo8-N_kB~2qw$!qs#20OFO8-fA!sEm7uu88V;z)wfgx+Dl+;@=5hrv(rQR9I1<9S1 zAGi{fY5rs^L{9JKw|BSm$#fB;I5>o&%wv9O3JQ53YQvNhS%4@YB)^(l!MwoG^Mt`# zD6^YxdbgNQUQOCdyD`o)i^+SLoHwPwq6rUu@gh6~*S z6pW`d1@_P?p&h^lK%g-#`ESkY)|u7;ZtwJUf-Iua@m1Cop~e%x#@^binY>y04g!HJ9RhDOtaFUKR$$%)Y%8{_X5Stg_vIMV)50)&h?sM6t9d%i7 zL=H=>wf7(xnjOfeH@$0;$|jqMiTsz0NMAs;sV^PkuZRYm*W?1b3wG$BP-+gHi-YuD p!l)TE`+!nxNP=o0p6# bool: return True +def process_all_billing_log_entries() -> None: + assert not RealmAuditLog.objects.get(pk=1).requires_billing_update + processor = BillingProcessor.objects.create( + log_row=RealmAuditLog.objects.get(pk=1), realm=None, state=BillingProcessor.DONE) + while run_billing_processor_one_step(processor): + pass + class StripeTest(ZulipTestCase): @mock_stripe("Product.create", "Plan.create", "Coupon.create", generate=False) def setUp(self, mock3: Mock, mock2: Mock, mock1: Mock) -> None: @@ -467,6 +475,30 @@ class StripeTest(ZulipTestCase): self.assert_in_success_response(["Upgrade to Zulip Standard"], response) self.assertEqual(response['error_description'], 'tampered plan') + def test_upgrade_with_insufficient_invoiced_seat_count(self) -> None: + self.login(self.example_email("hamlet")) + # Test invoicing for less than MIN_INVOICED_SEAT_COUNT + response = self.client_post("/upgrade/", { + 'invoiced_seat_count': self.quantity, + 'signed_seat_count': self.signed_seat_count, + 'salt': self.salt, + 'plan': Plan.CLOUD_ANNUAL + }) + self.assert_in_success_response(["Upgrade to Zulip Standard", + "at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response) + self.assertEqual(response['error_description'], 'lowball seat count') + # Test invoicing for less than your user count + with patch("corporate.views.MIN_INVOICED_SEAT_COUNT", 3): + response = self.client_post("/upgrade/", { + 'invoiced_seat_count': self.quantity - 1, + 'signed_seat_count': self.signed_seat_count, + 'salt': self.salt, + 'plan': Plan.CLOUD_ANNUAL + }) + self.assert_in_success_response(["Upgrade to Zulip Standard", + "at least %d users" % (self.quantity,)], response) + self.assertEqual(response['error_description'], 'lowball seat count') + @patch("corporate.lib.stripe.billing_logger.error") def test_upgrade_with_uncaught_exception(self, mock1: Mock) -> None: self.login(self.example_email("hamlet")) @@ -481,6 +513,69 @@ class StripeTest(ZulipTestCase): "Something went wrong. Please contact"], response) self.assertEqual(response['error_description'], 'uncaught exception during upgrade') + @mock_stripe("Customer.create", "Subscription.create", "Subscription.save", + "Customer.retrieve", "Invoice.list") + def test_upgrade_billing_by_invoice(self, mock5: Mock, mock4: Mock, mock3: Mock, + mock2: Mock, mock1: Mock) -> None: + user = self.example_user("hamlet") + self.login(user.email) + self.client_post("/upgrade/", { + 'invoiced_seat_count': 123, + 'signed_seat_count': self.signed_seat_count, + 'salt': self.salt, + 'plan': Plan.CLOUD_ANNUAL}) + process_all_billing_log_entries() + + # Check that we correctly created a Customer in Stripe + stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id) + self.assertEqual(stripe_customer.email, user.email) + # It can take a second for Stripe to attach the source to the + # customer, and in particular it may not be attached at the time + # stripe_get_customer is called above, causing test flakes. + # So commenting the next line out, but leaving it here so future readers know what + # is supposed to happen here (e.g. the default_source is not None as it would be if + # we had not added a Subscription). + # self.assertEqual(stripe_customer.default_source.type, 'ach_credit_transfer') + + # Check that we correctly created a Subscription in Stripe + stripe_subscription = extract_current_subscription(stripe_customer) + self.assertEqual(stripe_subscription.billing, 'send_invoice') + self.assertEqual(stripe_subscription.days_until_due, DEFAULT_INVOICE_DAYS_UNTIL_DUE) + self.assertEqual(stripe_subscription.plan.id, + Plan.objects.get(nickname=Plan.CLOUD_ANNUAL).stripe_plan_id) + self.assertEqual(stripe_subscription.quantity, get_seat_count(user.realm)) + self.assertEqual(stripe_subscription.status, 'active') + # Check that we correctly created an initial Invoice in Stripe + for stripe_invoice in stripe.Invoice.list(customer=stripe_customer.id, limit=1): + self.assertTrue(stripe_invoice.auto_advance) + self.assertEqual(stripe_invoice.billing, 'send_invoice') + self.assertEqual(stripe_invoice.billing_reason, 'subscription_create') + # Transitions to 'open' after 1-2 hours + self.assertEqual(stripe_invoice.status, 'draft') + # Very important. Check that we're invoicing for 123, and not get_seat_count + self.assertEqual(stripe_invoice.amount_due, 8000*123) + + # Check that we correctly updated Realm + realm = get_realm("zulip") + self.assertTrue(realm.has_seat_based_plan) + self.assertEqual(realm.plan_type, Realm.STANDARD) + # Check that we created a Customer in Zulip + self.assertEqual(1, Customer.objects.filter(stripe_customer_id=stripe_customer.id, + realm=realm).count()) + # Check that RealmAuditLog has STRIPE_PLAN_QUANTITY_RESET, and doesn't have STRIPE_CARD_CHANGED + audit_log_entries = list(RealmAuditLog.objects.order_by('-id') + .values_list('event_type', 'event_time', + 'requires_billing_update')[:4])[::-1] + self.assertEqual(audit_log_entries, [ + (RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), False), + (RealmAuditLog.STRIPE_PLAN_CHANGED, timestamp_to_datetime(stripe_subscription.created), False), + (RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET, timestamp_to_datetime(stripe_subscription.created), True), + (RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra(), False), + ]) + self.assertEqual(ujson.loads(RealmAuditLog.objects.filter( + event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET).values_list('extra_data', flat=True).first()), + {'quantity': self.quantity}) + @patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription) def test_redirect_for_billing_home(self, mock_customer_with_subscription: Mock) -> None: user = self.example_user("iago") @@ -532,7 +627,7 @@ class StripeTest(ZulipTestCase): with self.assertRaisesRegex(BillingError, 'subscribing with existing subscription'): do_subscribe_customer_to_plan(self.example_user("iago"), mock_customer_with_subscription(), - self.stripe_plan_id, self.quantity, 0) + self.stripe_plan_id, self.quantity, 0, True) def test_sign_string(self) -> None: string = "abc" diff --git a/corporate/views.py b/corporate/views.py index 0265e2d933..c09285ee4e 100644 --- a/corporate/views.py +++ b/corporate/views.py @@ -18,7 +18,8 @@ from zerver.models import UserProfile, Realm from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \ stripe_get_customer, upcoming_invoice_total, get_seat_count, \ extract_current_subscription, process_initial_upgrade, sign_string, \ - unsign_string, BillingError, process_downgrade, do_replace_payment_source + unsign_string, BillingError, process_downgrade, do_replace_payment_source, \ + MIN_INVOICED_SEAT_COUNT from corporate.models import Customer, Plan billing_logger = logging.getLogger('corporate.stripe') @@ -56,7 +57,14 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse: try: plan, seat_count = unsign_and_check_upgrade_parameters( user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt']) - process_initial_upgrade(user, plan, seat_count, request.POST['stripeToken']) + if 'invoiced_seat_count' in request.POST: + min_required_seat_count = max(seat_count, MIN_INVOICED_SEAT_COUNT) + if int(request.POST['invoiced_seat_count']) < min_required_seat_count: + raise BillingError( + 'lowball seat count', + "You must invoice for at least %d users." % (min_required_seat_count,)) + seat_count = int(request.POST['invoiced_seat_count']) + process_initial_upgrade(user, plan, seat_count, request.POST.get('stripeToken', None)) except BillingError as e: error_message = e.message error_description = e.description @@ -129,8 +137,12 @@ def billing_home(request: HttpRequest) -> HttpResponse: renewal_amount = 0 payment_method = None - if stripe_customer.default_source is not None: - payment_method = "Card ending in %(last4)s" % {'last4': stripe_customer.default_source.last4} + stripe_source = stripe_customer.default_source + if stripe_source is not None: + if stripe_source.object == 'card': + # To fix mypy error, set Customer.default_source: Union[Source, Card] in stubs and debug + payment_method = "Card ending in %(last4)s" % \ + {'last4': stripe_source.last4} # type: ignore # see above context.update({ 'plan_name': plan_name, diff --git a/stubs/stripe/__init__.pyi b/stubs/stripe/__init__.pyi index f1eeb676be..2e0df42869 100644 --- a/stubs/stripe/__init__.pyi +++ b/stubs/stripe/__init__.pyi @@ -8,7 +8,7 @@ from typing import Optional, Any, Dict, List, Union api_key: Optional[str] class Customer: - default_source: Card + default_source: Source created: int id: str source: str @@ -43,7 +43,12 @@ class Customer: class Invoice: + auto_advance: bool amount_due: int + billing: str + billing_reason: str + default_source: Source + status: str total: int @staticmethod @@ -51,16 +56,22 @@ class Invoice: subscription_items: List[Dict[str, Union[str, int]]]=...) -> Invoice: ... + @staticmethod + def list(customer: str=..., limit: Optional[int]=...) -> List[Invoice]: + ... + class Subscription: created: int status: str canceled_at: int cancel_at_period_end: bool + days_until_due: Optional[int] proration_date: int quantity: int @staticmethod - def create(customer: str=..., billing: str=..., items: List[Dict[str, Any]]=..., + def create(customer: str=..., billing: str=..., days_until_due: Optional[int]=..., + items: List[Dict[str, Any]]=..., prorate: bool=..., tax_percent: float=...) -> Subscription: ... @@ -68,9 +79,15 @@ class Subscription: def save(subscription: Subscription, idempotency_key: str=...) -> Subscription: ... +class Source: + id: str + object: str + type: str + class Card: id: str last4: str + object: str class Plan: id: str