From e7220fd71f57a49fab04434539806ac32ec3b9e0 Mon Sep 17 00:00:00 2001 From: Rishi Gupta Date: Sat, 15 Dec 2018 00:33:25 -0800 Subject: [PATCH] billing: Do subscription management in-house instead of with Stripe Billing. This is a major rewrite of the billing system. It moves subscription information off of stripe Subscriptions and into a local CustomerPlan table. To keep this manageable, it leaves several things unimplemented (downgrading, etc), and a variety of other TODOs in the code. There are also some known regressions, e.g. error-handling on /upgrade is broken. --- analytics/views.py | 19 +- corporate/lib/stripe.py | 300 +++++++----- corporate/management/commands/setup_stripe.py | 58 --- corporate/migrations/0003_customerplan.py | 35 ++ corporate/models.py | 41 +- ...ling_page_permissions:Charge.create.1.json | Bin 0 -> 1923 bytes ..._page_permissions:Customer.retrieve.1.json | Bin 4882 -> 0 bytes ..._page_permissions:Customer.retrieve.2.json | Bin 4882 -> 0 bytes ...ing_page_permissions:Invoice.create.1.json | Bin 0 -> 2270 bytes ...ermissions:Invoice.finalize_invoice.1.json | Bin 0 -> 2403 bytes ...g_page_permissions:Invoice.upcoming.1.json | Bin 2302 -> 0 bytes ...g_page_permissions:Invoice.upcoming.2.json | Bin 2302 -> 0 bytes ...page_permissions:InvoiceItem.create.1.json | Bin 0 -> 471 bytes ...page_permissions:InvoiceItem.create.2.json | Bin 0 -> 453 bytes ...age_permissions:Subscription.create.1.json | Bin 2160 -> 0 bytes .../initial_upgrade:Customer.retrieve.1.json | Bin 4882 -> 0 bytes .../initial_upgrade:Customer.retrieve.2.json | Bin 4882 -> 0 bytes .../initial_upgrade:Invoice.upcoming.1.json | Bin 2302 -> 0 bytes ...initial_upgrade:Subscription.create.1.json | Bin 2160 -> 0 bytes ...ent_method_string:Customer.retrieve.2.json | Bin 4876 -> 0 bytes ...ent_method_string:Customer.retrieve.3.json | Bin 4876 -> 0 bytes ...ent_method_string:Customer.retrieve.4.json | Bin 4880 -> 0 bytes ...ent_method_string:Customer.retrieve.5.json | Bin 2217 -> 0 bytes ...ent_method_string:Customer.retrieve.6.json | Bin 3438 -> 0 bytes ...t_method_string:Subscription.create.1.json | Bin 2154 -> 0 bytes ...eplace_payment_source:Charge.create.1.json | Bin 0 -> 1923 bytes ...ce_payment_source:Customer.retrieve.1.json | Bin 4882 -> 2219 bytes ...ce_payment_source:Customer.retrieve.2.json | Bin 4894 -> 2231 bytes ...ce_payment_source:Customer.retrieve.3.json | Bin 4894 -> 2231 bytes ...ce_payment_source:Customer.retrieve.4.json | Bin 4894 -> 2231 bytes ...eplace_payment_source:Customer.save.1.json | Bin 4253 -> 1590 bytes ...eplace_payment_source:Customer.save.2.json | Bin 1425 -> 1534 bytes ...place_payment_source:Invoice.create.1.json | Bin 0 -> 2270 bytes ...ent_source:Invoice.finalize_invoice.1.json | Bin 0 -> 2403 bytes ...e_payment_source:InvoiceItem.create.1.json | Bin 0 -> 471 bytes ...e_payment_source:InvoiceItem.create.2.json | Bin 0 -> 453 bytes ..._payment_source:Subscription.create.1.json | Bin 2160 -> 0 bytes .../setUp:Coupon.create.1.json | Bin 343 -> 0 bytes .../setUp:Coupon.create.2.json | Bin 343 -> 0 bytes .../stripe_fixtures/setUp:Plan.create.1.json | Bin 463 -> 0 bytes .../stripe_fixtures/setUp:Plan.create.2.json | Bin 462 -> 0 bytes .../setUp:Product.create.1.json | Bin 460 -> 0 bytes ...illing_by_invoice:Customer.retrieve.1.json | Bin 3440 -> 0 bytes ...illing_by_invoice:Customer.retrieve.2.json | Bin 6216 -> 0 bytes ...illing_by_invoice:Customer.retrieve.3.json | Bin 6216 -> 0 bytes ...billing_by_invoice:Invoice.upcoming.1.json | Bin 4863 -> 0 bytes ...ling_by_invoice:Subscription.create.1.json | Bin 2154 -> 0 bytes ...illing_by_invoice:Subscription.save.1.json | Bin 2150 -> 0 bytes .../upgrade_by_card:Charge.create.1.json | Bin 0 -> 1923 bytes .../upgrade_by_card:Charge.list.1.json | Bin 0 -> 2137 bytes ...=> upgrade_by_card:Customer.create.1.json} | Bin 1583 -> 1583 bytes ... upgrade_by_card:Customer.retrieve.1.json} | Bin 2218 -> 2219 bytes .../upgrade_by_card:Invoice.create.1.json | Bin 0 -> 2270 bytes ...de_by_card:Invoice.finalize_invoice.1.json | Bin 0 -> 2403 bytes .../upgrade_by_card:Invoice.list.1.json | Bin 0 -> 2849 bytes .../upgrade_by_card:InvoiceItem.create.1.json | Bin 0 -> 471 bytes .../upgrade_by_card:InvoiceItem.create.2.json | Bin 0 -> 453 bytes ...on => upgrade_by_card:Token.create.1.json} | Bin 826 -> 826 bytes ...h_outdated_seat_count:Charge.create.1.json | Bin 0 -> 1922 bytes ...ith_outdated_seat_count:Charge.list.1.json | Bin 0 -> 2136 bytes ...utdated_seat_count:Customer.create.1.json} | Bin 1583 -> 1583 bytes ..._outdated_seat_count:Invoice.create.1.json | Bin 0 -> 2292 bytes ...seat_count:Invoice.finalize_invoice.1.json | Bin 0 -> 2432 bytes ...th_outdated_seat_count:Invoice.list.1.json | Bin 0 -> 2878 bytes ...dated_seat_count:InvoiceItem.create.1.json | Bin 0 -> 471 bytes ...dated_seat_count:InvoiceItem.create.2.json | Bin 0 -> 455 bytes ...h_outdated_seat_count:Token.create.1.json} | Bin 826 -> 834 bytes .../upgrade_by_invoice:Charge.list.1.json | Bin 0 -> 82 bytes ...upgrade_by_invoice:Customer.create.1.json} | Bin ...grade_by_invoice:Customer.retrieve.1.json} | Bin 781 -> 783 bytes .../upgrade_by_invoice:Invoice.create.1.json | Bin 0 -> 1723 bytes ...by_invoice:Invoice.finalize_invoice.1.json | Bin 0 -> 1857 bytes ...=> upgrade_by_invoice:Invoice.list.1.json} | Bin 2880 -> 2223 bytes ...grade_by_invoice:InvoiceItem.create.1.json | Bin 0 -> 456 bytes ...ere_first_card_fails:Charge.create.1.json} | Bin 1437 -> 1604 bytes ...here_first_card_fails:Charge.create.2.json | Bin 0 -> 1925 bytes ..._where_first_card_fails:Charge.list.1.json | Bin 0 -> 2205 bytes ..._where_first_card_fails:Charge.list.2.json | Bin 0 -> 4260 bytes ...e_first_card_fails:Customer.create.1.json} | Bin 1583 -> 1583 bytes ...first_card_fails:Customer.retrieve.1.json} | Bin 2218 -> 2218 bytes ...first_card_fails:Customer.retrieve.2.json} | Bin 2218 -> 2218 bytes ...ere_first_card_fails:Customer.save.1.json} | Bin ...ere_first_card_fails:Invoice.create.1.json | Bin 0 -> 2273 bytes ...card_fails:Invoice.finalize_invoice.1.json | Bin 0 -> 2406 bytes ...where_first_card_fails:Invoice.list.1.json | Bin 0 -> 83 bytes ...where_first_card_fails:Invoice.list.2.json | Bin 0 -> 2852 bytes ...first_card_fails:InvoiceItem.create.1.json | Bin 0 -> 473 bytes ...first_card_fails:InvoiceItem.create.2.json | Bin 0 -> 455 bytes ...e_first_card_fails:InvoiceItem.list.1.json | Bin 0 -> 87 bytes ...here_first_card_fails:Token.create.1.json} | Bin 826 -> 826 bytes ...here_first_card_fails:Token.create.2.json} | Bin 826 -> 826 bytes ...save_fails_at_first:Customer.create.1.json | Bin 1583 -> 0 bytes ...ve_fails_at_first:Customer.retrieve.3.json | Bin 4882 -> 0 bytes ...ve_fails_at_first:Customer.retrieve.4.json | Bin 4882 -> 0 bytes ..._fails_at_first:Subscription.create.2.json | Bin 2160 -> 0 bytes ...on_save_fails_at_first:Token.create.1.json | Bin 826 -> 0 bytes ...tdated_seat_count:Customer.retrieve.1.json | Bin 4882 -> 0 bytes ...ated_seat_count:Subscription.create.1.json | Bin 2160 -> 0 bytes corporate/tests/test_stripe.py | 440 ++++++++++++------ corporate/views.py | 67 +-- docs/subsystems/billing.md | 82 +--- stubs/stripe/__init__.pyi | 39 +- templates/corporate/billing.html | 2 +- templates/corporate/upgrade.html | 20 +- tools/linter_lib/custom_check.py | 2 +- zerver/models.py | 3 + zerver/tests/test_presence.py | 2 +- 107 files changed, 654 insertions(+), 456 deletions(-) delete mode 100644 corporate/management/commands/setup_stripe.py create mode 100644 corporate/migrations/0003_customerplan.py create mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Charge.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Customer.retrieve.1.json delete mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Customer.retrieve.2.json create mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.finalize_invoice.1.json delete mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.upcoming.1.json delete mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.upcoming.2.json create mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:InvoiceItem.create.1.json create mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:InvoiceItem.create.2.json delete mode 100644 corporate/tests/stripe_fixtures/billing_page_permissions:Subscription.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/initial_upgrade:Customer.retrieve.1.json delete mode 100644 corporate/tests/stripe_fixtures/initial_upgrade:Customer.retrieve.2.json delete mode 100644 corporate/tests/stripe_fixtures/initial_upgrade:Invoice.upcoming.1.json delete mode 100644 corporate/tests/stripe_fixtures/initial_upgrade:Subscription.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.2.json delete mode 100644 corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.3.json delete mode 100644 corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.4.json delete mode 100644 corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.5.json delete mode 100644 corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json delete mode 100644 corporate/tests/stripe_fixtures/payment_method_string:Subscription.create.1.json create mode 100644 corporate/tests/stripe_fixtures/replace_payment_source:Charge.create.1.json create mode 100644 corporate/tests/stripe_fixtures/replace_payment_source:Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/replace_payment_source:Invoice.finalize_invoice.1.json create mode 100644 corporate/tests/stripe_fixtures/replace_payment_source:InvoiceItem.create.1.json create mode 100644 corporate/tests/stripe_fixtures/replace_payment_source:InvoiceItem.create.2.json delete mode 100644 corporate/tests/stripe_fixtures/replace_payment_source:Subscription.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/setUp:Coupon.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/setUp:Coupon.create.2.json delete mode 100644 corporate/tests/stripe_fixtures/setUp:Plan.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/setUp:Plan.create.2.json delete mode 100644 corporate/tests/stripe_fixtures/setUp:Product.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.2.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.3.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Invoice.upcoming.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.save.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:Charge.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:Charge.list.1.json rename corporate/tests/stripe_fixtures/{initial_upgrade:Customer.create.1.json => upgrade_by_card:Customer.create.1.json} (98%) rename corporate/tests/stripe_fixtures/{payment_method_string:Customer.retrieve.1.json => upgrade_by_card:Customer.retrieve.1.json} (97%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.finalize_invoice.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:InvoiceItem.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card:InvoiceItem.create.2.json rename corporate/tests/stripe_fixtures/{initial_upgrade:Token.create.1.json => upgrade_by_card:Token.create.1.json} (96%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Charge.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Charge.list.1.json rename corporate/tests/stripe_fixtures/{upgrade_with_outdated_seat_count:Customer.create.1.json => upgrade_by_card_with_outdated_seat_count:Customer.create.1.json} (75%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.finalize_invoice.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:InvoiceItem.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:InvoiceItem.create.2.json rename corporate/tests/stripe_fixtures/{upgrade_with_outdated_seat_count:Token.create.1.json => upgrade_by_card_with_outdated_seat_count:Token.create.1.json} (78%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_invoice:Charge.list.1.json rename corporate/tests/stripe_fixtures/{upgrade_billing_by_invoice:Customer.create.1.json => upgrade_by_invoice:Customer.create.1.json} (100%) rename corporate/tests/stripe_fixtures/{payment_method_string:Customer.retrieve.7.json => upgrade_by_invoice:Customer.retrieve.1.json} (59%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_invoice:Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_invoice:Invoice.finalize_invoice.1.json rename corporate/tests/stripe_fixtures/{upgrade_billing_by_invoice:Invoice.list.1.json => upgrade_by_invoice:Invoice.list.1.json} (50%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_by_invoice:InvoiceItem.create.1.json rename corporate/tests/stripe_fixtures/{upgrade_where_subscription_save_fails_at_first:Subscription.create.1.json => upgrade_where_first_card_fails:Charge.create.1.json} (67%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.create.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.list.2.json rename corporate/tests/stripe_fixtures/{upgrade_where_subscription_save_fails_at_first:Customer.save.1.json => upgrade_where_first_card_fails:Customer.create.1.json} (98%) rename corporate/tests/stripe_fixtures/{upgrade_where_subscription_save_fails_at_first:Customer.retrieve.2.json => upgrade_where_first_card_fails:Customer.retrieve.1.json} (92%) rename corporate/tests/stripe_fixtures/{upgrade_where_subscription_save_fails_at_first:Customer.retrieve.1.json => upgrade_where_first_card_fails:Customer.retrieve.2.json} (92%) rename corporate/tests/stripe_fixtures/{payment_method_string:Customer.create.1.json => upgrade_where_first_card_fails:Customer.save.1.json} (100%) create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.finalize_invoice.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.list.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.list.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.create.1.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.create.2.json create mode 100644 corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.list.1.json rename corporate/tests/stripe_fixtures/{upgrade_where_subscription_save_fails_at_first:Token.create.2.json => upgrade_where_first_card_fails:Token.create.1.json} (92%) rename corporate/tests/stripe_fixtures/{payment_method_string:Token.create.1.json => upgrade_where_first_card_fails:Token.create.2.json} (95%) delete mode 100644 corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.3.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Subscription.create.2.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Token.create.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_with_outdated_seat_count:Customer.retrieve.1.json delete mode 100644 corporate/tests/stripe_fixtures/upgrade_with_outdated_seat_count:Subscription.create.1.json diff --git a/analytics/views.py b/analytics/views.py index 3cf4307d74..9e5782f220 100644 --- a/analytics/views.py +++ b/analytics/views.py @@ -495,21 +495,12 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: # estimate annual subscription revenue total_amount = 0 if settings.BILLING_ENABLED: - from corporate.lib.stripe import estimate_customer_arr - from corporate.models import Customer - stripe.api_key = get_secret('stripe_secret_key') - estimated_arr = {} - try: - for stripe_customer in stripe.Customer.list(limit=100): - # TODO: could do a select_related to get the realm.string_id, potentially - customer = Customer.objects.filter(stripe_customer_id=stripe_customer.id).first() - if customer is not None: - estimated_arr[customer.realm.string_id] = estimate_customer_arr(stripe_customer) - except stripe.error.StripeError: - pass + from corporate.lib.stripe import estimate_annual_recurring_revenue_by_realm + estimated_arrs = estimate_annual_recurring_revenue_by_realm() for row in rows: - row['amount'] = estimated_arr.get(row['string_id'], None) - total_amount = sum(estimated_arr.values()) + if row['string_id'] in estimated_arrs: + row['amount'] = estimated_arrs[row['string_id']] + total_amount += sum(estimated_arrs.values()) # augment data with realm_minutes total_hours = 0.0 diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index a574cf5433..52002b6468 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -1,9 +1,9 @@ -import datetime +from datetime import datetime from decimal import Decimal from functools import wraps import logging import os -from typing import Any, Callable, Dict, Optional, TypeVar, Tuple +from typing import Any, Callable, Dict, Optional, TypeVar, Tuple, cast import ujson from django.conf import settings @@ -19,7 +19,8 @@ from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime from zerver.lib.utils import generate_random_token from zerver.lib.actions import do_change_plan_type from zerver.models import Realm, UserProfile, RealmAuditLog -from corporate.models import Customer, CustomerPlan, Plan, Coupon +from corporate.models import Customer, CustomerPlan, Plan, Coupon, \ + get_active_plan from zproject.settings import get_secret STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key') @@ -50,6 +51,61 @@ def unsign_string(signed_string: str, salt: str) -> str: signer = Signer(salt=salt) return signer.unsign(signed_string) +# Be extremely careful changing this function. Historical billing periods +# are not stored anywhere, and are just computed on the fly using this +# function. Any change you make here should return the same value (or be +# within a few seconds) for basically any value from when the billing system +# went online to within a year from now. +def add_months(dt: datetime, months: int) -> datetime: + assert(months >= 0) + # It's fine that the max day in Feb is 28 for leap years. + MAX_DAY_FOR_MONTH = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30, + 7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31} + year = dt.year + month = dt.month + months + while month > 12: + year += 1 + month -= 12 + day = min(dt.day, MAX_DAY_FOR_MONTH[month]) + # datetimes don't support leap seconds, so don't need to worry about those + return dt.replace(year=year, month=month, day=day) + +def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime: + estimated_months = round((dt - billing_cycle_anchor).days * 12. / 365) + for months in range(max(estimated_months - 1, 0), estimated_months + 2): + proposed_next_month = add_months(billing_cycle_anchor, months) + if 20 < (proposed_next_month - dt).days < 40: + return proposed_next_month + raise AssertionError('Something wrong in next_month calculation with ' + 'billing_cycle_anchor: %s, dt: %s' % (billing_cycle_anchor, dt)) + +# TODO take downgrade into account +def next_renewal_date(plan: CustomerPlan) -> datetime: + months_per_period = { + CustomerPlan.ANNUAL: 12, + CustomerPlan.MONTHLY: 1, + }[plan.billing_schedule] + periods = 1 + dt = plan.billing_cycle_anchor + while dt <= plan.billed_through: + dt = add_months(plan.billing_cycle_anchor, months_per_period * periods) + periods += 1 + return dt + +def renewal_amount(plan: CustomerPlan) -> int: # nocoverage: TODO + if plan.fixed_price is not None: + basis = plan.fixed_price + elif plan.automanage_licenses: + assert(plan.price_per_license is not None) + basis = plan.price_per_license * get_seat_count(plan.customer.realm) + else: + assert(plan.price_per_license is not None) + basis = plan.price_per_license * plan.licenses + if plan.discount is None: + return basis + # TODO: figure out right thing to do with Decimal + return int(float(basis * (100 - plan.discount) / 100) + .00001) + class BillingError(Exception): # error messages CONTACT_SUPPORT = _("Something went wrong. Please contact %s." % (settings.ZULIP_ADMINISTRATOR,)) @@ -73,9 +129,6 @@ def catch_stripe_errors(func: CallableT) -> CallableT: if STRIPE_PUBLISHABLE_KEY is None: raise BillingError('missing stripe config', "Missing Stripe config. " "See https://zulip.readthedocs.io/en/latest/subsystems/billing.html.") - if not Plan.objects.exists(): - raise BillingError('missing plans', - "Plan objects not created. Please run ./manage.py setup_stripe") try: return func(*args, **kwargs) # See https://stripe.com/docs/api/python#error_handling, though @@ -101,38 +154,7 @@ def stripe_get_customer(stripe_customer_id: str) -> stripe.Customer: return stripe.Customer.retrieve(stripe_customer_id, expand=["default_source"]) @catch_stripe_errors -def stripe_get_upcoming_invoice(stripe_customer_id: str) -> stripe.Invoice: - return stripe.Invoice.upcoming(customer=stripe_customer_id) - -# This allows us to access /billing in tests without having to mock the -# whole invoice object -def upcoming_invoice_total(stripe_customer_id: str) -> int: - return stripe_get_upcoming_invoice(stripe_customer_id).total - -# Return type should be Optional[stripe.Subscription], which throws a mypy error. -# Will fix once we add type stubs for the Stripe API. -def extract_current_subscription(stripe_customer: stripe.Customer) -> Any: - if not stripe_customer.subscriptions: - return None - for stripe_subscription in stripe_customer.subscriptions: - if stripe_subscription.status != "canceled": - return stripe_subscription - -def estimate_customer_arr(stripe_customer: stripe.Customer) -> int: # nocoverage - stripe_subscription = extract_current_subscription(stripe_customer) - if stripe_subscription is None: - return 0 - # This is an overestimate for those paying by invoice - estimated_arr = stripe_subscription.plan.amount * stripe_subscription.quantity / 100. - if stripe_subscription.plan.interval == 'month': - estimated_arr *= 12 - discount = Customer.objects.get(stripe_customer_id=stripe_customer.id).default_discount - if discount is not None: - estimated_arr *= 1 - discount/100. - return int(estimated_arr) - -@catch_stripe_errors -def do_create_customer(user: UserProfile, stripe_token: Optional[str]=None) -> stripe.Customer: +def do_create_customer(user: UserProfile, stripe_token: Optional[str]=None) -> Customer: realm = user.realm # We could do a better job of handling race conditions here, but if two # people from a realm try to upgrade at exactly the same time, the main @@ -152,10 +174,10 @@ def do_create_customer(user: UserProfile, stripe_token: Optional[str]=None) -> s RealmAuditLog.objects.create( realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_CARD_CHANGED, event_time=event_time) - Customer.objects.create(realm=realm, stripe_customer_id=stripe_customer.id) + customer = Customer.objects.create(realm=realm, stripe_customer_id=stripe_customer.id) user.is_billing_admin = True user.save(update_fields=["is_billing_admin"]) - return stripe_customer + return customer @catch_stripe_errors def do_replace_payment_source(user: UserProfile, stripe_token: str) -> stripe.Customer: @@ -170,96 +192,154 @@ def do_replace_payment_source(user: UserProfile, stripe_token: str) -> stripe.Cu event_time=timezone_now()) return updated_stripe_customer +# Returns Customer instead of stripe_customer so that we don't make a Stripe +# API call if there's nothing to update +def update_or_create_stripe_customer(user: UserProfile, stripe_token: Optional[str]=None) -> Customer: + realm = user.realm + customer = Customer.objects.filter(realm=realm).first() + if customer is None: + return do_create_customer(user, stripe_token=stripe_token) + if stripe_token is not None: + do_replace_payment_source(user, stripe_token) + return customer + +def compute_plan_parameters( + automanage_licenses: bool, billing_schedule: int, + discount: Optional[Decimal]) -> Tuple[datetime, datetime, datetime, int]: + # Everything in Stripe is stored as timestamps with 1 second resolution, + # so standardize on 1 second resolution. + # TODO talk about leapseconds? + billing_cycle_anchor = timezone_now().replace(microsecond=0) + if billing_schedule == CustomerPlan.ANNUAL: + # TODO use variables to account for Zulip Plus + price_per_license = 8000 + period_end = add_months(billing_cycle_anchor, 12) + elif billing_schedule == CustomerPlan.MONTHLY: + price_per_license = 800 + period_end = add_months(billing_cycle_anchor, 1) + else: + raise AssertionError('Unknown billing_schedule: {}'.format(billing_schedule)) + if discount is not None: + # There are no fractional cents in Stripe, so round down to nearest integer. + price_per_license = int(float(price_per_license * (1 - discount / 100)) + .00001) + next_billing_date = period_end + if automanage_licenses: + next_billing_date = add_months(billing_cycle_anchor, 1) + return billing_cycle_anchor, next_billing_date, period_end, price_per_license + +# Only used for cloud signups @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, charge_automatically: bool) -> None: - if extract_current_subscription(stripe_customer) is not None: # nocoverage +def process_initial_upgrade(user: UserProfile, licenses: int, automanage_licenses: bool, + billing_schedule: int, stripe_token: Optional[str]) -> None: + realm = user.realm + customer = update_or_create_stripe_customer(user, stripe_token=stripe_token) + # TODO write a test for this + if CustomerPlan.objects.filter(customer=customer, status=CustomerPlan.ACTIVE).exists(): # nocoverage # Unlikely race condition from two people upgrading (clicking "Make payment") # at exactly the same time. Doesn't fully resolve the race condition, but having # a check here reduces the likelihood. - billing_logger.error("Stripe customer %s trying to subscribe to %s, " - "but has an active subscription" % (stripe_customer.id, stripe_plan_id)) + billing_logger.warning( + "Customer {} trying to upgrade, but has an active subscription".format(customer)) raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING) - customer = Customer.objects.get(stripe_customer_id=stripe_customer.id) + + billing_cycle_anchor, next_billing_date, period_end, price_per_license = compute_plan_parameters( + automanage_licenses, billing_schedule, customer.default_discount) + # The main design constraint in this function is that if you upgrade with a credit card, and the + # charge fails, everything should be rolled back as if nothing had happened. This is because we + # expect frequent card failures on initial signup. + # Hence, if we're going to charge a card, do it at the beginning, even if we later may have to + # adjust the number of licenses. + charge_automatically = stripe_token is not None + if charge_automatically: + stripe_charge = stripe.Charge.create( + amount=price_per_license * licenses, + currency='usd', + customer=customer.stripe_customer_id, + description="Upgrade to Zulip Standard, ${} x {}".format(price_per_license/100, licenses), + receipt_email=user.email, + statement_descriptor='Zulip Standard') + # Not setting a period start and end, but maybe we should? Unclear what will make things + # most similar to the renewal case from an accounting perspective. + stripe.InvoiceItem.create( + amount=price_per_license * licenses * -1, + currency='usd', + customer=customer.stripe_customer_id, + description="Payment (Card ending in {})".format(cast(stripe.Card, stripe_charge.source).last4), + discountable=False) + + # TODO: The correctness of this relies on user creation, deactivation, etc being + # in a transaction.atomic() with the relevant RealmAuditLog entries + with transaction.atomic(): + # billed_licenses can greater than licenses if users are added between the start of + # this function (process_initial_upgrade) and now + billed_licenses = max(get_seat_count(realm), licenses) + plan_params = { + 'licenses': billed_licenses, + 'automanage_licenses': automanage_licenses, + 'charge_automatically': charge_automatically, + 'price_per_license': price_per_license, + 'discount': customer.default_discount, + 'billing_cycle_anchor': billing_cycle_anchor, + 'billing_schedule': billing_schedule, + 'tier': CustomerPlan.STANDARD} + CustomerPlan.objects.create( + customer=customer, + billed_through=billing_cycle_anchor, + next_billing_date=next_billing_date, + **plan_params) + RealmAuditLog.objects.create( + realm=realm, acting_user=user, event_time=billing_cycle_anchor, + event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED, + # TODO: add tests for licenses + # Only 'licenses' is guaranteed to be useful to automated tools. The other extra_data + # fields can change in the future and are only meant to assist manual debugging. + extra_data=ujson.dumps(plan_params)) + description = 'Zulip Standard' + if customer.default_discount is not None: # nocoverage: TODO + description += ' (%s%% off)' % (customer.default_discount,) + stripe.InvoiceItem.create( + currency='usd', + customer=customer.stripe_customer_id, + description=description, + discountable=False, + period = {'start': datetime_to_timestamp(billing_cycle_anchor), + 'end': datetime_to_timestamp(period_end)}, + quantity=billed_licenses, + unit_amount=price_per_license) + 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 - # we also need to be careful not to block the customer from retrying if their - # subscription attempt fails (e.g. due to insufficient funds). - - # Success here implies the stripe_customer was charged: https://stripe.com/docs/billing/lifecycle#active - # Otherwise we should expect it to throw a stripe.error. - stripe_subscription = stripe.Subscription.create( - customer=stripe_customer.id, + stripe_invoice = stripe.Invoice.create( + auto_advance=True, billing=billing_method, + customer=customer.stripe_customer_id, days_until_due=days_until_due, - items=[{ - 'plan': stripe_plan_id, - 'quantity': seat_count, - }], - prorate=True, - tax_percent=tax_percent) - with transaction.atomic(): - customer.has_billing_relationship = True - customer.save(update_fields=['has_billing_relationship']) - customer.realm.has_seat_based_plan = True - customer.realm.save(update_fields=['has_seat_based_plan']) - RealmAuditLog.objects.create( - realm=customer.realm, - 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, - 'billing_method': billing_method})) + statement_descriptor='Zulip Standard') + stripe.Invoice.finalize_invoice(stripe_invoice) - current_seat_count = get_seat_count(customer.realm) - if seat_count != current_seat_count: - RealmAuditLog.objects.create( - realm=customer.realm, - event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET, - event_time=timestamp_to_datetime(stripe_subscription.created), - requires_billing_update=True, - extra_data=ujson.dumps({'quantity': current_seat_count})) - -def process_initial_upgrade(user: UserProfile, seat_count: int, schedule: int, - stripe_token: Optional[str]) -> None: - if schedule == CustomerPlan.ANNUAL: - plan = Plan.objects.get(nickname=Plan.CLOUD_ANNUAL) - else: # schedule == CustomerPlan.MONTHLY: - plan = Plan.objects.get(nickname=Plan.CLOUD_MONTHLY) - customer = Customer.objects.filter(realm=user.realm).first() - if customer is None: - stripe_customer = do_create_customer(user, stripe_token=stripe_token) - # 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) - else: - stripe_customer = stripe_get_customer(customer.stripe_customer_id) - do_subscribe_customer_to_plan( - user=user, - stripe_customer=stripe_customer, - stripe_plan_id=plan.stripe_plan_id, - seat_count=seat_count, - # TODO: billing address details are passed to us in the request; - # use that to calculate taxes. - tax_percent=0, - charge_automatically=(stripe_token is not None)) - do_change_plan_type(user.realm, Realm.STANDARD) + do_change_plan_type(realm, Realm.STANDARD) def attach_discount_to_realm(user: UserProfile, discount: Decimal) -> None: customer = Customer.objects.filter(realm=user.realm).first() if customer is None: - do_create_customer(user) - customer = Customer.objects.filter(realm=user.realm).first() + customer = do_create_customer(user) customer.default_discount = discount customer.save() def process_downgrade(user: UserProfile) -> None: # nocoverage pass + +def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverage + annual_revenue = {} + for plan in CustomerPlan.objects.filter( + status=CustomerPlan.ACTIVE).select_related('customer__realm'): + renewal_cents = renewal_amount(plan) + if plan.billing_schedule == CustomerPlan.MONTHLY: + renewal_cents *= 12 + # TODO: Decimal stuff + annual_revenue[plan.customer.realm.string_id] = int(renewal_cents / 100) + return annual_revenue diff --git a/corporate/management/commands/setup_stripe.py b/corporate/management/commands/setup_stripe.py deleted file mode 100644 index b2cc734118..0000000000 --- a/corporate/management/commands/setup_stripe.py +++ /dev/null @@ -1,58 +0,0 @@ -from corporate.models import Plan, Coupon, Customer -from django.conf import settings -from zerver.lib.management import ZulipBaseCommand -from zproject.settings import get_secret - -from typing import Any - -import stripe -stripe.api_key = get_secret('stripe_secret_key') - -class Command(ZulipBaseCommand): - help = """Script to add the appropriate products and plans to Stripe.""" - - def handle(self, *args: Any, **options: Any) -> None: - assert (settings.DEVELOPMENT or settings.TEST_SUITE) - - Customer.objects.all().delete() - Plan.objects.all().delete() - Coupon.objects.all().delete() - - # Zulip Cloud offerings - product = stripe.Product.create( - name="Zulip Cloud Standard", - type='service', - statement_descriptor="Zulip Cloud Standard", - unit_label="user") - - plan = stripe.Plan.create( - currency='usd', - interval='month', - product=product.id, - amount=800, - billing_scheme='per_unit', - nickname=Plan.CLOUD_MONTHLY, - usage_type='licensed') - Plan.objects.create(nickname=Plan.CLOUD_MONTHLY, stripe_plan_id=plan.id) - - plan = stripe.Plan.create( - currency='usd', - interval='year', - product=product.id, - amount=8000, - billing_scheme='per_unit', - nickname=Plan.CLOUD_ANNUAL, - usage_type='licensed') - Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=plan.id) - - coupon = stripe.Coupon.create( - duration='forever', - name='25% discount', - percent_off=25) - Coupon.objects.create(percent_off=25, stripe_coupon_id=coupon.id) - - coupon = stripe.Coupon.create( - duration='forever', - name='85% discount', - percent_off=85) - Coupon.objects.create(percent_off=85, stripe_coupon_id=coupon.id) diff --git a/corporate/migrations/0003_customerplan.py b/corporate/migrations/0003_customerplan.py new file mode 100644 index 0000000000..6fd93d43ea --- /dev/null +++ b/corporate/migrations/0003_customerplan.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-12-22 21:05 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('corporate', '0002_customer_default_discount'), + ] + + operations = [ + migrations.CreateModel( + name='CustomerPlan', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('licenses', models.IntegerField()), + ('automanage_licenses', models.BooleanField(default=False)), + ('charge_automatically', models.BooleanField(default=False)), + ('price_per_license', models.IntegerField(null=True)), + ('fixed_price', models.IntegerField(null=True)), + ('discount', models.DecimalField(decimal_places=4, max_digits=6, null=True)), + ('billing_cycle_anchor', models.DateTimeField()), + ('billing_schedule', models.SmallIntegerField()), + ('billed_through', models.DateTimeField()), + ('next_billing_date', models.DateTimeField(db_index=True)), + ('tier', models.SmallIntegerField()), + ('status', models.SmallIntegerField(default=1)), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='corporate.Customer')), + ], + ), + ] diff --git a/corporate/models.py b/corporate/models.py index 0b8b87f496..9f0736b48d 100644 --- a/corporate/models.py +++ b/corporate/models.py @@ -9,17 +9,52 @@ from zerver.models import Realm, RealmAuditLog class Customer(models.Model): realm = models.OneToOneField(Realm, on_delete=models.CASCADE) # type: Realm stripe_customer_id = models.CharField(max_length=255, unique=True) # type: str - # Becomes True the first time a payment successfully goes through, and never - # goes back to being False + # Deprecated .. delete once everyone is migrated to new billing system has_billing_relationship = models.BooleanField(default=False) # type: bool default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True) # type: Optional[Decimal] def __str__(self) -> str: return "" % (self.realm, self.stripe_customer_id) -class CustomerPlan(object): +class CustomerPlan(models.Model): + customer = models.ForeignKey(Customer, on_delete=models.CASCADE) # type: Customer + licenses = models.IntegerField() # type: int + automanage_licenses = models.BooleanField(default=False) # type: bool + charge_automatically = models.BooleanField(default=False) # type: bool + + # Both of these are in cents. Exactly one of price_per_license or + # fixed_price should be set. fixed_price is only for manual deals, and + # can't be set via the self-serve billing system. + price_per_license = models.IntegerField(null=True) # type: Optional[int] + fixed_price = models.IntegerField(null=True) # type: Optional[int] + + # A percentage, like 85 + discount = models.DecimalField(decimal_places=4, max_digits=6, null=True) # type: Optional[Decimal] + + billing_cycle_anchor = models.DateTimeField() # type: datetime.datetime ANNUAL = 1 MONTHLY = 2 + billing_schedule = models.SmallIntegerField() # type: int + + # This is like analytic's FillState, but for billing + billed_through = models.DateTimeField() # type: datetime.datetime + next_billing_date = models.DateTimeField(db_index=True) # type: datetime.datetime + + STANDARD = 1 + PLUS = 2 # not available through self-serve signup + ENTERPRISE = 10 + tier = models.SmallIntegerField() # type: int + + ACTIVE = 1 + ENDED = 2 + NEVER_STARTED = 3 + # You can only have 1 active subscription at a time + status = models.SmallIntegerField(default=ACTIVE) # type: int + + # TODO maybe override setattr to ensure billing_cycle_anchor, etc are immutable + +def get_active_plan(customer: Customer) -> Optional[CustomerPlan]: + return CustomerPlan.objects.filter(customer=customer, status=CustomerPlan.ACTIVE).first() # Everything below here is legacy diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:Charge.create.1.json b/corporate/tests/stripe_fixtures/billing_page_permissions:Charge.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..f467b0fbdd60a23794e4a2ad903f43410a19801e GIT binary patch literal 1923 zcmah~$xa+G5WVvityYc*W(E|YoPv}bB4t5AR)a{Kuc!A{?@&M`UI5M9O&E@F{6gpftc(Y`36bEx@_ zkn8A0A0hf+I&?|Mqo3pTC;~lq(hh0e*o^~$RWQ|URMJ-00ki-oj;nVUi<3q5P+gSZ zM8xF=Uk;sgQ$5suVn1RqOVEn zt&w~dP{}QJ1-}91OE)`;FCf4KVAg+;CngXGILax*8qL<&f@+v)NH31Z;X z@Dz_oM9k6{?v1-+K0t`6kQ%IYW{ZNYcdXZD5HhCO`WdZ~O!_;faEmHAYvVdlqkYnO zZd?K^yd@8hN}*#@Q|l|(b*Mu{sab^qPlfsxc#(0bTE*V?!2P3WaGN%p=$t3>n@6(>@QQ8#nEPXFQNp~1>O~)|r@-OLO z@XF)qE506u|G#0B4Bzs@Kw_t%lbk8=;e1{jK3F1<4lRk#dQv6X4k2C4)#7ZBHZ)Om z))DtpsV~lUIXaX?dMe87Yfg7tj_m8t7G2Rd$u*QaI$>q5)`B9la5gDsa dw&m3M$T?zqJ@XaK4nnPJyBg>K+QTbrB~$BnSwKOG&Kv6_P7hHvHc^GbC5s z6?d(k>O-t>KF$p1@y*E3XNv_BQZ`-f_)3(bmWutbxOju-(kS6n0smiK#2+u{j8V1R zgM@XqV02ihwdhL6ZPOW2@(emqdr=r=EtlHSg};Qqae$h zTp3Yg9NFL6ig=5H9$tLkA9mI?l`;sHo#lVtefZ=3@4tQi^%gR)>`^&@OtX z>8VnfV(5OXp+raYlU-k4U&i~Da-tAUfad3y;3+%{6T;pX0t>^K2yRyYC`o>z!FcML z2UY7Q;dE1T(6?>SC(JY?nsl;4K`DLf6g_e+N~_Qm99wC0ix<(}Q&;MC@!e87fLF7eJ*RHbr1)77`Ksr;1?m3&T_u6K==inm6s^&@Jbu!oehQtwpN z6c~bot7)8k{X)Z*75A3ysPXgZaj`;6mu^kfSeT zdl60teU$I{A!NSq2iW=EKZa&|cbcT53e78J@Jkva|_-RO|%k$Wg zeLLH3)w9=WjoP-QmYycp3g}0{>?THxZI5pP_gA`v?(<~76=tKj=)f_!DMeZC1Mj9# zF88toSS_~=QqORQAgxfwxNwwQIJB&rf-9PnJ;x1TssJEp8o4&Lro_9ntc(ogof#sb z`3;(zz@9LtDIA5^TMoO8E_u-nmkKW*?@goUWkyYS8N_NLB)*fN8b%e9rU`be>QEy= z<%rV#si%AY8ULMJ3=a?h1t0XZ3m(oU+BHZIeUlb9NAHu@1aJ!rY(Jj{-U#Vngo4bO zjs|@y1#iV>W7Gz=KHgceIZjiAGg0Bd0!25l&iH^o2$oPcB`kmn|F8i!%>tlH?>n{Cx~T;(=!i=m@zx0cC&j) z3IJQP=>>VM2z8d?0{jz zMfVwrfy!y?n!>PYD%ZlzLgUuhY zZ=C>^p)XlCI))6JJ|3C)5&ok{fPXNrl+WG)_z3Jf(C@^nk+C8o5e2cT$O$EQhbVxU`P!HU| zoOn!DzKgfXKnu?MeQd~nK@_c`FmT8Jw+Rmx-+mv)_vw1wB-y1m;~C$<#tDCjqUARL I&R)*`1sbLP761SM diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/billing_page_permissions:Customer.retrieve.2.json deleted file mode 100644 index 2234b2f40d8860a30bbca1d4b2e9f3cfd7504424..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4882 zcmc&&OK%%D5Wf3Y2t2hw5L>nnPJyBg>K+QTbrB~$BnSwKOG&Kv6_P7hHvHc^GbC5s z6?d(k>O-t>KF$p1@y*E3XNv_BQZ`-f_)3(bmWutbxOju-(kS6n0smiK#2+u{j8V1R zgM@XqV02ihwdhL6ZPOW2@(emqdr=r=EtlHSg};Qqae$h zTp3Yg9NFL6ig=5H9$tLkA9mI?l`;sHo#lVtefZ=3@4tQi^%gR)>`^&@OtX z>8VnfV(5OXp+raYlU-k4U&i~Da-tAUfad3y;3+%{6T;pX0t>^K2yRyYC`o>z!FcML z2UY7Q;dE1T(6?>SC(JY?nsl;4K`DLf6g_e+N~_Qm99wC0ix<(}Q&;MC@!e87fLF7eJ*RHbr1)77`Ksr;1?m3&T_u6K==inm6s^&@Jbu!oehQtwpN z6c~bot7)8k{X)Z*75A3ysPXgZaj`;6mu^kfSeT zdl60teU$I{A!NSq2iW=EKZa&|cbcT53e78J@Jkva|_-RO|%k$Wg zeLLH3)w9=WjoP-QmYycp3g}0{>?THxZI5pP_gA`v?(<~76=tKj=)f_!DMeZC1Mj9# zF88toSS_~=QqORQAgxfwxNwwQIJB&rf-9PnJ;x1TssJEp8o4&Lro_9ntc(ogof#sb z`3;(zz@9LtDIA5^TMoO8E_u-nmkKW*?@goUWkyYS8N_NLB)*fN8b%e9rU`be>QEy= z<%rV#si%AY8ULMJ3=a?h1t0XZ3m(oU+BHZIeUlb9NAHu@1aJ!rY(Jj{-U#Vngo4bO zjs|@y1#iV>W7Gz=KHgceIZjiAGg0Bd0!25l&iH^o2$oPcB`kmn|F8i!%>tlH?>n{Cx~T;(=!i=m@zx0cC&j) z3IJQP=>>VM2z8d?0{jz zMfVwrfy!y?n!>PYD%ZlzLgUuhY zZ=C>^p)XlCI))6JJ|3C)5&ok{fPXNrl+WG)_z3Jf(C@^nk+C8o5e2cT$O$EQhbVxU`P!HU| zoOn!DzKgfXKnu?MeQd~nK@_c`FmT8Jw+Rmx-+mv)_vw1wB-y1m;~C$<#tDCjqUARL I&R)*`1sbLP761SM diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.create.1.json b/corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..f3f761177942592987d9bfc777403e5c71e56b17 GIT binary patch literal 2270 zcmd^BOK%e~5We?Utb78As!3EK#H|7jNW28MB4o|lPGj)eiyxt(>VIeK@jjBYfPfo0 zB{So1Uf;~*=A>yDbS~M*Wm4>Evv@QU^`PaU%&QKx)%I#q^u5snqIQ6}xPif~;mKAh7aTC{o~W-oAf*@#^K}i|32QVo5dD4x8TYfCqow)>e4nT$COuY0+kzs4@8fPx!j2+{_F1^o4~{A#MS zXoyg#dwh=Axj^*6q8?Yhst)ROnT+n64-u>cPwg2DYwe&lxe+6Os=B7Af1cuCT+5`9 zrHIA!?>#emt2!s|a^I;4wJ!lS&7^!maE8%!N@hCScz-7Tt1}uG@v;eW>eSFiK*x16nA7jE>li5Ogb0!43C4# zXkGBT_QRk%UJu2)S3==6;$sxU9MeA8zc%z=0Ff{ioHRb{Xjd-e9VrIIa+y6j*IcLfv5#hZORO9Kq#RuSBkckf`JoS(VB-K%1fEz zILDjTQ5G_+9FmlW*v@#KOuf?-ss$((LewB_Qj|i zmRA%4v>O%8wRNZxYM}+=)EgPn&z)=S@o3b-^1wQ*iytuEjIveuYmo?0&rUvHw}+?Y zr^EW}Y6-8$=SN@s^LeU1a)Jo8j!%b2k5rest?EZeJqlqW)<_0!G2$H(71@iJ0si_L ze-&98HHp`#dojb}f^YiZ+ipL8R&6}jg_oi&J~~hpn8cFKFh3!ba*eK8oiP%>#;7yS zk0fwd0#xG9+@x6WMpwJiH{3x6=I(ybxh}6b3v3|)MH&+h)Gaqchp9~1=JJq#C|c)N zQX%}-fmx?tkMl5bi`>N^9ouu{)@T#=2CDjj&${m(loKvY=+U4=>&xLlZ3J}7Ho$@8 zjI@va<$C&RR=wrThYtIHPmyz2He9*l$q6!~NV8f9Rm`Td>C?#A+)(vzjF}E&b+h$* z#;m#hH;kD)3S%O3zNJuP4wg3BY)l&&PLy@|jfm3@WPkOhIYeH!VohW|eC@%QGN<0nm03*e_Ng>KNWx8?szU||7%-3zzO=ehHx%vNq z{#K_wL3b?%l6BjAA%H*(XB@8~?ThHkiVnN(94fGf&`h_Sb-d6!c5~Q9&WGbbV#zV( s0~>>&JU$azr-SO?X84_ACk)dLv0Hce!OOYU*A@#4Z)3Jk@!tL3PYBMDj{pDw literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.upcoming.1.json b/corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.upcoming.1.json deleted file mode 100644 index 66710c40a4b598a891fd61b4e239373586079034..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2302 zcma)8OK%e~5We?UtSk;7L6e9IQY3^>%K-@}Dx3<)n(R7>sn=fo2u-X0cV=wwt4S() z%FcM6-#0V8J1PnWwF%nuDhT$bcy%(JPM;6N4af@RBbgO7NGv&%V7#-uvX}D8{fJrKMM|B3roT>IR540^7H#oAFn=~V;5%WO$DB6nV}^8#Q>F9 zLQtMN6KpxEwGythvY|GR+w4TTw)UshyQ=BOi zs^Tm1L58|`3ch$UpB_)A#iZB@u+L)gt-IU;SSae3w;|%4wUK1ZDY*}W{6@)*s7*!I z9bK}j79Mi-clQKPmYcitw7BuS zfo(1DkU~0ZnEiOpm=5(K-I{!7Uf?L_E7KR3^NrMF`TBNkKtT#Za~4l&OE(&N++}>< z@@jRvyNf!`tSAK@EZ*hU`HTkp4iYMGl~T3sl`wQfhlV~7i&TOoI7^8MBb&csu{P$~VUh%-9p2BQ`=fvF&33y0 diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.upcoming.2.json b/corporate/tests/stripe_fixtures/billing_page_permissions:Invoice.upcoming.2.json deleted file mode 100644 index 66710c40a4b598a891fd61b4e239373586079034..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2302 zcma)8OK%e~5We?UtSk;7L6e9IQY3^>%K-@}Dx3<)n(R7>sn=fo2u-X0cV=wwt4S() z%FcM6-#0V8J1PnWwF%nuDhT$bcy%(JPM;6N4af@RBbgO7NGv&%V7#-uvX}D8{fJrKMM|B3roT>IR540^7H#oAFn=~V;5%WO$DB6nV}^8#Q>F9 zLQtMN6KpxEwGythvY|GR+w4TTw)UshyQ=BOi zs^Tm1L58|`3ch$UpB_)A#iZB@u+L)gt-IU;SSae3w;|%4wUK1ZDY*}W{6@)*s7*!I z9bK}j79Mi-clQKPmYcitw7BuS zfo(1DkU~0ZnEiOpm=5(K-I{!7Uf?L_E7KR3^NrMF`TBNkKtT#Za~4l&OE(&N++}>< z@@jRvyNf!`tSAK@EZ*hU`HTkp4iYMGl~T3sl`wQfhlV~7i&TOoI7^8MBb&csu{P$~VUh%-9p2BQ`=fvF&33y0 diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/billing_page_permissions:InvoiceItem.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..5cea8259c0815359cb8727bd583fedc69665a541 GIT binary patch literal 471 zcmZ9JOH0H+5QOjhD~6mD6p|1;coX#?_`;)?r8Cn?%RJ&d7M8I8-R>mr8grYPuCHq< z<3_0pTqr(Q*Xr`BtLyqgG+kmuulKB0C7EgwQw|QJh`OZh^UK@Q&Ev!S{T#OhXJ2mRe44{4U@#Wof^eGNsc0E%0bBp7QqJ|Jxik EKY-zaLjV8( literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/billing_page_permissions:InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/billing_page_permissions:InvoiceItem.create.2.json new file mode 100644 index 0000000000000000000000000000000000000000..f796eeb199d3e4aaa44fc797ff9c61c651a24ed5 GIT binary patch literal 453 zcmY*W!Ait15WV*+O3og-AhLK9^dPtjdfUq~O-2|=lbTExr0l;tlh&@yEid!ly!SFS zZEIkNC9t_~?{2%UyHUuNlrY#I0ZqxyEMVq1U{cVQe0qI*KRmoVAD7I8<}stxG93VedkSh?hsLbP4?C%-PHaE@e^8jAOo z>FJC^bX&Or8N?*W;B={DvHwD=@y((^jKiE}#6(f|Pg081fR p4;p#(G^Utf>EW93yFg%Kt$SZR<2Had8xy0*u&ImQKtc+E0uU?1aA^vP+JJU43y=kc6zPOjbtFxwEKVnd)f&vD&}0LFY9FS8)<>LEPNLs-Yvq=( z<=(+IAKW#hOygS+5Z8LH#NkG;OzTP-N6X+s&77rwJdi#yK)JZ~+o3GEhIE$Fo#Xe<<}BNR zqxS3#Fzh;5tz64Ch80pXHwZItBPZ_n^ZTA~+}qsrWB=1QH1rVkyVywj&QP$ zG}(YCC*{bXFU0oUFfPFf@r}qX?Wl3VF*Pi;i@gWI(CkP)z3EL!Dw}L3CiY)4VtoNM zroMC-PKbt_Q*xo*MLTp*C=G{h#lc42g=9t_q}Y-qsDU9IvcCrpFXJ*!B{lEyb^|11 Ln?0EC&UgO+|5aE> diff --git a/corporate/tests/stripe_fixtures/initial_upgrade:Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/initial_upgrade:Customer.retrieve.1.json deleted file mode 100644 index 176eecd0220d83a27085608d3f2e847c667079b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4882 zcmc&&OK&4L48G@A7&^7UViPAhWD6ASqUoVP+Z3DK9(J)1WQ}aAnb%4hBTkV2UXqf= znz3e_p6Y`i;wO@#$d4R9pDh+lNZE9?<10~$S}OL#;^GaiOQVES1^ixKEHCJ_^n%V9 zRm(j{SZ51Hi-lT?u5{csogpR9paZoRg;CaWsU2#i*E1c@^iP3J{VxEKzh#KR_{?=B+T@>_i;`@HFv#zO>L9pyB|MTv{AMbzv?enj<5dUS5+R413Vqc3& zOI`}=u7?Luk1em7+HGO^Dx4SB_DZ0&%ZsbHy4LkZnbzpqp&J7i5+~MOT>ugxkST?B z(K}5?mBJK5_k9ZCdw-DVC%eA9zKr`T z2IH!09#pNLgwsvULEpAPpYW#PqDdz!6r|F(PSGLPqO=N4!LpS`x404QJ$0pS7vFuR zk6X3-o-D9JTe`lWly<60=o0sAMO7;IGi`k=Qt{TPwSGh`6!s7) zR_dLqngT;`a5asS&tE9mvf|!SA7j609pw}`#rhy5(Tj;kXR%w|w*GQsc`#qt7F-CG z9q#B0*o_K%_28aaXuIba|@=7E7Y!v$mcv-pxrrM@Pc?n zkR6lU;E@yjK*DK`AaRB#`t>8OAj#|a!b2xGgM_c=4T9&mg9K?^1pc7)DGniFQV9nh zA;~P4AmW7z;42mm5Xe?o;8`;$RaidgFSYHd(lt($JW>gDSHO0Lc;tMyJcoOxeqiz2 zkWQE9u_F6&w%w{{uh$i7+m>2-np`WO9|gUe7%`STo(Wu9=@P2Xll@khjpCvM$6%)v zWw{T$n?kwV%MxI<+%`x(VS)hm_kkj;#DSya!l7l|6kJiC>^TlWVBN!10YK0+axF8a zT&5;ySQ#0}J2ON=@f$QZfqg}vCf^IOw;X00UGkzE4i#QLo|{I;%d{HY2T_4oO@zc( z5@f?DW72Da8LK+vNKiSVbieB9-hapU++dhQ+yhzgK~K97fD`2!q=&jmlbfUW$!P+( zg#otjPkt>(gmlnCLFPH0HpVFlt=R8k>GLxE9DVg->)MAo4EJHBx_&h-^uOO9aI*A?k6p@^y z5EvDYpVocYkqPKo?G$bFWPqu2L>Rp^PZcvaF-H%lh#|SHBT8WZfed_|XUcJ&0GK;j z`T#jy^za7?^qZM-Pjfn7;%s(O!DpBb=_V%Xfg6|;kIBe)@iraJyz_n@8?s+GSG0=4 nz#aeJBs>^=`*|48r|Wf-+%BCN*Z2-5PWU5=hTi}#i*E1c@^iP3J{VxEKzh#KR_{?=B+T@>_i;`@HFv#zO>L9pyB|MTv{AMbzv?enj<5dUS5+R413Vqc3& zOI`}=u7?Luk1em7+HGO^Dx4SB_DZ0&%ZsbHy4LkZnbzpqp&J7i5+~MOT>ugxkST?B z(K}5?mBJK5_k9ZCdw-DVC%eA9zKr`T z2IH!09#pNLgwsvULEpAPpYW#PqDdz!6r|F(PSGLPqO=N4!LpS`x404QJ$0pS7vFuR zk6X3-o-D9JTe`lWly<60=o0sAMO7;IGi`k=Qt{TPwSGh`6!s7) zR_dLqngT;`a5asS&tE9mvf|!SA7j609pw}`#rhy5(Tj;kXR%w|w*GQsc`#qt7F-CG z9q#B0*o_K%_28aaXuIba|@=7E7Y!v$mcv-pxrrM@Pc?n zkR6lU;E@yjK*DK`AaRB#`t>8OAj#|a!b2xGgM_c=4T9&mg9K?^1pc7)DGniFQV9nh zA;~P4AmW7z;42mm5Xe?o;8`;$RaidgFSYHd(lt($JW>gDSHO0Lc;tMyJcoOxeqiz2 zkWQE9u_F6&w%w{{uh$i7+m>2-np`WO9|gUe7%`STo(Wu9=@P2Xll@khjpCvM$6%)v zWw{T$n?kwV%MxI<+%`x(VS)hm_kkj;#DSya!l7l|6kJiC>^TlWVBN!10YK0+axF8a zT&5;ySQ#0}J2ON=@f$QZfqg}vCf^IOw;X00UGkzE4i#QLo|{I;%d{HY2T_4oO@zc( z5@f?DW72Da8LK+vNKiSVbieB9-hapU++dhQ+yhzgK~K97fD`2!q=&jmlbfUW$!P+( zg#otjPkt>(gmlnCLFPH0HpVFlt=R8k>GLxE9DVg->)MAo4EJHBx_&h-^uOO9aI*A?k6p@^y z5EvDYpVocYkqPKo?G$bFWPqu2L>Rp^PZcvaF-H%lh#|SHBT8WZfed_|XUcJ&0GK;j z`T#jy^za7?^qZM-Pjfn7;%s(O!DpBb=_V%Xfg6|;kIBe)@iraJyz_n@8?s+GSG0=4 nz#aeJBs>^=`*|48r|Wf-+%BCN*Z2-5PWU5=hTi}&0@66cV&2E!a z^pu_PJil*ddUsS53>p)(=XDV5P4ViaEX(HuaSO6W`AB9(15!)992>N)k`;JqG+zjE zrh`(+m-nJ+J+COz)?1Pa^1lG(M0`7!N+s8ljSglDp)IYwTW+Cou37Vu7xFG6&(L^arycEr;k@3&an&A^ri++wM$Zd8aT~~YG6l^%p&U=~AHP*oApg>2rsqYJ^LCITDbD;M1 z&#_U$q2LYyLnP4~;Mb4%Rb*+@B#cql<4a;R+1Hk=dQix$2u|4IXDG}1b#WDxY>P8x zLS1}CKFH7%Pr(;YX612N7L#Hlz&?w`ckXfvV6LcN-iC;G)<%*sr{q2i@*5@BqA@jD z_jJjsMtI28-`x{HS#IXPMMW)R)G98KhQpj%VWlDZr3Pjr1OY{3z3Z5+7Q2$=}B!ex+>K3LG}mQkjF?*%n46 zUB_+ajmOq?%Qf?f1sfSSSx+ZYuMPIfO}Yy)iCgm>?)$j=-|kubm`)sK+#0(pXP1dg ziyO~d*fatUDWtQ8*>86QRmOB^=IPetJM#iZIbWG=aXH^eJ(jO;-v$(wQLpeFq5@xJs$o?n)RsqC-O;h{de7ut;G2FT?0v vL>0~ZbHzN|;CDKQ_?w0W2O$14ah#>Zgpti(F<%*T?J!9K(hl$E(f!ds1KW1L diff --git a/corporate/tests/stripe_fixtures/initial_upgrade:Subscription.create.1.json b/corporate/tests/stripe_fixtures/initial_upgrade:Subscription.create.1.json deleted file mode 100644 index 3d7d3bc116859f0ebe5d9aab6b9dcf6a7c000476..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2160 zcma)8QEwA541V9QsPc^1%J#4!A%rLoNWi4Q69`3aE^Pz3+>tod%C!HEoy)bk-nF7H z?b(iF`};m$pDt!I0c~rI0yK@2E3IX#z0wZF>zRwzzTl9hvDUaXnS|PaUuy{wo0`rj zuy!ZPAgOlBQh-yN#?y)URsTs*iX7TXK)%N`mDH}Hs1?|tivV3wNLhmN8c=iS{3@q> z)x}5esoY_@<4cDz0l{LOK7RkOpbq9;lNIcOj1G;JRn%pH zm0m%#C_@uzeglsQs-ZKbjk+GSXt+BO;&Sq4gSrkB@I-(RBo!dg(~tBt%hu#MH7Rz| z!F-FEx+I6x8X@O@dgdW#d$atd6}Q1f2IWngNW+z->Ey81g4-9GZJ^L>c`E2*!a3(8 z`mNVquL)Zj1FXxz9YX3nzFPv~P|tywGN_HN`K%yT5k3Zsf+%icCf9>RDltUuI7JNA z@N_paZc8qj>&N%o<=^|;yZi8P6y{L-Em+R6)8MDQvgg8lP=KZJ)J?@b?6t5TzzIO8 zF=y0o2VLvnT%_9vX%CPkR0h7*H&yKLt$6+2c+B5t5m9Q@@Ib^PU z?DgQns`2${aM2sEy?R+*Q@12)NZhpvV_H|*1zHAsYL+bh6M^)J4CU>i-vMRGHKnth z?m2z`?9Pe}1ZvM80V58BwI=jz6WAa%bA+&nK5^o5JHH$WC%i39Zzj}F)ui^s6h*{{ z8bxJg<^-o0NK;IR@~j*MjD^_08%|5`jQD8H^71~GUEWcr1t-k3)b{of1XHsk`HZG_ zPEy5W3o(iRl93n-s56bFBk+u9$azjKw7Y1BP70;t(6KngiFYBHF|rhUlCT;C!ejCG Y;Ob>s#<`^KJ>GACZ0xhM`QrKFA4l3)RsaA1 diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.2.json deleted file mode 100644 index e5e5d74652d4c4d517fe55d0d957d4791b1caf1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4876 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 diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json deleted file mode 100644 index cb6c2a71317f91a632bc3fdd32657f0215d0da29..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3438 zcmbtW-*3|}5PsiZQTYiZw5{tNHY5ay@qh$O8ay!}%T3(YkT{utR4vo~ckXN_and*) zVh>B|yWD;L?(VZ6Pi8a1vaGVw&?PIF%s6>HJAZ}inc~dw9DdKwn?G-`N^zO(!9uLg ziCfJ1irKiWQoNWt}Ne8&OFN1j&l>V)nv6UxuJUJ8a<4 zhT|m@1%4q}$=`YR)2u4}7XtB;%xe1e_U_C3&!2vLyaDiwV3pfSWSrKDufz^LomXQn z#Fm#;judn_OKw=s41>i-paqC=IjPU&1GkIXD2(b+5paKUd##qgc!t|YWTq;)@_6({ z)U}Xnu(bR01nMqbNPhnlLqVnu)3mHq4<#&jISQeTiw~=&GOVBtiLS7)YL#Q~Y&$<| z0BL+1kapH!X*Z(nvaQ+(L7~KHUQWVf8$OO?` z=2ZAng?+>>G}~tdr_igL3fb;=r;7$WV=EVw8O$iEoI*_SZOhto<2g8jbpR7V#L-j>7={@i1&TU*lj1GmoG5Tkq8wsE{)nfNyyZm!|b$zSv4p;(^LK?2N z%xTM>GcR((fWfELr1{8h$AowgXR^G2MT+bh+e&go*O}!h7X#`g|0f|aGGYGglW>P+iNxiC6d8}=W5+LtE;!qIMd0hiiv#%W` zAj({7INf_A8P2PiYVu$iXpF(retxkSGDPC>L9R#Cp_k$*`V~&J&}!r7S}=BnicYs+ z>z{GGeu_`J7^xd{{We~DGle`75Rz`rl;%WdbDS}19x@^^X>oamtfU3yxl(dejwj{g znZhwG8Lf3(EG8-e0+1AlW6}^+>p&F9Kga<|34u4HB!HNs!w)XU#~!ZcaNxp`U)6KO;$7pz3{pb6ytyHz(F5kWthGL g_pZEv#_{I%C*-P%1h-wITex$s!nHRzoIIWU1=uBb1^@s6 diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Subscription.create.1.json b/corporate/tests/stripe_fixtures/payment_method_string:Subscription.create.1.json deleted file mode 100644 index 1ce714d5145be88cee1fba2144fafbc45977c009..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2154 zcma)8O>Yx15WV+TL_QmVOgb?L`1XL=VK**YSC#k_}FaC%cs{VJzUhl@6{Sdh{ znRz>&nfIQzPiLzYgQihZfR{$|Eh2Bw3e=vxUg@CJiwN0BrKH|bh&j|1mwIocKvpKX zI0%Itw0JO8gNhhpql2l@6X*U_xDA0YQIR?VZecXz-A+elmlIV6ji#uYL z2M4=yaYsm<*LO`c9Q!%Skvs8#b$phQridNsi-ridypsq>8WM+CBDiY!bi1-R7QZ!@ zkMH-JzjxO+ckccKrajs{s958GV5hUZ)53Z%iK6w?riwH4PUsgfLkRV)rTDF(9UZg| zbo!v|k+Q_fAlFz^g_h6wHO+Q)bM+Pdx7+4%SjS7WQGK1L`_@u|=g!Aj3)*dst>?-0 zR)Xrh8_hL!N|FZmhbD1MmlSG8S3xh$Ql)(a{cP^IPpNVl$>%}F8G}O{%oIR z58!A(dqNF6u2xCc>5X~gX`B;|S+GeJ=lk{f$U5$A8G19}f9fU;$fiWXM&c+sD=Q~L z*}$A^!j_AMWH7d3_fD8M;e!2UjF)ezdCM_5ZMHr>qF`!wWT4UZmP}P_wy+cXFC($B zh1$^AJj^fHhLTH$q1%gY(MhYc8XDV!&AbgM>_fC;CzI$0=HapU$Kc{+`}S*_YTxAD OEs#6ys>krz^VvUia8zIb diff --git a/corporate/tests/stripe_fixtures/replace_payment_source:Charge.create.1.json b/corporate/tests/stripe_fixtures/replace_payment_source:Charge.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..f467b0fbdd60a23794e4a2ad903f43410a19801e GIT binary patch literal 1923 zcmah~$xa+G5WVvityYc*W(E|YoPv}bB4t5AR)a{Kuc!A{?@&M`UI5M9O&E@F{6gpftc(Y`36bEx@_ zkn8A0A0hf+I&?|Mqo3pTC;~lq(hh0e*o^~$RWQ|URMJ-00ki-oj;nVUi<3q5P+gSZ zM8xF=Uk;sgQ$5suVn1RqOVEn zt&w~dP{}QJ1-}91OE)`;FCf4KVAg+;CngXGILax*8qL<&f@+v)NH31Z;X z@Dz_oM9k6{?v1-+K0t`6kQ%IYW{ZNYcdXZD5HhCO`WdZ~O!_;faEmHAYvVdlqkYnO zZd?K^yd@8hN}*#@Q|l|(b*Mu{sab^qPlfsxc#(0bTE*V?!2P3WaGN%p=$t3>n@6(>@QQ8#nEPXFQNp~1>O~)|r@-OLO z@XF)qE506u|G#0B4Bzs@Kw_t%lbk8=;e1{jK3F1<4lRk#dQv6X4k2C4)#7ZBHZ)Om z))DtpsV~lUIXaX?dMe87Yfg7tj_m8t7G2Rd$u*QaI$>q5)`B9la5gDsa dw&m3M$T?zqJ@XaK4nnPJyBg>K+QTbrB~$BnSwKOG&Kv6_P7hHvHc^GbC5s z6?d(k>O-t>KF$p1@y*E3XNv_BQZ`-f_)3(bmWutbxOju-(kS6n0smiK#2+u{j8V1R zgM@XqV02ihwdhL6ZPOW2@(emqdr=r=EtlHSg};Qqae$h zTp3Yg9NFL6ig=5H9$tLkA9mI?l`;sHo#lVtefZ=3@4tQi^%gR)>`^&@OtX z>8VnfV(5OXp+raYlU-k4U&i~Da-tAUfad3y;3+%{6T;pX0t>^K2yRyYC`o>z!FcML z2UY7Q;dE1T(6?>SC(JY?nsl;4K`DLf6g_e+N~_Qm99wC0ix<(}Q&;MC@!e87fLF7eJ*RHbr1)77`Ksr;1?m3&T_u6K==inm6s^&@Jbu!oehQtwpN z6c~bot7)8k{X)Z*75A3ysPXgZaj`;6mu^kfSeT zdl60teU$I{A!NSq2iW=EKZa&|cbcT53e78J@Jkva|_-RO|%k$Wg zeLLH3)w9=WjoP-QmYycp3g}0{>?THxZI5pP_gA`v?(<~76=tKj=)f_!DMeZC1Mj9# zF88toSS_~=QqORQAgxfwxNwwQIJB&rf-9PnJ;x1TssJEp8o4&Lro_9ntc(ogof#sb z`3;(zz@9LtDIA5^TMoO8E_u-nmkKW*?@goUWkyYS8N_NLB)*fN8b%e9rU`be>QEy= z<%rV#si%AY8ULMJ3=a?h1t0XZ3m(oU+BHZIeUlb9NAHu@1aJ!rY(Jj{-U#Vngo4bO zjs|@y1#iV>W7Gz=KHgceIZjiAGg0Bd0!25l&iH^o2$oPcB`kmn|F8i!%>tlH?>n{Cx~T;(=!i=m@zx0cC&j) z3IJQP=>>VM2z8d?0{jz zMfVwrfy!y?n!>PYD%ZlzLgUuhY zZ=C>^p)XlCI))6JJ|3C)5&ok{fPXNrl+WG)_z3Jf(C@^nk+C8o5e2cT$O$EQhbVxU`P!HU| zoOn!DzKgfXKnu?MeQd~nK@_c`FmT8Jw+Rmx-+mv)_vw1wB-y1m;~C$<#tDCjqUARL I&R)*`1sbLP761SM diff --git a/corporate/tests/stripe_fixtures/replace_payment_source:Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/replace_payment_source:Customer.retrieve.2.json index b9885ba9ba8d270536677d6342b677c8a23bfcbd..c0ff70e2044692bfac390babfa904bb17ea56fff 100644 GIT binary patch delta 21 dcmbQIwq0<;FZRiaLe7i^lV`IVOuj1Q2>@Ia2jBnz literal 4894 zcmc&&OK%%D5Wf3Y2t2hw5KFcXPJyBg;vNb#b&)ie6a_(XDT(#ILUIMmhW~qKhUAL7 z;;z+GeTWs#$C=?gz8U%XVzFRC%BHIwUyD-IQn4Qv%Qtu~jS@~3@c-2^{&+!WjH=}U zB&@Rqqr*aNL{~a)o6eAuXV8J#i^3>txzvs>{2`>?Xi1k+F}h#~@uk*acJbMPbvwqD zx>hSR{G~3GS-72ASS8+MVO;K%d_Zt3Y*y112^{Bnfn=1~r zsI=rISi2cMLp`>tun3AwL>=sE-aT<(TPo07l1?v z)JvgV6li*|gr^SOkCl|@h<>sg_=)!`@lP;=8Z( z>rU;zCkw35meLoL(oIzfUE-acs7mF2rmJsdQ~4_)D*2o;UGEz$6>p8&=ttB-VGk*1 zrQWNmDKG>_SJOE8Du%W#EAB1z#o`aGqnaY8SRaHWda>~6Eq15d)+aYM3G?l3!G+M@ zAxD3a?L{~pbW^_bhmfQTGTlGG&NIL%G}|L5uptu+#HVa95N8-+ET48^ni)t(jvc%p zCJJg~k|8{Df+a{e%@icgutk4s~yCbj&olh|d36p*} zvI$9M83hq9)CPZJ`v8sXgax)WgNB9UqyAFco-keGM9Cwetb&Tp5Sv`?SC?@rSo}03 z)zxL}%f6{?x9&OYymoEdQcF*k8wDh!V2%?n#^%R2f%|J+LJxY%-wCr-Ty!w5;HDI1 zc?j&ALb*K15@5C5HAq0ieS)+?8RPO%a^cXjZVIkwX7)@sfT;q2plRgV)XEa?(&92Q zkauQ?1PU-{ZUTG8w5D(r;$S)KIlAOUH(W5he7r-Ao|hRo;bjo3iIDhCf@&C5OqwRx zx2j`}1eGIV_otrp{b&4la!EWw02F-C(=K>8n`qY{J@!pn+#J16UK7AAEVKQT8h9h5 zgFy;1XF3}9sT8~w+pSSs*Z_HF#r8B!5za(~V+$1B!cyb=HOlaJY!q%`5A}-+R5s+l zUy=ss`Pz+8VEfbMI|}{p5N-?rziX}^KkU~}_qXrv?WdC{VQy;JNcVzRhB!U*kcb(x zQ))N6oumM;HJe_L*IGVAW)^kbfma!Cj%7eO&LCRT6rJA(@gqgy=H1O_<^H^DA9FgL zRt7s@*l^K(Mq;3H+P$VQu;klKW2)E6#u*JnA4m=@Ia2jBnz literal 4894 zcmc&&OK%%D5Wf3Y2t2hw5KFcXPJyBg;vNb#b&)ie6a_(XDT(#ILUIMmhW~qKhUAL7 z;;z+GeTWs#$C=?gz8U%XVzFRC%BHIwUyD-IQn4Qv%Qtu~jS@~3@c-2^{&+!WjH=}U zB&@Rqqr*aNL{~a)o6eAuXV8J#i^3>txzvs>{2`>?Xi1k+F}h#~@uk*acJbMPbvwqD zx>hSR{G~3GS-72ASS8+MVO;K%d_Zt3Y*y112^{Bnfn=1~r zsI=rISi2cMLp`>tun3AwL>=sE-aT<(TPo07l1?v z)JvgV6li*|gr^SOkCl|@h<>sg_=)!`@lP;=8Z( z>rU;zCkw35meLoL(oIzfUE-acs7mF2rmJsdQ~4_)D*2o;UGEz$6>p8&=ttB-VGk*1 zrQWNmDKG>_SJOE8Du%W#EAB1z#o`aGqnaY8SRaHWda>~6Eq15d)+aYM3G?l3!G+M@ zAxD3a?L{~pbW^_bhmfQTGTlGG&NIL%G}|L5uptu+#HVa95N8-+ET48^ni)t(jvc%p zCJJg~k|8{Df+a{e%@icgutk4s~yCbj&olh|d36p*} zvI$9M83hq9)CPZJ`v8sXgax)WgNB9UqyAFco-keGM9Cwetb&Tp5Sv`?SC?@rSo}03 z)zxL}%f6{?x9&OYymoEdQcF*k8wDh!V2%?n#^%R2f%|J+LJxY%-wCr-Ty!w5;HDI1 zc?j&ALb*K15@5C5HAq0ieS)+?8RPO%a^cXjZVIkwX7)@sfT;q2plRgV)XEa?(&92Q zkauQ?1PU-{ZUTG8w5D(r;$S)KIlAOUH(W5he7r-Ao|hRo;bjo3iIDhCf@&C5OqwRx zx2j`}1eGIV_otrp{b&4la!EWw02F-C(=K>8n`qY{J@!pn+#J16UK7AAEVKQT8h9h5 zgFy;1XF3}9sT8~w+pSSs*Z_HF#r8B!5za(~V+$1B!cyb=HOlaJY!q%`5A}-+R5s+l zUy=ss`Pz+8VEfbMI|}{p5N-?rziX}^KkU~}_qXrv?WdC{VQy;JNcVzRhB!U*kcb(x zQ))N6oumM;HJe_L*IGVAW)^kbfma!Cj%7eO&LCRT6rJA(@gqgy=H1O_<^H^DA9FgL zRt7s@*l^K(Mq;3H+P$VQu;klKW2)E6#u*JnA4m=@Ia2jBnz literal 4894 zcmc&&OK%%D5Wf3Y2t2hw5KFcXPJyBg;vNb#b&)ie6a_(XDT(#ILUIMmhW~qKhUAL7 z;;z+GeTWs#$C=?gz8U%XVzFRC%BHIwUyD-IQn4Qv%Qtu~jS@~3@c-2^{&+!WjH=}U zB&@Rqqr*aNL{~a)o6eAuXV8J#i^3>txzvs>{2`>?Xi1k+F}h#~@uk*acJbMPbvwqD zx>hSR{G~3GS-72ASS8+MVO;K%d_Zt3Y*y112^{Bnfn=1~r zsI=rISi2cMLp`>tun3AwL>=sE-aT<(TPo07l1?v z)JvgV6li*|gr^SOkCl|@h<>sg_=)!`@lP;=8Z( z>rU;zCkw35meLoL(oIzfUE-acs7mF2rmJsdQ~4_)D*2o;UGEz$6>p8&=ttB-VGk*1 zrQWNmDKG>_SJOE8Du%W#EAB1z#o`aGqnaY8SRaHWda>~6Eq15d)+aYM3G?l3!G+M@ zAxD3a?L{~pbW^_bhmfQTGTlGG&NIL%G}|L5uptu+#HVa95N8-+ET48^ni)t(jvc%p zCJJg~k|8{Df+a{e%@icgutk4s~yCbj&olh|d36p*} zvI$9M83hq9)CPZJ`v8sXgax)WgNB9UqyAFco-keGM9Cwetb&Tp5Sv`?SC?@rSo}03 z)zxL}%f6{?x9&OYymoEdQcF*k8wDh!V2%?n#^%R2f%|J+LJxY%-wCr-Ty!w5;HDI1 zc?j&ALb*K15@5C5HAq0ieS)+?8RPO%a^cXjZVIkwX7)@sfT;q2plRgV)XEa?(&92Q zkauQ?1PU-{ZUTG8w5D(r;$S)KIlAOUH(W5he7r-Ao|hRo;bjo3iIDhCf@&C5OqwRx zx2j`}1eGIV_otrp{b&4la!EWw02F-C(=K>8n`qY{J@!pn+#J16UK7AAEVKQT8h9h5 zgFy;1XF3}9sT8~w+pSSs*Z_HF#r8B!5za(~V+$1B!cyb=HOlaJY!q%`5A}-+R5s+l zUy=ss`Pz+8VEfbMI|}{p5N-?rziX}^KkU~}_qXrv?WdC{VQy;JNcVzRhB!U*kcb(x zQ))N6oumM;HJe_L*IGVAW)^kbfma!Cj%7eO&LCRT6rJA(@gqgy=H1O_<^H^DA9FgL zRt7s@*l^K(Mq;3H+P$VQu;klKW2)E6#u*JnA4m=O<^khBL#NZ@w9QyI3p;Sy#d=+)ga4U~eW>Dc=eHhzD9` zCoNtTmmCCVF_dZXvux4$HGal|0QqzXYr}Ma#7AbGFdqQ1rHuful$VE|AQvhYZ46E6 z7JSdA-B$DDc6i+R!8|`)=x<;rZoA~@77-H#ad0k3202g6eBSS+)_;$?~ zn=TpIcwI$sVWU^lmG?`1a<+k!tsFnOflq0t?2Z8TbO5f6B_;1GhEOvR@kvtda?D0-Id! zSC_dtE&du*{4)0?_>WC({rbpZuWQ$~Rg%Z6Z3K{z02&Qt%*~(Dvz7{a5Lton9Wz@& znFrQ@lbk7aDAS)*DL-%pYMSpF1WljLlUa`3uPAfqSvD0Fp)-z5*HaS}D5lgGz?_;G z`D~aQ0|(>5^fiG347!`pO{96Y7b|wK6qrsbTKN%5IT>Mt%vYxaC*2HkHIb6oNq~j{ zV#+cB+tw@Jz^>=ID1 zVZbBa=myXN}o!+!mIfBWv0l6QJfhK>rt)#4!Ji=uH}7s9g!}WZeHydr zq%hb4{f5iolOP5v54+ct4vccUX-s_%HqJ;O`b2U_3DbT90I55)h(gFP5VD1Z_&AAU zfW=^o56D-~fMvqkbvw)tDVqn)j&MWIL7ZvPa+++(&G>vv!mdokluNSFS(jwR#495W zZEk-mAN@ENd6ASEXih=0n9-%j0y<=bV5X_%6{IpvC*+kxa+Zu?zdwC(ifKnC2#e@B z+L$Mdqja1qW-2j959f%XsMZN3kpDmivCl`^ah?E(JKOr8a=z)|T^8oeOuLtu9&d3n zyQ$!tj7Pf3K<&7JIQCSme9O1RKuf{LeXP%ZNtC^!FmcEKw+Z(a-{U@v_mg_l6x9uP U#%FX78z;P)hUGUYT)bTT3$XxASO5S3 diff --git a/corporate/tests/stripe_fixtures/replace_payment_source:Customer.save.2.json b/corporate/tests/stripe_fixtures/replace_payment_source:Customer.save.2.json index 804e2130cefa45e362d79b33a5854a5b50effb48..445c8137ae76f064a7863242ea19bbe129ff51be 100644 GIT binary patch delta 96 zcmbQp{f~P?GLye&N@{LFeo1Oxa;2_!YNe8uf>NTTNus%td7`eFiJ^tAiG^9RZjwo| ynQoG$c~X**Nm`0&aRaX diff --git a/corporate/tests/stripe_fixtures/replace_payment_source:Invoice.create.1.json b/corporate/tests/stripe_fixtures/replace_payment_source:Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..f3f761177942592987d9bfc777403e5c71e56b17 GIT binary patch literal 2270 zcmd^BOK%e~5We?Utb78As!3EK#H|7jNW28MB4o|lPGj)eiyxt(>VIeK@jjBYfPfo0 zB{So1Uf;~*=A>yDbS~M*Wm4>Evv@QU^`PaU%&QKx)%I#q^u5snqIQ6}xPif~;mKAh7aTC{o~W-oAf*@#^K}i|32QVo5dD4x8TYfCqow)>e4nT$COuY0+kzs4@8fPx!j2+{_F1^o4~{A#MS zXoyg#dwh=Axj^*6q8?Yhst)ROnT+n64-u>cPwg2DYwe&lxe+6Os=B7Af1cuCT+5`9 zrHIA!?>#emt2!s|a^I;4wJ!lS&7^!maE8%!N@hCScz-7Tt1}uG@v;eW>eSFiK*x16nA7jE>li5Ogb0!43C4# zXkGBT_QRk%UJu2)S3==6;$sxU9MeA8zc%z=0Ff{ioHRb{Xjd-e9VcRJLAW0Hje^= z-pHlNyyx>bGrilZDh7=W26-JMJE;zz4MZ)ddQ;|P1Ij3Ky;8KTRsv92!)G!EF+uAb z2T?XHauGAE9I{l#*yw`HrU5O7dI3hn5Hrv|E2c{8oQ{b(xNFIQOdHB4Kyg`!0o9p&?QTqS ze0h0lZ;n4*oL`AgE?1v8!IW9Yr^BO1s!QG0vqwlhieVDgL8*?oyDB?Im*StV?@?RQ7chMs|DN+18{pBX5z@f{(1E(~u4@QM{WJWbxiTuEoHLZhJ385SWuf(lz9L5q(+7Vb`4_1@#b{>6Wui7kbBTj@t-BJPs_CoKilp rF&N3yGm&*Zs0g>i@0>ajly->Sy2E!lom>0WQ(^IK-1aHnyWjf>sUnkG literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/replace_payment_source:InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/replace_payment_source:InvoiceItem.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..5cea8259c0815359cb8727bd583fedc69665a541 GIT binary patch literal 471 zcmZ9JOH0H+5QOjhD~6mD6p|1;coX#?_`;)?r8Cn?%RJ&d7M8I8-R>mr8grYPuCHq< z<3_0pTqr(Q*Xr`BtLyqgG+kmuulKB0C7EgwQw|QJh`OZh^UK@Q&Ev!S{T#OhXJ2mRe44{4U@#Wof^eGNsc0E%0bBp7QqJ|Jxik EKY-zaLjV8( literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/replace_payment_source:InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/replace_payment_source:InvoiceItem.create.2.json new file mode 100644 index 0000000000000000000000000000000000000000..f796eeb199d3e4aaa44fc797ff9c61c651a24ed5 GIT binary patch literal 453 zcmY*W!Ait15WV*+O3og-AhLK9^dPtjdfUq~O-2|=lbTExr0l;tlh&@yEid!ly!SFS zZEIkNC9t_~?{2%UyHUuNlrY#I0ZqxyEMVq1U{cVQe0qI*KRmoVAD7I8<}stxG93VedkSh?hsLbP4?C%-PHaE@e^8jAOo z>FJC^bX&Or8N?*W;B={DvHwD=@y((^jKiE}#6(f|Pg081fR p4;p#(G^Utf>EW93yFg%Kt$SZR<2Had8xy0*u&ImQKtc+E0uU?1aA^vP+JJU43y=kc6zPOjbtFxwEKVnd)f&vD&}0LFY9FS8)<>LEPNLs-Yvq=( z<=(+IAKW#hOygS+5Z8LH#NkG;OzTP-N6X+s&77rwJdi#yK)JZ~+o3GEhIE$Fo#Xe<<}BNR zqxS3#Fzh;5tz64Ch80pXHwZItBPZ_n^ZTA~+}qsrWB=1QH1rVkyVywj&QP$ zG}(YCC*{bXFU0oUFfPFf@r}qX?Wl3VF*Pi;i@gWI(CkP)z3EL!Dw}L3CiY)4VtoNM zroMC-PKbt_Q*xo*MLTp*C=G{h#lc42g=9t_q}Y-qsDU9IvcCrpFXJ*!B{lEyb^|11 Ln?0EC&UgO+|5aE> diff --git a/corporate/tests/stripe_fixtures/setUp:Coupon.create.1.json b/corporate/tests/stripe_fixtures/setUp:Coupon.create.1.json deleted file mode 100644 index 2a77fe8e71a98ab6812ecf61c803f70a572a84e3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 343 zcmY+AL2JV>42AFc6~WM38A(gnvgAL7j38)>v>o<`albvh?^Yu-V=%vuu+0qOVknxLBIAOr8hkgj3z}k( z&-6IvYP`CfzmMlM$~xiQf)p(lX9AzF#Sr{xMvFApEl(MQ0u}@cf4wz00M%(V4lliR z$<%ghc07Hdk?Iw3F6TCWMmG5WfN^+zYjWL(q)$&p#Sx}<3Q~K{$}Zqr7vYTEt@{Hl Cm1dj( diff --git a/corporate/tests/stripe_fixtures/setUp:Plan.create.1.json b/corporate/tests/stripe_fixtures/setUp:Plan.create.1.json deleted file mode 100644 index 80d368405e62e5112cacd3f0f24b73925719e7dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 463 zcmZXRxo*QS5JY=@g`hfx1u3wV+N}#C>4`PDk{R<5$faPx@V|$LkibYsycsTMhmWeR z8{i}tY(8po0pBv2CWjLcvfyD#k_k!`N!ws3lEZG7!9^-1&BQ#vqE6OkM#qAY)MTAI zjDtiJ+V^fabnUQjix8Yc!`E~rcv0Lk8Jk&wVMp8DZ{6v1zV+j>-(*AR7EsBgt)c5H zW_$}*UQlr@ZAA(-g7RfIjRXP+B78h^5F`1`K*^qms_w+ zKlGnSU&r}gJS5TicUsk2)}Zv`+tTnVba0Vv#aFAz*!(mj8s&I_I{P HxO!GU+qjH! diff --git a/corporate/tests/stripe_fixtures/setUp:Plan.create.2.json b/corporate/tests/stripe_fixtures/setUp:Plan.create.2.json deleted file mode 100644 index e175f0664fbdbb4c64e8df93b3cd6fb73d575d6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 462 zcmZXR%}&EG5QOh}iYR9eiBd$umEJ1>cWdKpgTYxxYddPF`tJBA0#)3sZ^rA{@wF(+ z3ZzpjR?nqlj}M7V6XOI9g|{$8iM-ZXnvEwHNLSZ&B7@Re(Il+=z&U!W1zC7fu1dOO z43ownG>>iHwN3xjWWh5d$t_;-cFb-Sjn$%o#L={umv#6)b^Xb{?lL*#6?8Oh2z*b4 z+{5JubXaRPW6X30ZS!t3IvBx0fY)2fK}voR@QE_6n&M=(uo?Xh!!cz% z^oKWe@5kkmJx(FpaP7CV1jL@?k1~)w8=n7{>{x*op+*&+7-74cmcB%|ZAo2gC6R2X Hx40EQ1F(zF diff --git a/corporate/tests/stripe_fixtures/setUp:Product.create.1.json b/corporate/tests/stripe_fixtures/setUp:Product.create.1.json deleted file mode 100644 index 295cf23638c8959a8617a7fc8fb46d054bcb9f9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 460 zcmaKo!A=7)3`Fny6;aL{sI~}}EB%9V0-=>RF<{6hQQ}lZRsS6)+X51ZTlDN1&*ac4 zrNJ=mP~R#}1uq(5PBaz9EcEfIF$0k)cmcjRx1fZ;Xa%jV`r)Qu4cC`#V6l11aBPZ` zg~eIW=1o-{0!nG>>O+j{0uHHiDzW6z##B&Y%&jX zUC|H4QB-#>6sxuj-a@jAahTrF@cDL;XA?#+?{aapG^5W_xqiY|=ln`B!sM#=H)N?Q t+dQ5thV=h!{)jDI#x=b8_*=(-H%4fFgb4Ar zB4M!-TjrIcw(%xxqPH71>Hy_^Lyt{(x*D0zGjpTy1Mr`FR zA_J-mD|rxgQy~jOskLydVvfP$A7BQ!0cuoDW2U%qzi6GoT|-d>(0Ac&o6QfwkpL{r zHMKCY9J`loEAaabkBxCm?W4lW{k9s8BABeCV@n;OgfU&QL&??FuGFF%A*=Ca{`JJnX0eu zk5-BRQw2hz;z0uoRY@o6K^a}LI6mK!KH_Q|ruZHI&solsp`{T>QVp_PLIUPv8>TR> zr|2*(xuhvppK+YPEm$M)J1rPWX54{gK+G8*j4e-s@!igd9T+L~mhBKY)>sE8tVS1x z__L9Y`*<1;YWZH&xDNqcL-%Dj=^YY3=a{^lGKsJmE6?`q z*Y@*0mJqC+Fb5XAmuKKH4$u4blY1&6`S_qLXWU_w;x7A@PPCY= zaIU4`(5UQmOR@18H_}sn(&d$gLAS5+rI$;UX9hyb;v@x8W@mGrF%u7&v6!;C{DZBO z4W&D2Pg%)H6Iry6e>9F|%jl!?X0gx-Fn~fYPFq9Pt$U_G{=*JP$p~VgWC6sT9DabD z9(%Z)!-=yn@VTZ-Lrxa&DiCxYEvJ*};Ge{?+w8J;@i@Pm#PZ{fZ0vyP&AO}@Onwo4 zBRNLFE3(dIE-|?SCx6JDVfhX`y7EFbfw%B}Qg55gaQiiSfIH_lU3(MY?C$I@4~=-f diff --git a/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.retrieve.2.json deleted file mode 100644 index 892a5ed1784ffda2f4a3dd5df4fe2c16ecbd2ee6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6216 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 Res4igbfcLrQO+LE{sobs0e=7h diff --git a/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.create.1.json b/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Subscription.create.1.json deleted file mode 100644 index 3d8723db53bb3fbba428452ef8a9778be0cccd54..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2154 zcma)8O^@0z5WVv&kk3^YmOboNs#aCEhe~acwmr3~EaMo~tz)PDKv2Yg?~I)UhZs~Y zkeTs#X5M=f4wLDWu%=OxGcS#%YawVOEEn37H&Y#ydW9h?sg%?kFcBwoMWx;v$py)s zl<&C`lxhBAEJV&{-6uwIa0o@2$NbV%6!JjSnkgr;08v3mel@p(d4ZwxS(|d6;)AtN z<}ls)rNbEKnZ@LzDCbQruxP@AqhFTKpWl7@`2E8ZbTCg%R&4KR&|WH91p$$ZS*)TG zYZjEJ&IFhm*!!7P(s2VPavStKYEWWt#D!JyUg<^cAm9Kn01y@P44!_(r)g`Ap2D1N zvBLBr?Jcr&NgGlN&)WQlV|LW6ehH3kV3Cen*+i;fWpOs`@kTM-7uwnw_i~F<@is)1 zlO0L7*;ugwWNC10lMn6`Qn%x~2N+KEoajg$e-SmFCBP{_NBXQFh%3xwF-XKCKlmO~ z1Xm4Dr;%Y>^vNuC@3*VJ%ln6=dp--(Uf3;Dm}4)PEqiIlg-G7MM+M`lO@%%5O6Z*z z0D;D|eG@T;B_*_jRnW6KXX&57K%E|;oZn_B4@e8n zlG56Q$N2p-O^duR2MyXYXvk@_O1h43#2XLooMB9YjjXuY&TmH2agVv_Z9)G~O>B^q zNJ2*B$STV-M?lFyoTMPjtAZqKEXD4fa9M;`vlwm-fe-l(k?riC&$S@wOmwf 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 deleted file mode 100644 index 915de189658345b3eb654aa81867172325be874b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2150 zcma)8L9Y@q5We#(NYBOx@(w%`V~lt(QAwVjG^Q!t0@ZH2wo~%(h5zof-CeqDA##D8 znNDZs`@UJ8C(|imO`{}dAdRM5A!s8k7aGXxsrE{}2q7D(l+-&k5hrv-r9K$R1<9S1 zAGs2gX}&iWBj>a36QejfgrW>WerYO-c`$0rloMHis4yhInp?p@gwXk{O*v2T-dZek znC|k@VT=Q2L-J9S15=AoG~wRSFYCw8?>>F}{$Yh3%u|yUJ38t$NJT3zFmf@ARa9cj zyaMWs$El&cpIIdxH+UkqVb7xmW$2AKzbW1;LDUWdo)HEFqGG`C=|_B;w$|t=%;{CA zFnvgSi!5EzhSUOBoB#Qg9W|R@f`>Ljk&av0M5=_!;%wUEjbgelw6!q~@(`v1)<=|+ zJ&lZpXJo7|!*a=|~;li#nVo$SFieLRJvQ6=t#+B;t`D{1{Sr zR}D|6k%hMC$}FDVA2xs2%ZIglya>}E?18C}<0zOdduhjoNZ!6jh2yDB6?*8E&^xaH zf{kg(e`}UnXIgu_ebCn#vWUvS*H}~eglG5}d%J(Q|0>|ys(Bjb0V!-?Uq|Y`wCJF1 z_5v7k9;}kCV;cbj*vtjO#M{V;yTkl$BpmlPH@zvSAFGM&krGA7 zh#FaCdFBWw8Ay{9M0rz=gpGyRy%DZU@P_zs$%fT^DZ6$@T^AgYWvTV{5d@{#p?pTu zyCtb?vYD93f60i91(Zx<=@7Uf8g$;03+^u5p+%u24qb|aTzM0s8GMjpN0Oih0_-XK ZQ!sm(-h8Q~^cL?nKwD>*J(wq_$v-(8MmOpTw*|+*hqZ zf#%491MPwLo^^Q8?E87t z&3A-cM=$ya(FfC^OF|y~9IrXaFkPqHJYul1=TRqkZzp1aUwT&62!o% z;VB-Gh?u1@+#9!HK0t`6kQ%IYW{ZNYcdXZD5HhCO`WdZ~O!^H|xJ8wmwQ(J&(LU)s zH!cAd-jat$rO>gdsr41?I@F<})U3jQCqsa+v&prF7+T}xBN2{kmmLN>( zK(WYcEZQMQA`rulhLys#&=JhX$Hbo#0xeA(%yjJ5^RDeJ*)*FX%(E}8><9JxPZ|l; z*@90TdCita)B#vW+h1d%9homT`OfzsarBA*HcL4!08T_j}=6 zX|2?EGnt7lldsxc#(0Z-lZ-9+gOSemGN%p=$t3>n@6(>@QQ8#nJbf=gNp~1>O~)|r@-OLO z@XF)qE506u|G#0B4Bzs@Kw_t%lbk8=VKuJ}A1o0_hnB?WJ*kpxhmbDj+2VYVHZ)Om z))DtpsV~lUIi!?CdMe87Yfg7tj_m8t7G2Rd$u*QaTSy d+j8oBf&U488YP7{-o8m!!o|?6d;gVZc6$72To3ATSc$XptpBA8FH~|9zC4+H#y= z^C1XGiF_m>(Pb>b zMI7u7A!PG>Ht%lE2WNB+HQy4FI(kt=i$0hZU2^c~$N7t_0zG%qzCyjV_YMSB!Blrq zNn70n&;pz|t=?SD&SuqPbvY!V%R%acof6X97$zNRKq@MdaU+iUTJ#?7r)~#PFoA(W zcq;4prSF1F1FZRKT;;(oOT96YPl7AC!M4NHfbv84Tgta%R*}#w{~%ASqNLymYY)X} zw!{^vhM9)^BlZ|4@<7JM7&r|o6|^HKu~uVvFmBC!fDlu$HCXG+1_fJg+0NT1r3=In zKcQ=~OTT6cH>gq&ZCnd#bT9cmH!cAf_JeMER089JSzi*txX|n zh+$=%d?Lc`${EIFArETAZrcTAQU{7n5k}2%$dSm&u%)4=@GEF3^@pd#%`*bhL!b_H zM|EtM(=Hc_KAvyq*yEGKjl_q2Fn%SimHK{SH_>HA6$1nz9MPC@8S-vk1DB2DJzQ#V zeccImmaD=FE01t`^eE#i3pYuK5~wjp6b_$)cKXAB<$Xqn0VMDdLdlD l(>c-$PqWPG*6)2iRH~%!22e+r_2unm{7#QhrDJUori=j;=x`}0C?%H`6{Y4SS1MU4+Y;;TI007mj3u^!X diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..2d99ed340539690c8f0f43bea9e601e4b0f85959 GIT binary patch literal 2270 zcmcguNpI9J6u$RYL_UE;HA^$Ja6`ZWiA``TLe@*{=@=Y4co{lW{qH<4aWc~u5Jlva zynXrJd$~HQDgvGM%2DPyd0NdMO-MZmc1Sb41EGYv7#V#pMGH=7Mb|tSQC`Yq$2s2h zj<&(WsF0{U*j|IQJjr^eDX=Z5HrQ~+Cwe2KOzud#0dv7Apk#;sT99%_qH-3ZhJ)1# z)kz1+gG}6{htv{S>y6>6-33W+Sr+pa4RaHSt+({;yAN-ky?%B6^2K~UUnCg@E=0FL zN_wJu#@FDbqgHzpqc4sax2+L!42OUW&f@=s+Wt{pToG@{@qvvZ2#*C`Idxl964 zinfUOGc_r;ywmJ9_Z@eT3v+cn$y_&IcpExJLKSIrIAQnP2pz^D)i#HR?MKq$WOX`! zvO23*qXL%SIxxZD;$*q1PfzN4Rq8x-+#_^QY5+*K+#7A;z#{9gg5NwSCtMhfv(Y25 zzL_HVz@Xdpp&W><2)vkIj;BO3=9X6uDjfekMBc-$#r6g3q3N^b9i)p^b?o@XXpR` literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.finalize_invoice.1.json new file mode 100644 index 0000000000000000000000000000000000000000..78e8e3391d5094c885a9060a38452575fe6956b4 GIT binary patch literal 2403 zcmcguSx*}=5Ps)Zto+nUh1@`SYoVulseLL{WleS+;^MUzA0a8?zju7>k%P7>sCgmr z+~0gN!_8in5h#_DmKKhYldS)w^Hc#ZHs3TWflDqgT0vC_o`dB|(i!tkq!S|gv6hvU zrMZvL8pK!TeOv8VRCQKSC>9`d?_vhRM8T8`5yO#u4*G&oK-CiCMWP$b5DTstk?9q%%5d0@A zD0pZ$GMX!6QN-m!@tjeog%3ZswlXKZUInWIV=*swkgKwnwESO#Ma&PcUtNv9o)#CE z*Dsf6v)4*amwfQ{dCWd=f^fNxPmM=|R2{l1W)Bd$=iLOXkqpeB#~UOnvKKJ}{PivT z%F=6)L|&upaE!@$+xWrWZZ|)PHrjRW1h29$7GwcBvZPi_Erh99qiYf;jQH;{s+Cid zI1Y=4ivOLN;0so&VpsT*SxCU#+;%Eg(;qDNY#|;+8Ul9271Lb#sf^gB^pJn}G#E{e z`cEgv<4J3P@EZ#{&~G%HjE_d+@ub1I8@NL5qLZ5K$#bQZ4toPd&A=zwl>=$H^%HvB zMkK~fy92f1(5@;E2a+?;KGc`;X|ic+OPdc3_WzwCpI}uoX|qSC$dD|~YQ|+Y9*&2P z17lM`#r+sF?E0!_>(7i?GyBhsX;wnpzb<1M@xwD_&_=umV?tivqbo88LrbMMrj2wb z%GlH+VwDBaT)km(k(O|8F{n4L!7q2Nh+f=um_^_&j_mflldVZ7k}WO81@Bpjry(3* zq``Czd-P&pr!{W+K{|7X;I_?RY7DLlVUnWViW-qKuHOm=ZL)s+K zq!kQy-8mFs4ZfIqIjeA?ckE`rjjZ#>frOMpLU(Ko&x_C|)Q$($!m|6FVkJ3-?P9lX VaLuN3r7jI7=6j>JPx0RE-Y;5)lR^Lh literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.list.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card:Invoice.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..7a174d549bf7a64114e110ddb79635d7e723c302 GIT binary patch literal 2849 zcmc&$$!^;)5WVLs1fE);iI><(np>N6MK`pkrYHzXqGL7`sc>=Z2Ko06C5jSdxhaqU z&B3ub3*XGVnc?kTlAxRzf=-hk@I6Uxt4}y3g|x!p+)|K@pY@JQ!t$YWMGL|N6PK~D zEIG@FVN&1)bq3088ZM&B2{Nnk zr_WzMzWDI&;??ziEz%E6D)TrPKz*Ospi!D7K0_NK0c%eL5PIWOJYm zV5J9{EJj|-{nrrO^62Kx_2Kuk{PODN_4<7AR*H1ZChuRi5mo#`OvE{C@)c#Xfl%g) zM}RPLZbChPNK}K@rZ4*a;(Dg-n);XT9k4o@0pP}$7w#gD7xzkhVL2<4Z-$7RsFrDs zvM)PH`Bocv6`x0Te;ykHUr+{3>C(ym-ITKxEo8n6N>Dkj||Xlvmi1CQZ&ayUO8KcAn>=CL7^OdFyq8Xr#Qv*W|rZ0_e-?Vt~)gnj^r z-A?j1l}f5=j)6EDI$nNli7?E#$#4>*K-#9=<{fZuHl<4)kiKfEDhC~$5z06RE#vkd zPPs2+Q&3@&CuhL8q@EE86Ul5kn?4Pl;RDVe&cJEkXVcI3GjPlFKQl0lr?~cA1_qT* zWs@YTEvP>V11B+xM_^#U{Ihk9mPF%1s)4RY-LBTAQ)gtPAv|pSX~&&kB%{@&*%}(Y z>>OXCdO~|-A&)RdhG!8PoI*&@wZX8o9P?r2RS%Nh2tdLXX}#P1Uf+jecD8yOt!Hou z82R^7^oFVD8TP{~@S;ft^71gdwlx&ZD7*%60M;EU_0Ze3-@z-y=z?t)j;vlSJNAup zW~^I;btnPkU#6PlTUBPT3QG_fvhM%J5NQTb`uW~AzM<(-$}0_^U47N|^Q7Xz9eh>{ Q9pvT^E71V!(B9qNAA$4N+5i9m literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card:InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card:InvoiceItem.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..5cea8259c0815359cb8727bd583fedc69665a541 GIT binary patch literal 471 zcmZ9JOH0H+5QOjhD~6mD6p|1;coX#?_`;)?r8Cn?%RJ&d7M8I8-R>mr8grYPuCHq< z<3_0pTqr(Q*Xr`BtLyqgG+kmuulKB0C7EgwQw|QJh`OZh^UK@Q&Ev!S{T#OhXJ2mRe44{4U@#Wof^eGNsc0E%0bBp7QqJ|Jxik EKY-zaLjV8( literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card:InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/upgrade_by_card:InvoiceItem.create.2.json new file mode 100644 index 0000000000000000000000000000000000000000..41f53cd858e3a2694016d5bf0991967752a1d739 GIT binary patch literal 453 zcmY+B%TB~F3`O_-imGcCEp3^?4ha@WJS5o7X5=Ow$tX=q9wVV@{vEq5g~={AKG*im zl;wE=gG)9Rclk|ImgSX5DtV7qeY04kpo>)u(GBQDR4E)@-`-#DpP%-R4~$hyjfRN) zUa_*Anba7Rr!kVV0`HTdG5?5QHTczBQ&2Jn^hOMhU;@r8X+elC+x^;ajme%IDM4*A zW+n}taB%w4ZomjUByiz$p37YSiAvTlRu76eEZdCe$w~i-L*ZD^%B8B?U3uGfO)EUh zA%Yi!s=jWUUEMS->x=nlKvv#n8ol$d=J2=UR{|R;O5EK%Q%L>)#5O!xiihpLI!+-w FXFoDWel!38 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/initial_upgrade:Token.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card:Token.create.1.json similarity index 96% rename from corporate/tests/stripe_fixtures/initial_upgrade:Token.create.1.json rename to corporate/tests/stripe_fixtures/upgrade_by_card:Token.create.1.json index 774e3d9d0ba4381229bc7dbce56ec287ad31e102..b3a57bdccadb0a270c7f15f85cb9ba0f52683f2e 100644 GIT binary patch delta 13 UcmdnRwu^1UK_*6{$%mLK03=le_5c6? delta 13 UcmdnRwu^1UK_*6%$%mLK03=@o_y7O^ diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Charge.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Charge.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..ebcc9d7f6186265192faff0a5c9348f009e0f57f GIT binary patch literal 1922 zcmah~OK%%D5WeSEEEYW)wq(nW)l-cm?IA!BAhlt*D2Nd`t7XL{Sw197LH>J(TWTc%`T1*bJ#>JP>`(0tyPjkkVX@xqf$lv2(t$9 zRI~yGnj;Giv*5OsN`A5kg-v8FlK6KCjbb7b+{^=$1`#SsNU_*2{c5o33<`6=L z=C_O6`FwHzepv+3In;blk?ZJ1?;-kNI&=x-(X&qv`@7BN@bP)^>&mT*jzG_yv_o2t zcI`l56-@OKm9*8@09t?(7u8Sqv&&iaTJ`CXVUYSwCxrAihDiq+kctXn+=#Pyhu*__ zDsm776Bx+UQ~KY&=PG3yM9tebo$GFv+x_Pcw$vo`)<`~asN@#A62AfEOE)|6FCf4% zfLZ;GJTZZSfTNspSfkkrTTl%%4avr-8z*vek3bBZ8lLI2^9nC7aYoVhs zzZ?>ME-7f~;h?5tx0*KXWXF!9DPSIbsjHvUpMUjrw%}P;N*}GL{Nn(X^kJ;j1W?4k zvgLV`NjJKj@6~pGRi^Ff|DV*l8d4g2lfg%k$gyX6sKfe)_ByCc0O%lK>wqjk{ai!?T>V)ToqcZJi^6UHO5=yo1|>PpY?Qo zk~wu~NG8$ube&E_2WgYToAmt{lw{{auILu#Req8l2CF=tzT&SV`2Q!2lHpr^K9JaJ z=p<(fd{|6t!&gfL(xD~zrYBXx_6X@g&wnhRUbZ`D?ruMQ z9#Ty&MVWog>1@l9ef`y6T09Bjf&c5WVMD2*MtN)J~iZGBa3T_ws$3N=Xm*(-mMs1hQJv;?= zTlTRTH9pwH#oGYGx&`Xf^89*nJ)bXbKQ0F_SPP2nkVzRF&!PqAbptj6EI9J*Zg;cU z?7u!PeqY)3u#badR+u-c_hxM=hr;XPDM(?8pPnjCEk7@QyPaLkisvGujEB8Y?^cQn zXM&$psG>q*l7wk~5^KOYx}L(822MD54B;@Xm)E`uA~vvO^`y$p)w11v|6+!Gg=)26 zli*Uc&~%(CD&0{12Jbs4E4xsy{sk6Qg_lx`Tze{7krmWbRzz2bkJO_rPYoF_2G6ij zF`R(C~X@mN9Q8$ zn6{C?{4EPS=s-%aWavftgYFtozCgA{f`^Mdj^CLWp`^jr+KN5u^^ncbB|}r6M&dSI zP$HDa*cf3{8w(6jn* zn8!NYqiNZ~*HAd1=O?!kRs-9_1BSYj5igH2%E(3i9nVSz?}drMuj21L5{RFsutJke zbNe71%}=weB0opK|MN5?$Xa&#V!l(f5sXOc{9>9rJR8zz46$QHT~UX+~3)s3ycB8m-?+EWqM@s60){BL>=~wUXj;7 laUc4H!(JwH>*u~4GL^!04Jd<6{_>%nf9M?Dm1nPKzW_rsQd0l` literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_with_outdated_seat_count:Customer.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Customer.create.1.json similarity index 75% rename from corporate/tests/stripe_fixtures/upgrade_with_outdated_seat_count:Customer.create.1.json rename to corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Customer.create.1.json index 29771943bd4c54e58bf1dbbe3a42104b757b7f17..65bd59dd820a0efcab5176ad96642c6eefdf84a9 100644 GIT binary patch delta 205 zcmZ3_vz}*y2B)cssiC2Xg}KEYZ=}J0W3JOY@d1d*T$*J)LMX70-6-rhLN~T7Rt_Fq{o7EVlnfOq( vO!jB)6fjG1j0mv|iz+KG$~QB0^`88K*#xJxlTBFuqZ{JLIu%{$39B~%+Jr)9 delta 205 zcmZ3_vz}*y2B(3c0T>ugG&GR#^AGZM^zn>xb%6*%NyCYi4Z0|DhDthI3JOY@d1d*T z$*J)LMX70-6-rhLN?=t6hMUzGrJ49pwM_PB?i4@~GB7Zl{DRp8r?rz!SpK6M;>bD` JUFZp`Hvr=qH$4CV diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..46ae86b558fd741c6a87e2ebec29d0fb4d599bcd GIT binary patch literal 2292 zcmcgu$!^;)5WVLs1fE);MJ&gTmEMAEKzk|Lpm19h1SL^6V~SL`IB|mfdxwjb*e<%F zKH0PL&6^oN9wZ4OwY0+E(o*y$Nyj7jyc)t;y zymK_diV`gaxTBf~OL))*Kah5X6;>%KijA|hx{PL4Ltv_$u&^3moNkU+tB3RX;yX*$WW08mLQV!6RuTIH+n5|vaJiZu=#7d8fAx<#Znv=qnSos5bSUXF9Z zBPgAwVuEn?Kub&QziZz*B|r{}CV&90WCvwD78i@W*c^X2B# z>O&~6^=`^)=nY+<8U&9(6Ce;2OW@a6|0{`7uL;xwU7AfIt(+Wu_Q4)iTR?rWoHfZu zLqthb=uc}H!6P|$o#+S@)$xsN=Z)7_*YEV%?byGuHI6tjpee*c?AP2lThm&W`_k9c zkciCVlcQ$LB=Z|uxK<$tBlZag>V_&N-8=+vqZ9NWMCo*Pd^{axv#fUjEVUuZ1xzCq zXIYnRn79G%fK|(o7`ahW`H2P9HiEa72*ZqR5^0+cxx5w8Y#N6Lu**}=KQI>5 zmD4{it{-rCef-B9c~3Sq6()Ic1{6ucup~?*lksHya@gkbcPKOK$Kqyj^tPE-)i72& z9NU!niQE6COju8|lfHlZD0A}fDU{ z;}{|=LUg!J4`Ty0eA(F%?H)V~yAG=G+=ZFl9eA`e>PXOggPXugSj_(R0mZ|FW|Md< zgst;%WTxZOi&2`rh9#N$`Uh`{Wd?D)#@(c%urF}CAf6qgsN3ETF}tD#g?og%D%v>? z3+4H}3D*rG!Bb$;aJ!_Xzma=(vx}}Dv@!;v;J03KA!~g&Q literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.finalize_invoice.1.json new file mode 100644 index 0000000000000000000000000000000000000000..a5ef2ff1b2af0cca91f809b52bfee9af773b447d GIT binary patch literal 2432 zcmcguOHbQC5We#(ET3AbC2`2ZaEl-ywHHbwl(wo?YkQL{tar^m0s-;gJNvL5l2mOG zDi@M@&$r*q}Hv?(k%QWBo8t))>#Ff$4Q6KRFHQD`z=4{vWD&u0De zOfI}lfwco{&>tj8KTSPA1({>RG*ZGySACPHAS#!%vf$XTAqZ79BCVkzs0}_Eb%$x1 zxZVgapnsHM7NGGt=agecA4!3*g*K@x@=i;kG^i+F37QjRB(v$4T370%)2Zo^$Zkk(=@MHQkxRItu85?}TH~|4clnh40;h>w2 z($)d6R2s`o{*BTm+h*bl$O}Pgx`)V>6w)mYP;Dc4XE4`P+Y#*@wJBp|vdxj&im2C> z#RKT+i0>K<1vTZg&4n!p4j+fF=Ex;pmxSx+-6>Ec^3xho9`$?u-uumGF8+lwqjs)t z7Du0|+1&F#AXg9eHf8?c_P;6Px8&%!eZPH_IevS}xGfm4dOc%YT|a&C@e(Uk3b{3I z@#aV=9o{?ELSxq4yOaJ|k!+huk1*D&6CCPqL*Dw+c$Mk0p1mbNAr+;1`EHr+@$e literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.list.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:Invoice.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..c2b0ee3247d9cee841597bafc4dcf46508b3bc3e GIT binary patch literal 2878 zcmc&$-EY${5P#oa5%~!upiS40jJIhUNbmw>H*r@p$IkpwR;K;$oD(N@+7y96 zVh>&G`|#cGes|}Gqa;BEF$BF#zQFe+d8j|(l$6p6g9}SRmQKd-_iRU25mtb3?_AN6 zFu}ylfMr#2mJ`FIz$@xp3(I+MYz!?cgL8Lx-*qFQ?!pz}ng$zdBqqg%h}`)z$_Ckz zao)s%@-0;mEH_w7tLn^p7Z+6Lid7Cs zcSYoo=@z}dfu%THwpfhkS(bMBQ&9j;cuBa2F~qTAf^hbO7MQq_q-~_ZcrCR7F%uif za*D0uE~K?Fm3}!IRb=}@8;F&@$YnY5TJFC#IIW}-^X&BFyUXJKDm}S0R~N_Z;{5$4 zWK@$3F@e)_mY-&?*W#Myfu9s#yJl5Jt{T2N4j7YVg{2$ZqGj z?g{nXY$5iy%H=njJE|cEIj%3?QK_h6 z(v5;p3~vlXhU0X$n9pb9Y?1X1p=R0;74vwJ`Ki`8*bSzFh5(0gEBTu$B~?AhKsF@FsSqBYUDlxm>uQQX$rMBG6}8p6AsKZ)G=RWjP7?cUJv zWp949i(u5uXdhXJBg~QEVT1<95EAs>U|3s@`LOirCkgaov-z@Vw4;SBm(7l!O-?Vy zY4#k3eA?v}#An;fW_Yq%X^dv5|S-<@GmM2hAVo&~szES@dR@H@2V!7HTd za&8xote-Cj_KtIAtlNl4R#b${enBla`08pqIQwr(L+;@)JUiewhIDg?2hMw09Psd+ nF4t1tY6#yIT;j^afAX%f5apq}?255+m9*=$kEPw16 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_card_with_outdated_seat_count:InvoiceItem.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..e061261911458397767375822ec93eaf38dbe30b GIT binary patch literal 471 zcmZ9JOHacv5QOjj6;@6lfs_=t1#VRfNSrw47CH7NY#ZCDA4MVRf5%RuJ~(>3^LcjM z&j?AF)Lyydl{_wKnx+p?UQWfOT7Yb*>X z6p?^fWLxCx)yH?bk@-QYpI!a*+2&O>`cWM<@@Q4QX$EJ5N03_F;v_g0GP5w)pN27A z?tsTSl8J#1I1XyTCa(hIi0TBH)cmC#J2y(E`vGdth5~_{ewa zG${RbJRG0hZrIF=L0=3owCW(`X{ECZGlWSZb%pa+IZI$R10O36j3 hi6yBiN>&Pn28IxIhG2CQczGzNi0cJvQmhS)dBG`5{u(=^NUh}B58>^#i?My{G_bZ Yv?vE8pkHRFpPZ3cl%86w#8t}$0I0AQ%m4rY literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_invoice:Customer.create.1.json similarity index 100% rename from corporate/tests/stripe_fixtures/upgrade_billing_by_invoice:Customer.create.1.json rename to corporate/tests/stripe_fixtures/upgrade_by_invoice:Customer.create.1.json diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.7.json b/corporate/tests/stripe_fixtures/upgrade_by_invoice:Customer.retrieve.1.json similarity index 59% rename from corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.7.json rename to corporate/tests/stripe_fixtures/upgrade_by_invoice:Customer.retrieve.1.json index fa52968bb90f12bb03e3292f129339a5489b2c32..368c0c84a2d0b4525f932717918dfa2cd769fd82 100644 GIT binary patch delta 109 zcmeBW>t~yw$zf;!28I(2qXeo-b21AQG@`(iOKRD~nJPRPiMctcB@SSzi6;~p4JW>m wV>F!1$!IBUsHDTC00c@!sfjtc@x>)YN>&O=V3m{O7!Lqd$ulhil82bQ0Xu#n>;M1& delta 107 zcmeBY>t&mu$zcEm6Ah#Iic%AEa*Y%;AoRr9D%^RAxv36N?rB9vy_MFj~qO kE9r140D%%%Homx|NXZJyF`AsfcmSwOk!cZ-Ji_D+0J(c0MgRZ+ diff --git a/corporate/tests/stripe_fixtures/upgrade_by_invoice:Invoice.create.1.json b/corporate/tests/stripe_fixtures/upgrade_by_invoice:Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..4438bc12366d90c2c709822337137aabbd0bcca1 GIT binary patch literal 1723 zcma)6$&MN^5WVvi$mc}R18A08q~s7OiNmaW>Vh9juV@cEhwYR!&yexY1Keb*6^*Qawck> zi-jQDF7SqWjpedcQiCmMFLP`RmV?-XX{bhUG23gUb?(W$G=eL$w+cg2QgL!8!CQlA ztOXN6XK%9u_i*x{CORigbD+rRMG1>O*j73+HqrA>pTB&3^WpvdySE5Rm16`1Dpa8* ze^PrPw-9yU-bR;RJxng7Z=C7~6e?SaDBmM721dy*7sp|7ZeD{fkPJCnWk9PxQg9$t z2m8+YfX0Flk4{g$iBP7@>3sb>!=!z7C$`gU(U+qp4bLu9vr)oAqW)KrDR# zM+K|Z)nM+nFFOFM3Mf-*YLp1J7IQ&d_61gnUTrf>Q==rm2gZ8n{}3(H1DLZo$P!ofE4 v@sa|_o`}NmapzfpM>OQt_?zRb4K|-bcp>YztasM_^!P^N$xUzE>}mEFBevg1 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_by_invoice:Invoice.finalize_invoice.1.json b/corporate/tests/stripe_fixtures/upgrade_by_invoice:Invoice.finalize_invoice.1.json new file mode 100644 index 0000000000000000000000000000000000000000..0faae6d8f1434153b2557672dee06b649754c73d GIT binary patch literal 1857 zcmb_c%WhgR5Z&i1kk@U1019MRsc96YDN?I;k*c!h-fMzeU)%MALL>gYeP%WG}c7sV1>ZR+P();usQ>vXdGH zR!K}FHAoL4dCN7J$AgASZw!;=E<}1;w@Au3#Z=$| z&=@W06U$d@1ztF6l{c~L_T?3`rQte|5@0h3w+$0rpl9?nI6fBNO$!j2k~EAJmmv6W zR#6C)9(A-<)}bqtM7)RdAD8JyZO!b{!1~x zzTvxDar17m{G7Rtf*@QT^3(9qGS!sU)#^W#9tN1mHHv^)M0`S(qJEJxz<*!kzd^P} zPf|AeI#mZd=A4>-b{);@!3(YjpB%^vOp-|xm}dxu2!d$!o86ft{&$j^;3Q?raZ3=3 z@PA7ae8Xy0ohn~52br0teV{E@QpI;x2Dy+xV=CBlYi76#>zK&RNhm-xjVH6q(PB2A z&bkET(mF6vf0K*Z^l~zt&f04I!WzYkAdTEJvNp=ZgMqGN27h{xj=Qj;7Yq%?Q5kKf-C6j_6%wK7NSkv>D#MakhSopK=B^EJ*8d8>w-akD}e zf4n&IVUjL(5t+deJf9zVHaQ~M)=}Kpn&F*>bTfNY66xzA9j$z@UgIw2bJe{j^O1Ha zU-g!9k_J!nq}yE%?`(YSpbx!55J-nvbHWhE~iDe5Q&WCj; zN;8?de?b627;#P*-K{)vuth_gwq9_9Fe6)TVqR%t&SW*lpUMV0Tnb>Il$M#7n3GwRni8K_qGY9DXaEC~?V02p zl`=|73W}}t^$QX!^@>Z1G7D1mlJj%*GxN&wGm}$6ba6?%zqxlsTCul2o>zaTYlvK^NsOI~SC&g5(^M+jp**F*sPjEIr| literal 2880 zcmb7GOK;pZ5Wf3Y2t*Glki@&Sf!e(laSj2Jx=3=$1H`Pv-DM(?Dj!>K4FC7ekko^c zc7yt0XgLqg`)K&~O-f0fi0aQu;obiKf zBkMZG!K_dfm+GJ;s`bW6fg1rP@;Z)|B~8$vAc+>O-BJ(%4r>e3q{RCy$GZ>zz3 z>8;a^FeJcl{@4BccYnTp^Xc^+R&zb*VF$?R8c*Y2A73T5;A_XN_9mp4m8%kcsC321 zp|o8Ia|18<>C3P}SS-GQI@L0>-AVtK~s7 zx*{*xR3vdD9HjAneCj}zWP0;Wl+;N^Rpk;VZyIMSjMOx7L%owRLIscKfQ@2oi*;bg z`Jj@>BWg^8X%TWu_dw||LzIliy1xW=J0o^DJltED&E=pLs0^LlAl~vSV5A)d*m5r# zx{6`i+$-rO(z$(g)0y7Cci^cnKLzP>=tZ6WGCZl#|vLAJNTFq7HO_@hf{Y&}lvsZa6i5Fy)x z#qyg4)?ot>YH7z4#GH0m>~1ZMP|D)EV3&vWW#WE|A9=o`O>r5wK#Htq5~Q(ZE?kB; zQ{ut#7LJX;XAZ>Y#RPFo`DPQ>cUs*qFphzQN7>F^7U^-6b)I$C(O8EE&KSMKJ4m`AZXB?cqmK!hvn*<)5F7ue{KT%2kV%4CC!;)r1PfqqvTng8MRxVZD?#n~lHLdU& z4iUTvRQ0ZH_I1;=j4$G&0g1eM8ol$d_VAbSD}jv^rS581&#pr1|97?tBunwMeObpn HWS8s*jYxjY literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Subscription.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.create.1.json similarity index 67% rename from corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Subscription.create.1.json rename to corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.create.1.json index 8a2b06101012b2ee0a3fcb954f3c0340a8a2cf4a..3e2712d623fd4dfdca3187835f55d267f83b0718 100644 GIT binary patch delta 216 zcmbQseS~KN9}|NxEQ76jCeo)AYVrx&nQ)5?oPhXe3ZK&u_!S&M#)McMrra)mYm5ZtUEcuVn89K Q$&Xo0n3R+zOS5SL0IVYvF#rGn diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.create.2.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.create.2.json new file mode 100644 index 0000000000000000000000000000000000000000..00ad4925f9b3bc2ee6883061f08843c0bbb60ab2 GIT binary patch literal 1925 zcmah~$xa+G5WVLsTCE%r%yvLI1t~d1%7TK(fhe+S>}uM-G zpgFSOKzrc5XB{3i`~KtSw~Mb|Za#k^*y&r=IVJ}iqRZI9MJzIh5VB}F+80H14mIBs zavi0Ylj{BX;&f3xRI7D~ zPM$&PSFI4z+ZbjoXh15egmEK|$~*KPZs#foQ80mlTs`Ig^qk9-X%aPW=cSCTSgcBF zQtyo9vw%wOuq*ftC|}yyQG5XbCIGYji#&0GK)_K>8P;gF#THb}XgiTnin+e0)p zP@OG!)|J{vFA`4^RMLmBQWHQC0n4K2T_)S;a=vG~L7FK(2z{x@8Lcjs2-(F0oUn!2}-)-kXyQjd6$1l z4}(`8PhauXDE$8sqh$Dw9|sb94V~mnfe)K`ZTMh`KsvM}UiYL*vOPjNnXAQmkTx_? zbk-5~Q>icRcDbaKM0zXA>}yVkTaN7O&la80x5zcd81k$)t4+CP6!_JhA{mvHclSa- zB8GEA!=?q`1UN{8xkD`<^SP$mK>MEBpLTd;v(G#&4^=bY&^-QhL+B)z_rl~?W&TEF ftUB42Q|BY+i0S#vS2VwX6qwyQ6BB1L`t!SgTfj8* literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.list.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..fb45cc66ed54c63b6d59bd467965e5398eb7907d GIT binary patch literal 2205 zcma)7OK;ma5WeSE5JHbZsywhLdTN07u)scwW>cUk3Svx-EUqb1;KQjCOK2T{~3GE-z0_3MiWT>$4y<==Swkn*hC{ylD^ROy;cH( zvWDa)YCS)jRlGqwRIfmT5y*oB<3XJAAfOM?<%kVvkCSWR5JHw;Qli6WRz~O0h#dv2 zqL)>n@WFQIl0rn!|M~aJ-?xAL`S9r@dAOYIBzoagzt5<;@7E3_vS8Um)T(E90gMDE z&)J)I^+nBovUiKeL79^ck;?Zm95tc=rKw6TtW3H}kZUecXl1CxDf`i^L+@doEx+05 zSTbji;Mw@(qIlyVCSQR>T5Yi#a$ZiSqj-CO z6#?z)e-sH^6cij4Fr{J*UttTn;kF_D0z}ryJg}uO20?>N1?@3P;58T?t=n)PAjDK` zH8zB=c6_g$g3<%Rkk6>C)@aZvF*bc+r2K|!+@ek%jddO9BfS^arEKP%A@?4&M#qbB z{nsK}f!Q#rBr`TJhXilK!0cEPU1-tK5c90qqY5nxt(wL@h}Qi5VDUJopH20B_X4L;l)*N#7X-dPh` zQeBV4Kt7Xvhma`ys=h8Y4WR;E?+C=H9Sbh6>#MSabhZWzRdhI{3Z-(-gNbtwe2iSU z@HOUN)z>%k$)HS6k{-#a?7XVC5)#OqD}xuDNC}jhU^i&gQ@$g_A)&QtY-v%CcHHM( z8186uyoMQ0dEdvoX7dGH-m)onmFx%pOc$rTK>1Qr1ip4QCJ;{Pyiig<(%6VBJPk6l d;k5MCT&7lj=zbMlCYQI{%eTWl@YUJN*)P+zVUqv= literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.list.2.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Charge.list.2.json new file mode 100644 index 0000000000000000000000000000000000000000..71b48be1a05de24b79ed25e49b09f5a3a1c69767 GIT binary patch literal 4260 zcmeHKOOG2j48HHL5QH8V8^2I2dTM~?P@s>X=@w{$LZBs%t$Icp=;2-OBLBVAjAx{= z*GOb{FdpR50uK*H{P5#X-@p9s+qbX2p)m4q z&PUr4+7MkAOK=gh+#!TcJRk36WpoaWJWwP&dR179KG+6bQpo71{tK@Py>z-ALcMD@ z4ivIr+1ps_mc0pJ6gYLwzPO#;%-924EVCE79kh8iRZ9CdhO-n^plf=ikXBvPHRwHT z&eaa05()#E@bs>y_hTA#svwu!=`+W4%@#RiZT42nGvl@1W7Ee}LG5$;8}fI=R^iaD ze@B^cMNYv{tYawFh&67Zu7s^fKY@>Rs;kIU7=xrrr3W3+Nw_r_?ycJiA0Wi^SPpIL z>>ia^AH-2RI;9H)5`Tu*B-ef?YTRR;j2P=0s3-T5-b?FJ0J%ZvrpLNQCvq|PYdAD$ z0;6!-8Ux;xhQgsuCOE{fwN5`0Vvov%SES8>)R_%@Z@l>@%}UzztwH4&E`yQqw5e=6cChh z1Y;p}=$i92NL^_;hD!+^k0gq6-KjgHsnt`{1@zCIt+l~<%EKMemSbUy%p+W1JSyZC zX zBWd9D7~+!HUWf91M;;@-y~Jb_TSNSOwOD0}BPY+27Rl+^U6a04kU-{K>Ac`H3ZT>k zyF;TNJDw*(paxH6OO1Ng;=b#JeganKZP>vnkqGg4KX`06qNc-U^v%=xp8tH55;4u{ zp(4B+v*bi>fNDuH<>ctG~YsH6!A#1GMw`o-UNbWqO-^R>26EQ3{bmFJ-|!1^ delta 40 vcmZ1_xJqz?5fh7{k`CA8EH>H6!A#1GhMUWoUNbWqPEKa?26EQ3{bmFJ->VC5 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Customer.retrieve.2.json similarity index 92% rename from corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.1.json rename to corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Customer.retrieve.2.json index df30e12fe7d04b97a90d861a96fc91ce32b656d5..e8fc81787cbc4c00d916799a7658d2b669211775 100644 GIT binary patch delta 40 vcmZ1_xJqz?5fh7%k`CA8EH>H6!A#1GMw`o-UNbWqO-^R>26EQ3{bmFJ-|!1^ delta 40 vcmZ1_xJqz?5fh7{k`CA8EH>H6!A#1GhMUWoUNbWqPEKa?26EQ3{bmFJ->VC5 diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Customer.save.1.json similarity index 100% rename from corporate/tests/stripe_fixtures/payment_method_string:Customer.create.1.json rename to corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Customer.save.1.json diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..e00cc01e2243fe0e9c910d53d7c973fbe861167d GIT binary patch literal 2273 zcmd^BOK%e~5We?Utb78As!8YpajSp>5--862wAhX(-^$=;zwwx`rjFQypJR;Ae9?A zB{So1Uf;~*=A>yDbS~M*Wm4>Uvv@KS^`PaU%&QKx)%I#q^u5snqIQ6}xPif~;mKAh7aTC{p0>-hX_1@#gjAtCx$#Vo5dD4x8TYfCqow)>e4nT$COuY0+kzs4@8fPx!j2+{_F1^o57{A#MS zXoyg#TRuBS@LVDKU{#OHUR4K$x=cp*&8G-ff~WorhP8Omn%s$zKvi9H)W6ShFs^md zNK?dO`tP0@y;Yr)ce(FWgj$)K+gawW{i;N670YGh#omMTc3^=LBI?o;V$Q`sA7*dgY?|hk9R2^3EdjcDE%(Jn2I*T!+cFyGk#JKBoJ&;|6 zdL_Xc&=}5?TwOZ&;Pe3``2f34+1NaKfgEXQS{iMe^VRw4aT&Fmu6!70R&%UjI{wU= zy=VUoXO{njGKD%{2#ReW@Xq;zd1LdL3bFQxMHj)07iU!=XcMxt?eeTPFyNPam&6$@ zJB*@`7l-zE;@Q!xGpnv6?}?STnalA*j!z?+qhU66EnS&LK4e+Li=Q^85&!S6y!I)0HemzWMzwSpbBO~FzTfMwx7Ssf8 z&V`w-{j0C4xZf)Z0=4$a(aLjjRE%E@o*EF<=9}R)2qn~YFK8Mm7&xI7o%7&Cc`1`0 z=Xl*X%0h(RAieV7+ZoT3s&|?~wFJe2i#bS}1f`HNg(GYM=9*JL)f(e5ka9($bylK= zgVhS{Ne#+_OwweBuU%ry8^aY_1x;_OtmZ8`<|aHcZ|UjT`RC(LAFke>jK||i(y73O z^d|U{J~4d7=isHIR(liE?*diaG9#J*PH>x@2-_mkD|$|MJLjsx`e_L=W0QI`7Yd~K z#VZN{+Kr4B+By^owbX)f>WvKH7tS^IXf$eIbzmLl#SfUSM_DWUwT>R1oE^VA|9J8B z>~^|1y|}VB@BQseUb?%KedGiYY8{^zj}ECebW_crA@nG?iC7~UxJ8fmNK|AmVg~r@ zTl`hz*C4VMP?tT_+^y|t?(G(XBR0Sr{q*cs4gi@~YHH$My;?E$p z%DGDdrzKz|{>@B^C9ie0D}2ozWMu9i29@jbhOt6=U<2OFmQw9MJFxYv**TW6Bh@Hx{1%STMxOHCs$+vme$&A%o`cbly$jB#AyezJABjZBJbhRa?&nci(ejH z5~H;2FpEfD9NO)PCtI^l6u)y6cYI(aUWa&tk>l;C5asJKow$EB8uGK)YYWrS4{+l1QbraHJAey3Oo!>~i_)(w90a-sDPi;0EanC)x4_ptXHvTTx; literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.list.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..e39960ab7254f5b071e40ef45d6574c984929f3a GIT binary patch literal 83 zcmb>CQczGzNi0cJvQmhS)dBG`5{u(=^NUh}B58>^#i?My{G_bZ Zv?vE8pkHRFpP5&dpP8Imti)Bz1pvYK7f%2H literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.list.2.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Invoice.list.2.json new file mode 100644 index 0000000000000000000000000000000000000000..ac3902a83ff775c3f2f4663c84eeeb470e3c1292 GIT binary patch literal 2852 zcmeHJOK;RL5We?UL_UE8-ADI;daEc15VWX8LMlSmB=L4#96R-6=~DH-Gftd`vk66_ zNA|Fr&3MM2XTJHy_XkNra4fNFTFZ*k(#-D|JJuHF_p;it&c#_pA>V?`e7H3T(-~7PL=Ayt z8_?H`0xmCb{|toKkv2+LNHqhaB*q~HNC%=@QTwAM0-QOmnapZ7! zp=ak8pH4o$zj|{zpU)TFI!w8Ty}-Bir-hWWH8^3ZQBGG!i#E=g$uuv0BK;Pznu-6&(x%;i?`j8UhBhqSS_G{>`93A;mM z5i2{)R51&y{I9X}==A*L?Zt=7ujjYR&DrIZxq0VqSK^Dk>rPbhi*Om|u*p}H%?3i5 zub%+I%!g^~fka{kqc(jp>=)lNUDwqALhtyry;>pSCRngeu_u@ZHNJ9!m&v6CnS<`A z)+`Qnhg9tKL0A$J$sbQ-;}9y!u`7K(`QN(=zGa2Vr=S$fLQtFghe1|Udc!hLHR4lF ztIY>XN~XE;QqUD6Kt>|_Y`5%)9kFJ{f{u9{1g#D*1XE%|V8DJWg`G;RbR}eHj#MYu zZyiX>t(S)7Q4E6^m-gFtB)Z*|K6gm_s;R0Z^ngY%<0N#Pd-(*U`wY8+Nt---jhstr znMk-yR?F4$dD|S}$oao>a5;?GwDjW~+;jcU!5#+}Pfo#>j^De`WCMm4N{{qC>G!p< zy*d-CEQogKFMHv_ESYT=&EPQjFezZOTBHNAwnJX9a&QhFPp7}WSsyoR* z1c-D+T95ZI+FzpP^agu(+Q8uUz$9FmqH9d8Xmm=xNU-m#SCcHFS;p`}KwDsaRjFlf zYQ$qyJflBsGjddQahaGnKA3fW7S=~;A^btsyyR77Mz3}Z5({_3{}fM}A(Uah_nm*T ibfeS{gP?tV_5KZ|;=u!cR}3BH<`^r<2 literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.create.1.json new file mode 100644 index 0000000000000000000000000000000000000000..f7ab6b3d8e18a0c897f4018be647b13d8855afe1 GIT binary patch literal 473 zcmZ9JOH0H+5QOjhD~6mD6q1mGcoX#?_`;)?r8Cn?%RJ&d7M8I8-R>mr8grYPuCHq< z<3_0pTqr(Q*Xpvl>gu|_5K)&H(d#|SRY|5=#FT@>D55TD`~33ubo2P|et*ZdW}Y#S zk?$K;=5KNuOghq#DR?oz!rr0J>g*PxQRof%PeopJZP%VJ97^1DDt1 zB~H$V^;=m^8KMAA7d$@02Mf}`G1Cx3gr%0#8@~(qOj+7*rc9~ye+xX?i>JK({{uIN F%@1PIf~f!i literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.create.2.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.create.2.json new file mode 100644 index 0000000000000000000000000000000000000000..822ff5f694bc54bc40d533d6c557e731ef4373c4 GIT binary patch literal 455 zcmY*WyH3O~5bX06QEG}DP@L$Hpg=l6g6^8r#$KdFeqpaqLec#@UMComE~A;*nep89 z{Q%Zy$HQH}+ux4kcqN$1F``r71s!tI!wM$$7NdYFr_;;p+w=Y5>G=3iWV?lPy pKq|LPA^HgG4u2iLGB_rdxvy_(sFbJwlQn&I#HaRR`*N_mbU)mAen9{L literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.list.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:InvoiceItem.list.1.json new file mode 100644 index 0000000000000000000000000000000000000000..3190cda6f9cd6388b725ec1c7c25389b1a4776cb GIT binary patch literal 87 zcmb>CQczGzNi0cJvQmhS)dBG`5{u(=^NUh}B58>^#i?My{G_bZ dv?vE8pkHRFpP5&dpP8JRS(2Jtti)Bz1pp^Z82$hN literal 0 HcmV?d00001 diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Token.create.2.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Token.create.1.json similarity index 92% rename from corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Token.create.2.json rename to corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Token.create.1.json index 99bb38a3aa22d0a716c10f363ef25873a023c307..31a829cefe45b21843356996fe20a1926f2a4362 100644 GIT binary patch delta 22 ecmdnRwu^0p5)+Gov5DbiHKs$1hLc}11p)v|69%6E delta 24 gcmdnRwu^0p5)+Gwk%`e{HKs$81(?_-zhm+N09eik8~^|S diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Token.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Token.create.2.json similarity index 95% rename from corporate/tests/stripe_fixtures/payment_method_string:Token.create.1.json rename to corporate/tests/stripe_fixtures/upgrade_where_first_card_fails:Token.create.2.json index 60d47536e7c17c03b5152a712f27efc6362cb64e..762a023c64c947b83236cd102e9d6d54c2717f51 100644 GIT binary patch delta 13 UcmdnRwu^1U3noUR$uF4#0VUf79RL6T delta 15 XcmdnRwu^1U3#Q2eOl*_iG5G)hFii!O diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.create.1.json deleted file mode 100644 index 4fe9f2c7da55c3e9b9a201d997ee16a215b6560e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1583 zcmb_cOK%e~5We?Utb78gB1zK|Cn}W#5-(AeLlLqj-dSSmwHMnPC8_e?nep1mrX+#` zdvP+)Z@zgvT~w6-rCe{LTz~HvA!1GcXVdZxir#y)YC{^^8d{6D)qn|@#X))9MeQuf#kM!Pt6n^$ z=N4Blv$GBf_ZS7Xpbh&hp*0xaB-dBUwS$Z1Vub3$F|Le8+m+J_W#_TcYqm`2r&(?E z3R_n*KL@BSMyMeI37?2FVvR^MtsF89o_k@4o}(sEf~b54Q>m7y9@(LTa5mx<8I&TQfeQ@fyn3rU4nF7a6bRCv zhp_Nq(@1>P0mj~GureceM~c!S%6VpvGJdsE=M&c(3$0etK#13W7KiIjw$8>SspdQ+ zIVQ_D2s}ks)A?A_(4e62yiTh$4DMm=${cbS(e$<+#)>z*)^S{4-jKhpZU$8Fi|PFO zD9Ly)(wJulDw~@rZff8m^TVuX#3*o|sI|F1_Ceu4wxM z@#l|Ll`22PH#(HEMRXC2ET?3aL-an~k;!T{DQg+{*R#tpDH}=ohjkA9Vt>6(Eb4!N ln65LG^Un~E5bc3RSW9gimx<)MMUlxBdcGEk@WS-&;x|n#v;+VE diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.3.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.3.json deleted file mode 100644 index ec42d58bb9d6dccb91480edc2c70bac4502324d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4882 zcmc&&OK&4L48G@A7&^7UVq+&cWD6ASqUoVP+Z6lgVT(eLHL|T{UMp#gI6?k#Z`a(Y3=c1|ck$S22i9R~Mj(D$pr~ zc2S_|sZy9?7=G-b#72yh-CW)DhAZVnA)Elq&o3cTcor6fy)OhdhB47uv;Id(iW42i zQ`bDGT0aS=o0@~YU4t=UrJ>MdlNAbD>1(IxksDE3g|6V(N~2r6i2k0sQn!n5zR<5b zwf~kputHlZUoc8HRV8$ZcXpyGmHUydzLibouY|1RYsz%JZ?sgrHEN?D(F&zKl$@1% zud1fN6dXfM^W^&%09#hvd+M9TA6iE}MM1GX2}$;1|R z+~74Q_<@Ae96{m?PxR*}TtSla_`<7Ba0Us_=M7TNaR&)fTm=50^C=D?VSME-fn~2l-%z zngD)-?k054nA4PwLL4lI-A0$Z=!Q#$myh?R(epB+CcF%CHIWjJB0pF{ z%$be`eJX`$#dd4d7Pda#S+PAWQ&eZ7!hr>bZegAAc#Sgr9f86v?4EvQfzF2d_v_I> zd%kz0DzN?O!X1VFcStuTfZsLOk3a3#PxrU)?(K(@EMaYGr_5du%Mhn$9ujrN?3DV= z?j@-L_?j&*$ZIVhA~TD+?jWj6G{-ie8fP?G(-fWG1o0zf;pW}VN98`Sa&qxeZPFvTM4lMR|)0pbDwsA%S(Fc-4N|^Rb4anW0#V&*l10h@Z78bUw7+x~i z{1N-w31Auel65=I4@<#=W;5I{aNuW}w;U#0aFd^JL3k`vGUb9SJL`fhnRscW1atdC zdi4EV=Mu@QWIRhAXVS(zVf4~GRm@ak zjvihkhNN02l)(N28F-v$;5bhJ%)JVIP=#7QD+&X5{C}J9VDatuVSJyiH%(GqdNZE!J#3uthbUTp JgTlqj#lKZC{u%%P diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json deleted file mode 100644 index ec42d58bb9d6dccb91480edc2c70bac4502324d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4882 zcmc&&OK&4L48G@A7&^7UVq+&cWD6ASqUoVP+Z6lgVT(eLHL|T{UMp#gI6?k#Z`a(Y3=c1|ck$S22i9R~Mj(D$pr~ zc2S_|sZy9?7=G-b#72yh-CW)DhAZVnA)Elq&o3cTcor6fy)OhdhB47uv;Id(iW42i zQ`bDGT0aS=o0@~YU4t=UrJ>MdlNAbD>1(IxksDE3g|6V(N~2r6i2k0sQn!n5zR<5b zwf~kputHlZUoc8HRV8$ZcXpyGmHUydzLibouY|1RYsz%JZ?sgrHEN?D(F&zKl$@1% zud1fN6dXfM^W^&%09#hvd+M9TA6iE}MM1GX2}$;1|R z+~74Q_<@Ae96{m?PxR*}TtSla_`<7Ba0Us_=M7TNaR&)fTm=50^C=D?VSME-fn~2l-%z zngD)-?k054nA4PwLL4lI-A0$Z=!Q#$myh?R(epB+CcF%CHIWjJB0pF{ z%$be`eJX`$#dd4d7Pda#S+PAWQ&eZ7!hr>bZegAAc#Sgr9f86v?4EvQfzF2d_v_I> zd%kz0DzN?O!X1VFcStuTfZsLOk3a3#PxrU)?(K(@EMaYGr_5du%Mhn$9ujrN?3DV= z?j@-L_?j&*$ZIVhA~TD+?jWj6G{-ie8fP?G(-fWG1o0zf;pW}VN98`Sa&qxeZPFvTM4lMR|)0pbDwsA%S(Fc-4N|^Rb4anW0#V&*l10h@Z78bUw7+x~i z{1N-w31Auel65=I4@<#=W;5I{aNuW}w;U#0aFd^JL3k`vGUb9SJL`fhnRscW1atdC zdi4EV=Mu@QWIRhAXVS(zVf4~GRm@ak zjvihkhNN02l)(N28F-v$;5bhJ%)JVIP=#7QD+&X5{C}J9VDatuVSJyiH%(GqdNZE!J#3uthbUTp JgTlqj#lKZC{u%%P diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Subscription.create.2.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Subscription.create.2.json deleted file mode 100644 index 705c1038cfe8a01b69b5784458d9803877b01d71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2160 zcma)8O^*{X488BKsB%W^R_vjRgb<<}kbp`HClHF2Had8xy0*u&ImQKtc+E0uU?1aA^vP+JJU43y=kc6zPOjbtFxwEKVnd)f&vD&}0LFY9FS8)<>LEPNLs-Yvq=( z<=(+IAKW#hOygS+5Z8LH#NkG;OzTP-N6X+s&77rwJdi#yK)JZ~+o3GEhIE$Fo#Xe<<}BNR zqxS3#Fzh;5tz64Ch80pXHwZItBPZ_n^ZTA~+}qsrWB=1QH1rVkyVywj&QP$ zG}(YCC*{bXFU0oUFfPFf@r}qX?Wl3VF*Pi;i@gWI(CkP)z3EL!Dw}L3CiY)4VtoNM zroMC-PKbt_Q*xo*MLTp*C=G{h#lc42g=9t_q}Y-qsDU9IvcCrpFXJ*!B{lEyb^|11 Ln?0EC&UgO+|5aE> diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Token.create.1.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Token.create.1.json deleted file mode 100644 index 8320bbde9b295fde46354b5ced2881fa1a72a183..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 826 zcmaJe?5488L!s+`eOD5u?!w!@@-X^0(msZ3&4k2b9m7p)-v`{F>^Rw2?$YCk`} z7kfWS5~aZzb(`$NJ^X<&4!zGcaTlWM6*R3$tMMRRx0YQ*EG;pbWWm7bhq#g~PK0M^ z4Z4I2OgzH)A}rSh>WzR~8?R_C;ids$+p-)e^3wluYDbN*W^#s%A$O(k%7K-~QSaoT zFV%m%FV4ZOzQSWV3l-_S1kbboBJsP)tJ-oA!r94_#5x2QLen(sqieFC=o&|sMbh)j z+td8<;p6@;l{4v;tXpdWQXtx)A?_0toyfTJL2V@X%hBspX*N3(SIA(1Au#U`C#Aub zJi3}2kPUFIZ&t6qP)D6{zfN$jH)v@GOtsAeUr~!DnPyn8$fZD_bziEaEmpM=!D}cz#^PV0QQWZ=cTKc))~;F~JIA`wVe|u280A|4 diff --git a/corporate/tests/stripe_fixtures/upgrade_with_outdated_seat_count:Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/upgrade_with_outdated_seat_count:Customer.retrieve.1.json deleted file mode 100644 index 07be67862161b5a3598ef5ac0babd0d92a136543..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4882 zcmc&&OK%%D5Wf3Y2t2hw5L>nmPJyBg>K+QTbrB~$BnSwKOG&Kv6_P7hHvHc^GbC4B ziMvuy^}&`oA7_U1_-5>vi`k3`DVwf#d?`v%OT~VeUA@6`X_RoPfZyj=^DFwAdqHQ6 zs^tzOtg{89!$Pe@S2}K+&XAH9(1F^E!YFIG)Q&FvC8S#i*E1&K2jP3J|=xj2Enqk{LlLjf4u+wx6i-cLHy@EYAf@Gid`)# zEqN)dTMQqdo?2ctwcEh)O?WS!?UX=k*H<_3bfxRHGOf|ILpKI4Bu=clx&S0Xpi&C$ zqIa5}DupSA?)wtL@BKrfpKNixxQ_QLQd{DSG5elvbfBIJVO07B8Z`=dRT4?7PqO z<4$eACkw35meLoL(oIzfUE-aMs7mF2rmJsdQ~4_)D*2o;U2hvL6>p7N=_k}eVGogF zrQWKlDKG?kSJOE8`h|uqEAB1zG4_YnQB9FktPesGy;ykk7Q4}H>ysPXgZaj`;6mu^ zkfSeTdlpUyeU$I{A!NGm2iWP}KZa&|e=g$VOP;SuAr8$;^ydm@|pl{ zVS(-E)7}?wSx5&X6lBhHH0V<)%GYb7*0A;Q&WiPMnj)Nu3I`S_x*b$j}Bp3VIjZk3wtBg1MGN2r15Upv7&ToSFk)p78 zw|G$Q<6Zld)9JJ_*a5?ai|#WL1C`U(HHLx3-mV%`ohln=G!T6tIi!SfyHtSO?OLot zh%gYcg}=hWmKEJg2Ae-(-#P&-Lw{u59>#~I&4X$)+%PcUXPVa>CR;F*pKn39Pg64G zf-F1hf-ISMsi)ND_NVmd$0^T~piGqJSV<-`I<=UT4$BbCIJKNXD&uq#C+;aCIY}Xt zg%~QohCZ_1&6fq>(I-&&jAIQM>d8Qqw34pnitq+jXO%MM-fq660?rBZu zTbxa9D)=`{hjbGQb0L_s zs*4ZSV!6X~=a&v+oCizEM^O%&l2kMk!SOGTPoLj?`uP3B0y~&?O;)gTJQ%MuuY$}A ztmF!U_S`iA=Qr>uCmKAH+pz0Vi;B8qE-Z^TD=%w@0eb`lA*sLvKK+PKlVpva!ldqL zQr(B8TU6ZBvB~Px={qn?20FO8;%qJ`eMFB`vY9UkS(3qJyW( z$6gD@tr}Zj2G?5!x>qmBt8`1G2KT!*VvOrb8i&i^$m*P>e*&OBJwZ7?^*f*}x=K1r z=??MxXLpuuz+rp#3>bDAtX8gP8}p6FX3h|1!A4HpZl|{+;kdWC=}kfXSWRq?l_ bool: return True class StripeTest(ZulipTestCase): - @mock_stripe(generate=False) def setUp(self, *mocks: Mock) -> None: - call_command("setup_stripe") + # TODO # Unfortunately this test suite is likely not robust to users being # added in populate_db. A quick hack for now to ensure get_seat_count is 8 # for these tests (8, since that's what it was when the tests were written). @@ -229,6 +229,11 @@ class StripeTest(ZulipTestCase): self.assertEqual(get_seat_count(get_realm('zulip')), 8) self.seat_count = 8 self.signed_seat_count, self.salt = sign_string(str(self.seat_count)) + # Choosing dates with corresponding timestamps below 1500000000 so that they are + # not caught by our timestamp normalization regex in normalize_fixture_data + self.now = datetime(2012, 1, 2, 3, 4, 5).replace(tzinfo=timezone_utc) + self.next_month = datetime(2012, 2, 2, 3, 4, 5).replace(tzinfo=timezone_utc) + self.next_year = datetime(2013, 1, 2, 3, 4, 5).replace(tzinfo=timezone_utc) def get_signed_seat_count_from_response(self, response: HttpResponse) -> Optional[str]: match = re.search(r'name=\"signed_seat_count\" value=\"(.+)\"', response.content.decode("utf-8")) @@ -242,7 +247,7 @@ class StripeTest(ZulipTestCase): realm: Optional[Realm]=None, del_args: List[str]=[], **kwargs: Any) -> HttpResponse: host_args = {} - if realm is not None: + if realm is not None: # nocoverage: TODO host_args['HTTP_HOST'] = realm.host response = self.client_get("/upgrade/", **host_args) params = { @@ -304,19 +309,19 @@ class StripeTest(ZulipTestCase): self.assert_in_success_response(["Page not found (404)"], response) @mock_stripe(tested_timestamp_fields=["created"]) - def test_initial_upgrade(self, *mocks: Mock) -> None: + def test_upgrade_by_card(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login(user.email) response = self.client_get("/upgrade/") self.assert_in_success_response(['Pay annually'], response) - self.assertFalse(user.realm.has_seat_based_plan) self.assertNotEqual(user.realm.plan_type, Realm.STANDARD) self.assertFalse(Customer.objects.filter(realm=user.realm).exists()) # Click "Make payment" in Stripe Checkout - self.upgrade() + with patch('corporate.lib.stripe.timezone_now', return_value=self.now): + self.upgrade() - # Check that we correctly created Customer and Subscription objects in Stripe + # Check that we correctly created a Customer object in Stripe stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id) self.assertEqual(stripe_customer.default_source.id[:5], 'card_') self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)") @@ -324,32 +329,69 @@ class StripeTest(ZulipTestCase): self.assertEqual(stripe_customer.email, user.email) self.assertEqual(dict(stripe_customer.metadata), {'realm_id': str(user.realm.id), 'realm_str': 'zulip'}) + # Check Charges in Stripe + stripe_charges = [charge for charge in stripe.Charge.list(customer=stripe_customer.id)] + self.assertEqual(len(stripe_charges), 1) + self.assertEqual(stripe_charges[0].amount, 8000 * self.seat_count) + # TODO: fix Decimal + self.assertEqual(stripe_charges[0].description, + "Upgrade to Zulip Standard, $80.0 x {}".format(self.seat_count)) + self.assertEqual(stripe_charges[0].receipt_email, user.email) + self.assertEqual(stripe_charges[0].statement_descriptor, "Zulip Standard") + # Check Invoices in Stripe + stripe_invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)] + self.assertEqual(len(stripe_invoices), 1) + self.assertIsNotNone(stripe_invoices[0].finalized_at) + invoice_params = { + # auto_advance is False because the invoice has been paid + 'amount_due': 0, 'amount_paid': 0, 'auto_advance': False, 'billing': 'charge_automatically', + 'charge': None, 'status': 'paid', 'total': 0} + for key, value in invoice_params.items(): + self.assertEqual(stripe_invoices[0].get(key), value) + # Check Line Items on Stripe Invoice + stripe_line_items = [item for item in stripe_invoices[0].lines] + self.assertEqual(len(stripe_line_items), 2) + line_item_params = { + 'amount': 8000 * self.seat_count, 'description': 'Zulip Standard', 'discountable': False, + 'period': { + 'end': datetime_to_timestamp(self.next_year), + 'start': datetime_to_timestamp(self.now)}, + # There's no unit_amount on Line Items, probably because it doesn't show up on the + # user-facing invoice. We could pull the Invoice Item instead and test unit_amount there, + # but testing the amount and quantity seems sufficient. + 'plan': None, 'proration': False, 'quantity': self.seat_count} + for key, value in line_item_params.items(): + self.assertEqual(stripe_line_items[0].get(key), value) + line_item_params = { + 'amount': -8000 * self.seat_count, 'description': 'Payment (Card ending in 4242)', + 'discountable': False, 'plan': None, 'proration': False, 'quantity': 1} + for key, value in line_item_params.items(): + self.assertEqual(stripe_line_items[1].get(key), value) - stripe_subscription = extract_current_subscription(stripe_customer) - self.assertEqual(stripe_subscription.billing, 'charge_automatically') - self.assertEqual(stripe_subscription.days_until_due, None) - self.assertEqual(stripe_subscription.plan.id, - Plan.objects.get(nickname=Plan.CLOUD_ANNUAL).stripe_plan_id) - self.assertEqual(stripe_subscription.quantity, self.seat_count) - self.assertEqual(stripe_subscription.status, 'active') - self.assertEqual(stripe_subscription.tax_percent, 0) - - # Check that we correctly populated Customer and RealmAuditLog in Zulip - self.assertEqual(1, Customer.objects.filter(stripe_customer_id=stripe_customer.id, - realm=user.realm).count()) + # Check that we correctly populated Customer and CustomerPlan in Zulip + customer = Customer.objects.filter(stripe_customer_id=stripe_customer.id, + realm=user.realm).first() + self.assertTrue(CustomerPlan.objects.filter( + customer=customer, licenses=self.seat_count, automanage_licenses=True, + price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, + billing_schedule=CustomerPlan.ANNUAL, billed_through=self.now, + next_billing_date=self.next_month, tier=CustomerPlan.STANDARD, + status=CustomerPlan.ACTIVE).exists()) + # Check RealmAuditLog audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user) .values_list('event_type', 'event_time').order_by('id')) self.assertEqual(audit_log_entries, [ (RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created)), (RealmAuditLog.STRIPE_CARD_CHANGED, timestamp_to_datetime(stripe_customer.created)), - # TODO: Add a test where stripe_customer.created != stripe_subscription.created - (RealmAuditLog.STRIPE_PLAN_CHANGED, timestamp_to_datetime(stripe_subscription.created)), + (RealmAuditLog.CUSTOMER_PLAN_CREATED, self.now), # TODO: Check for REALM_PLAN_TYPE_CHANGED # (RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra()), ]) + self.assertEqual(ujson.loads(RealmAuditLog.objects.filter( + event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED).values_list( + 'extra_data', flat=True).first())['licenses'], self.seat_count) # Check that we correctly updated Realm realm = get_realm("zulip") - self.assertTrue(realm.has_seat_based_plan) self.assertEqual(realm.plan_type, Realm.STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) # Check that we can no longer access /upgrade @@ -357,12 +399,90 @@ class StripeTest(ZulipTestCase): self.assertEqual(response.status_code, 302) self.assertEqual('/billing/', response.url) - # Check /billing has the correct information - response = self.client_get("/billing/") - self.assert_not_in_success_response(['Pay annually'], response) - for substring in ['Your plan will renew on', '$%s.00' % (80 * self.seat_count,), - 'Card ending in 4242', 'Update card']: - self.assert_in_response(substring, response) + # TODO: Check /billing has the correct information + # response = self.client_get("/billing/") + # self.assert_not_in_success_response(['Pay annually'], response) + # for substring in ['Your plan will renew on', '$%s.00' % (80 * self.seat_count,), + # 'Card ending in 4242', 'Update card']: + # self.assert_in_response(substring, response) + + @mock_stripe(tested_timestamp_fields=["created"]) + def test_upgrade_by_invoice(self, *mocks: Mock) -> None: + user = self.example_user("hamlet") + self.login(user.email) + # Click "Make payment" in Stripe Checkout + with patch('corporate.lib.stripe.timezone_now', return_value=self.now): + self.upgrade(invoice=True) + # Check that we correctly created a Customer in Stripe + stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id) + # 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 + # self.assertEqual(stripe_customer.default_source.type, 'ach_credit_transfer') + + # Check Charges in Stripe + self.assertFalse(stripe.Charge.list(customer=stripe_customer.id)) + # Check Invoices in Stripe + stripe_invoices = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer.id)] + self.assertEqual(len(stripe_invoices), 1) + self.assertIsNotNone(stripe_invoices[0].due_date) + self.assertIsNotNone(stripe_invoices[0].finalized_at) + invoice_params = { + 'amount_due': 8000 * 123, 'amount_paid': 0, 'attempt_count': 0, + 'auto_advance': True, 'billing': 'send_invoice', 'statement_descriptor': 'Zulip Standard', + 'status': 'open', 'total': 8000 * 123} + for key, value in invoice_params.items(): + self.assertEqual(stripe_invoices[0].get(key), value) + # Check Line Items on Stripe Invoice + stripe_line_items = [item for item in stripe_invoices[0].lines] + self.assertEqual(len(stripe_line_items), 1) + line_item_params = { + 'amount': 8000 * 123, 'description': 'Zulip Standard', 'discountable': False, + 'period': { + 'end': datetime_to_timestamp(self.next_year), + 'start': datetime_to_timestamp(self.now)}, + 'plan': None, 'proration': False, 'quantity': 123} + for key, value in line_item_params.items(): + self.assertEqual(stripe_line_items[0].get(key), value) + + # Check that we correctly populated Customer and CustomerPlan in Zulip + customer = Customer.objects.filter(stripe_customer_id=stripe_customer.id, + realm=user.realm).first() + self.assertTrue(CustomerPlan.objects.filter( + customer=customer, licenses=123, automanage_licenses=False, charge_automatically=False, + price_per_license=8000, fixed_price=None, discount=None, billing_cycle_anchor=self.now, + billing_schedule=CustomerPlan.ANNUAL, billed_through=self.now, + next_billing_date=self.next_year, tier=CustomerPlan.STANDARD, + status=CustomerPlan.ACTIVE).exists()) + # Check RealmAuditLog + audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user) + .values_list('event_type', 'event_time').order_by('id')) + self.assertEqual(audit_log_entries, [ + (RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created)), + (RealmAuditLog.CUSTOMER_PLAN_CREATED, self.now), + # TODO: Check for REALM_PLAN_TYPE_CHANGED + # (RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra()), + ]) + self.assertEqual(ujson.loads(RealmAuditLog.objects.filter( + event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED).values_list( + 'extra_data', flat=True).first())['licenses'], 123) + # Check that we correctly updated Realm + realm = get_realm("zulip") + self.assertEqual(realm.plan_type, Realm.STANDARD) + self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) + # Check that we can no longer access /upgrade + response = self.client_get("/upgrade/") + self.assertEqual(response.status_code, 302) + self.assertEqual('/billing/', response.url) + + # TODO: Check /billing has the correct information + # response = self.client_get("/billing/") + # self.assert_not_in_success_response(['Pay annually'], response) + # for substring in ['Your plan will renew on', '$%s.00' % (80 * self.seat_count,), + # 'Card ending in 4242', 'Update card']: + # self.assert_in_response(substring, response) @mock_stripe() def test_billing_page_permissions(self, *mocks: Mock) -> None: @@ -386,49 +506,46 @@ class StripeTest(ZulipTestCase): self.assert_in_success_response(["You must be an organization administrator"], response) @mock_stripe(tested_timestamp_fields=["created"]) - def test_upgrade_with_outdated_seat_count(self, *mocks: Mock) -> None: + def test_upgrade_by_card_with_outdated_seat_count(self, *mocks: Mock) -> None: self.login(self.example_email("hamlet")) - new_seat_count = 123 + new_seat_count = 23 # Change the seat count while the user is going through the upgrade flow with patch('corporate.lib.stripe.get_seat_count', return_value=new_seat_count): self.upgrade() - # Check that the subscription call used the old quantity, not new_seat_count - stripe_customer = stripe_get_customer( - Customer.objects.get(realm=get_realm('zulip')).stripe_customer_id) - stripe_subscription = extract_current_subscription(stripe_customer) - self.assertEqual(stripe_subscription.quantity, self.seat_count) - - # Check that we have the STRIPE_PLAN_QUANTITY_RESET entry, and that we - # correctly handled the requires_billing_update field - audit_log_entries = list(RealmAuditLog.objects.order_by('-id') - .values_list('event_type', 'event_time', - 'requires_billing_update')[:5])[::-1] - self.assertEqual(audit_log_entries, [ - (RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), False), - (RealmAuditLog.STRIPE_CARD_CHANGED, timestamp_to_datetime(stripe_customer.created), False), - # TODO: Ideally this test would force stripe_customer.created != stripe_subscription.created - (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), - ]) + stripe_customer_id = Customer.objects.first().stripe_customer_id + # Check that the Charge used the old quantity, not new_seat_count + self.assertEqual(8000 * self.seat_count, + [charge for charge in stripe.Charge.list(customer=stripe_customer_id)][0].amount) + # Check that the invoice has a credit for the old amount and a charge for the new one + stripe_invoice = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer_id)][0] + self.assertEqual([8000 * new_seat_count, -8000 * self.seat_count], + [item.amount for item in stripe_invoice.lines]) + # Check CustomerPlan and RealmAuditLog have the new amount + self.assertEqual(CustomerPlan.objects.first().licenses, new_seat_count) self.assertEqual(ujson.loads(RealmAuditLog.objects.filter( - event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET).values_list('extra_data', flat=True).first()), - {'quantity': new_seat_count}) + event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED).values_list( + 'extra_data', flat=True).first())['licenses'], new_seat_count) @mock_stripe() - def test_upgrade_where_subscription_save_fails_at_first(self, *mocks: Mock) -> None: + def test_upgrade_where_first_card_fails(self, *mocks: Mock) -> None: user = self.example_user("hamlet") self.login(user.email) # From https://stripe.com/docs/testing#cards: Attaching this card to # a Customer object succeeds, but attempts to charge the customer fail. - self.upgrade(stripe_token=stripe_create_token('4000000000000341').id) - # Check that we created a Customer object with has_billing_relationship False - customer = Customer.objects.get(realm=get_realm('zulip')) - self.assertFalse(customer.has_billing_relationship) - original_stripe_customer_id = customer.stripe_customer_id - # Check that we created a customer in stripe, with no subscription - stripe_customer = stripe_get_customer(customer.stripe_customer_id) - self.assertFalse(extract_current_subscription(stripe_customer)) + with patch("corporate.lib.stripe.billing_logger.error") as mock_billing_logger: + self.upgrade(stripe_token=stripe_create_token('4000000000000341').id) + mock_billing_logger.assert_called() + # Check that we created a Customer object but no CustomerPlan + stripe_customer_id = Customer.objects.get(realm=get_realm('zulip')).stripe_customer_id + self.assertFalse(CustomerPlan.objects.exists()) + # Check that we created a Customer in stripe, a failed Charge, and no Invoices or Invoice Items + self.assertTrue(stripe_get_customer(stripe_customer_id)) + stripe_charges = [charge for charge in stripe.Charge.list(customer=stripe_customer_id)] + self.assertEqual(len(stripe_charges), 1) + self.assertEqual(stripe_charges[0].failure_code, 'card_declined') + # TODO: figure out what these actually are + self.assertFalse(stripe.Invoice.list(customer=stripe_customer_id)) + self.assertFalse(stripe.InvoiceItem.list(customer=stripe_customer_id)) # Check that we correctly populated RealmAuditLog audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user) .values_list('event_type', flat=True).order_by('id')) @@ -436,22 +553,28 @@ class StripeTest(ZulipTestCase): RealmAuditLog.STRIPE_CARD_CHANGED]) # Check that we did not update Realm realm = get_realm("zulip") - self.assertFalse(realm.has_seat_based_plan) + self.assertNotEqual(realm.plan_type, Realm.STANDARD) # Check that we still get redirected to /upgrade response = self.client_get("/billing/") self.assertEqual(response.status_code, 302) self.assertEqual('/upgrade/', response.url) - # Try again, with a valid card - self.upgrade() + # Try again, with a valid card, after they added a few users + with patch('corporate.lib.stripe.get_seat_count', return_value=23): + with patch('corporate.views.get_seat_count', return_value=23): + self.upgrade() customer = Customer.objects.get(realm=get_realm('zulip')) - # Impossible to create two Customers, but check that we didn't - # change stripe_customer_id and that we updated has_billing_relationship - self.assertEqual(customer.stripe_customer_id, original_stripe_customer_id) - self.assertTrue(customer.has_billing_relationship) - # Check that we successfully added a subscription - stripe_customer = stripe_get_customer(customer.stripe_customer_id) - self.assertTrue(extract_current_subscription(stripe_customer)) + # It's impossible to create two Customers, but check that we didn't + # change stripe_customer_id + self.assertEqual(customer.stripe_customer_id, stripe_customer_id) + # Check that we successfully added a CustomerPlan + self.assertTrue(CustomerPlan.objects.filter(customer=customer, licenses=23).exists()) + # Check the Charges and Invoices in Stripe + self.assertEqual(8000 * 23, [charge for charge in + stripe.Charge.list(customer=stripe_customer_id)][0].amount) + stripe_invoice = [invoice for invoice in stripe.Invoice.list(customer=stripe_customer_id)][0] + self.assertEqual([8000 * 23, -8000 * 23], + [item.amount for item in stripe_invoice.lines]) # Check that we correctly populated RealmAuditLog audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user) .values_list('event_type', flat=True).order_by('id')) @@ -459,10 +582,10 @@ class StripeTest(ZulipTestCase): self.assertEqual(audit_log_entries, [RealmAuditLog.STRIPE_CUSTOMER_CREATED, RealmAuditLog.STRIPE_CARD_CHANGED, RealmAuditLog.STRIPE_CARD_CHANGED, - RealmAuditLog.STRIPE_PLAN_CHANGED]) + RealmAuditLog.CUSTOMER_PLAN_CREATED]) # 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 can no longer access /upgrade response = self.client_get("/upgrade/") self.assertEqual(response.status_code, 302) @@ -543,69 +666,6 @@ class StripeTest(ZulipTestCase): self.assert_json_error_contains(response, "Something went wrong. Please contact zulip-admin@example.com.") self.assertEqual(ujson.loads(response.content)['error_description'], 'uncaught exception during upgrade') - @mock_stripe(tested_timestamp_fields=["created"]) - def test_upgrade_billing_by_invoice(self, *mocks: Mock) -> None: - user = self.example_user("hamlet") - self.login(user.email) - self.upgrade(invoice=True) - - # 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) - # In the middle of migrating off of this billing algorithm - # 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.seat_count}) - - # Check /billing has the correct information - response = self.client_get("/billing/") - self.assert_not_in_success_response(['Pay annually', 'Update card'], response) - for substring in ['Your plan will renew on', 'Billed by invoice']: - self.assert_in_response(substring, response) - def test_redirect_for_billing_home(self) -> None: user = self.example_user("iago") self.login(user.email) @@ -650,17 +710,18 @@ class StripeTest(ZulipTestCase): # histories don't throw errors @mock_stripe() def test_payment_method_string(self, *mocks: Mock) -> None: + pass # If you signup with a card, we should show your card as the payment method # Already tested in test_initial_upgrade # If you pay by invoice, your payment method should be # "Billed by invoice", even if you have a card on file - user = self.example_user("hamlet") - do_create_customer(user, stripe_create_token().id) - self.login(user.email) - self.upgrade(invoice=True) - stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id) - self.assertEqual('Billed by invoice', payment_method_string(stripe_customer)) + # user = self.example_user("hamlet") + # do_create_customer(user, stripe_create_token().id) + # self.login(user.email) + # self.upgrade(invoice=True) + # stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id) + # self.assertEqual('Billed by invoice', payment_method_string(stripe_customer)) # If you signup with a card and then downgrade, we still have your # card on file, and should show it @@ -806,3 +867,82 @@ class RequiresBillingAccessTest(ZulipTestCase): json_endpoints.remove("json/billing/upgrade") self.assertEqual(len(json_endpoints), len(params)) + +class BillingHelpersTest(ZulipTestCase): + def test_next_month(self) -> None: + anchor = datetime(2019, 12, 31, 1, 2, 3).replace(tzinfo=timezone_utc) + period_boundaries = [ + anchor, + datetime(2020, 1, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + # Test that this is the 28th even during leap years + datetime(2020, 2, 28, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 3, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 4, 30, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 5, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 6, 30, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 7, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 8, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 9, 30, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 10, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 11, 30, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2020, 12, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2021, 1, 31, 1, 2, 3).replace(tzinfo=timezone_utc), + datetime(2021, 2, 28, 1, 2, 3).replace(tzinfo=timezone_utc)] + with self.assertRaises(AssertionError): + add_months(anchor, -1) + # Explictly test add_months for each value of MAX_DAY_FOR_MONTH and + # for crossing a year boundary + for i, boundary in enumerate(period_boundaries): + self.assertEqual(add_months(anchor, i), boundary) + # Test next_month for small values + for last, next_ in zip(period_boundaries[:-1], period_boundaries[1:]): + self.assertEqual(next_month(anchor, last), next_) + # Test next_month for large values + period_boundaries = [dt.replace(year=dt.year+100) for dt in period_boundaries] + for last, next_ in zip(period_boundaries[:-1], period_boundaries[1:]): + self.assertEqual(next_month(anchor, last), next_) + + def test_compute_plan_parameters(self) -> None: + # TODO: test rounding down microseconds + anchor = datetime(2019, 12, 31, 1, 2, 3).replace(tzinfo=timezone_utc) + month_later = datetime(2020, 1, 31, 1, 2, 3).replace(tzinfo=timezone_utc) + year_later = datetime(2020, 12, 31, 1, 2, 3).replace(tzinfo=timezone_utc) + test_cases = [ + # TODO test with Decimal(85), not 85 + # TODO fix the mypy error by specifying the exact type + # test all possibilities, since there aren't that many + [(True, CustomerPlan.ANNUAL, None), (anchor, month_later, year_later, 8000)], # lint:ignore + [(True, CustomerPlan.ANNUAL, 85), (anchor, month_later, year_later, 1200)], # lint:ignore + [(True, CustomerPlan.MONTHLY, None), (anchor, month_later, month_later, 800)], # lint:ignore + [(True, CustomerPlan.MONTHLY, 85), (anchor, month_later, month_later, 120)], # lint:ignore + [(False, CustomerPlan.ANNUAL, None), (anchor, year_later, year_later, 8000)], # lint:ignore + [(False, CustomerPlan.ANNUAL, 85), (anchor, year_later, year_later, 1200)], # lint:ignore + [(False, CustomerPlan.MONTHLY, None), (anchor, month_later, month_later, 800)], # lint:ignore + [(False, CustomerPlan.MONTHLY, 85), (anchor, month_later, month_later, 120)], # lint:ignore + # test exact math of Decimals; 800 * (1 - 87.25) = 101.9999999.. + [(False, CustomerPlan.MONTHLY, 87.25), (anchor, month_later, month_later, 102)], + # test dropping of fractional cents; without the int it's 102.8 + [(False, CustomerPlan.MONTHLY, 87.15), (anchor, month_later, month_later, 102)]] + with patch('corporate.lib.stripe.timezone_now', return_value=anchor): + for input_, output in test_cases: + output_ = compute_plan_parameters(*input_) # type: ignore # TODO + self.assertEqual(output_, output) + + def test_update_or_create_stripe_customer_logic(self) -> None: + user = self.example_user('hamlet') + # No existing Customer object + with patch('corporate.lib.stripe.do_create_customer', return_value='returned') as mocked1: + returned = update_or_create_stripe_customer(user, stripe_token='token') + mocked1.assert_called() + self.assertEqual(returned, 'returned') + # Customer exists, replace payment source + Customer.objects.create(realm=get_realm('zulip'), stripe_customer_id='cus_12345') + with patch('corporate.lib.stripe.do_replace_payment_source') as mocked2: + customer = update_or_create_stripe_customer(self.example_user('hamlet'), 'token') + mocked2.assert_called() + self.assertTrue(isinstance(customer, Customer)) + # Customer exists, do nothing + with patch('corporate.lib.stripe.do_replace_payment_source') as mocked3: + customer = update_or_create_stripe_customer(self.example_user('hamlet'), None) + mocked3.assert_not_called() + self.assertTrue(isinstance(customer, Customer)) diff --git a/corporate/views.py b/corporate/views.py index f30c268b04..3fa319b25a 100644 --- a/corporate/views.py +++ b/corporate/views.py @@ -14,15 +14,16 @@ from zerver.decorator import zulip_login_required, require_billing_access from zerver.lib.json_encoder_for_html import JSONEncoderForHTML from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_error, json_success -from zerver.lib.validator import check_string, check_int +from zerver.lib.validator import check_string, check_int, check_bool from zerver.lib.timestamp import timestamp_to_datetime 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, \ + stripe_get_customer, get_seat_count, \ + process_initial_upgrade, sign_string, \ unsign_string, BillingError, process_downgrade, do_replace_payment_source, \ - MIN_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE -from corporate.models import Customer, CustomerPlan, Plan + MIN_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE, \ + next_renewal_date, renewal_amount +from corporate.models import Customer, CustomerPlan, Plan, get_active_plan billing_logger = logging.getLogger('corporate.stripe') @@ -53,8 +54,9 @@ def check_upgrade_parameters( raise BillingError('not enough licenses', _("You must invoice for at least {} users.".format(min_licenses))) -def payment_method_string(stripe_customer: stripe.Customer) -> str: - subscription = extract_current_subscription(stripe_customer) +# TODO +def payment_method_string(stripe_customer: stripe.Customer) -> str: # nocoverage: TODO + subscription = None # extract_current_subscription(stripe_customer) if subscription is not None and subscription.billing == "send_invoice": return _("Billed by invoice") stripe_source = stripe_customer.default_source @@ -91,10 +93,11 @@ def upgrade(request: HttpRequest, user: UserProfile, check_upgrade_parameters( billing_modality, schedule, license_management, licenses, stripe_token is not None, seat_count) + automanage_licenses = license_management in ['automatic', 'mix'] billing_schedule = {'annual': CustomerPlan.ANNUAL, 'monthly': CustomerPlan.MONTHLY}[schedule] - process_initial_upgrade(user, licenses, billing_schedule, stripe_token) + process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token) except BillingError as e: # TODO add a billing_logger.warning with all the upgrade parameters return json_error(e.message, data={'error_description': e.description}) @@ -113,7 +116,7 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse: user = request.user customer = Customer.objects.filter(realm=user.realm).first() - if customer is not None and customer.has_billing_relationship: + if customer is not None and CustomerPlan.objects.filter(customer=customer).exists(): return HttpResponseRedirect(reverse('corporate.views.billing_home')) percent_off = 0 @@ -152,7 +155,7 @@ def billing_home(request: HttpRequest) -> HttpResponse: customer = Customer.objects.filter(realm=user.realm).first() if customer is None: return HttpResponseRedirect(reverse('corporate.views.initial_upgrade')) - if not customer.has_billing_relationship: + if not CustomerPlan.objects.filter(customer=customer).exists(): return HttpResponseRedirect(reverse('corporate.views.initial_upgrade')) if not user.is_realm_admin and not user.is_billing_admin: @@ -160,40 +163,44 @@ def billing_home(request: HttpRequest) -> HttpResponse: return render(request, 'corporate/billing.html', context=context) context = {'admin_access': True} - stripe_customer = stripe_get_customer(customer.stripe_customer_id) - if stripe_customer.account_balance > 0: # nocoverage, waiting for mock_stripe to mature - context.update({'account_charges': '{:,.2f}'.format(stripe_customer.account_balance / 100.)}) - if stripe_customer.account_balance < 0: # nocoverage - context.update({'account_credits': '{:,.2f}'.format(-stripe_customer.account_balance / 100.)}) - - billed_by_invoice = False - subscription = extract_current_subscription(stripe_customer) - if subscription: - plan_name = PLAN_NAMES[Plan.objects.get(stripe_plan_id=subscription.plan.id).nickname] - licenses = subscription.quantity + charge_automatically = False + plan = get_active_plan(customer) + if plan is not None: + plan_name = { + CustomerPlan.STANDARD: 'Zulip Standard', + CustomerPlan.PLUS: 'Zulip Plus', + }[plan.tier] + licenses = plan.licenses # Need user's timezone to do this properly - renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format( - dt=timestamp_to_datetime(subscription.current_period_end)) - renewal_amount = upcoming_invoice_total(customer.stripe_customer_id) - if subscription.billing == 'send_invoice': - billed_by_invoice = True + renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=next_renewal_date(plan)) + renewal_cents = renewal_amount(plan) + charge_automatically = plan.charge_automatically + if charge_automatically: # nocoverage: TODO + # TODO get last4 + payment_method = 'Card on file' + else: # nocoverage: TODO + payment_method = 'Billed by invoice' + billed_by_invoice = not plan.charge_automatically # Can only get here by subscribing and then downgrading. We don't support downgrading # yet, but keeping this code here since we will soon. else: # nocoverage plan_name = "Zulip Free" licenses = 0 renewal_date = '' - renewal_amount = 0 + renewal_cents = 0 + payment_method = '' context.update({ 'plan_name': plan_name, 'licenses': licenses, 'renewal_date': renewal_date, - 'renewal_amount': '{:,.2f}'.format(renewal_amount / 100.), - 'payment_method': payment_method_string(stripe_customer), + 'renewal_amount': '{:,.2f}'.format(renewal_cents / 100.), + 'payment_method': payment_method, + # TODO: Rename to charge_automatically 'billed_by_invoice': billed_by_invoice, 'publishable_key': STRIPE_PUBLISHABLE_KEY, - 'stripe_email': stripe_customer.email, + # TODO: get actual stripe email? + 'stripe_email': user.email, }) return render(request, 'corporate/billing.html', context=context) diff --git a/docs/subsystems/billing.md b/docs/subsystems/billing.md index 50bfdfd5e2..a2e61ced0a 100644 --- a/docs/subsystems/billing.md +++ b/docs/subsystems/billing.md @@ -8,86 +8,9 @@ To set up the development environment to work on the billing code: * Go to , and add the publishable key and secret key as `stripe_publishable_key` and `stripe_secret_key` to `zproject/dev-secrets.conf`. -* Run `./manage.py setup_stripe`. - -It is safe to run `manage.py setup_stripe` multiple times. Nearly all the billing-relevant code lives in `corporate/`. -## General architecture - -Notes: -* Anything that talks directly to Stripe should go in - `corporate/lib/stripe.py`. -* We generally try to store billing-related data in Stripe, rather than in - Zulip database tables. We'd rather pay the penalty of making extra stripe - API requests than deal with keeping two sources of data in sync. -* A realm should have a customer object in Stripe if and only if it has a - `Customer` object in Zulip. - -The two main billing-related states for a realm are "have never successfully -been charged for anything" and its opposite. This is determined by whether -the `realm` has a corresponding `Customer` object with -`has_billing_relationship=True`. There are only a few cases where a `realm` -might have a `Customer` object with `has_billing_relationship=False`: -* They are approved as a non-profit or otherwise have a partial discount, - but haven't entered any payment info. -* They entered valid payment info, but the initial charge failed (rare but - possible). - -If a realm doesn't have a billing relationship, all the messaging, screens, -etc. are geared towards making it easy to upgrade. If a realm does have a -billing relationship, all the screens are geared toward making it easy to -access current and historical billing information. - -Note that having a billing relationship doesn't necessarily mean they are -currently on a paid plan, or that they currently have a card on file. - -Notes: -* When manually testing, I find I often run `Customer.objects.all().delete()` - to reset the state. -* 4242424242424242 is Stripe's test credit card, also useful for manually - testing. You can put anything in the address fields, any future expiry - date, and anything for the CVV code. - has some other fun ones. - -## BillingProcessor - -The general strategy here is that billing-relevant events get written to -RealmAuditLog with `requires_billing_update = True`, and then a worker -goes through, reads RealmAuditLog row by row, and makes the appropriate -updates in Stripe (in order), keeping track of its state in -`BillingProcessor`. An invariant is that it cannot be important when -exactly the worker gets around to making the update in Stripe, as long -as the updates for each customer (realm) are made in `RealmAuditLog.id` order. - -Almost all the complexity in the code is due to error handling. We -distinguish three kinds of errors: -* Transient errors, like rate limiting or network failures, where we just - wait a bit and try again. -* Card decline errors (see below) -* Everything else (e.g. misconfigured API keys, errors thrown by buggy code, - etc.), where we just throw an exception and stop the worker. - -We use the following strategy for card decline errors. There is a global -BillingProcessor (with `realm=None`) that processes RealmAuditLog -entries for every customer (realm). If it runs into a card decline error on -some entry, it gives up on that entry and (temporarily) all future entries -of that realm, and spins off a realm-specific BillingProcessor that -marks that realm as needing manual attention. When whatever issue has been -corrected, the realm-specific BillingProcessor completes any -realm-specific RealmAuditLog entries, and then deletes itself. - -Notes for manually resolving errors: -* `BillingProcessor.objects.filter(state='stalled')` is always safe to - handle manually. -* `BillingProcessor.objects.filter(state='started')` is safe to handle - manually only if the billing process worker is not running. -* After resolving the issue, set the processor's state to `done`. -* Stripe's idempotency keys are only valid for 24 hours. So be mindful of - that if manually cleaning something up more than 24 hours after the error - occured. - ## Upgrading Stripe API versions Stripe makes pretty regular updates to their API. The process for upgrading @@ -103,7 +26,8 @@ our code is: zulipchat Stripe account, and upgrade the API version there. We currently aren't set up to do version upgrades where there are breaking -changes. The main remaining work is ensuring that we set the stripe version -in our API calls. +changes, though breaking changes should be unlikely given the parts of the +product we use. The main remaining work for handling breaking version upgrades +is ensuring that we set the stripe version in our API calls. has some additional information. diff --git a/stubs/stripe/__init__.pyi b/stubs/stripe/__init__.pyi index dc0f7f8f15..de11c70131 100644 --- a/stubs/stripe/__init__.pyi +++ b/stubs/stripe/__init__.pyi @@ -55,6 +55,9 @@ class Invoice: billing: str billing_reason: str default_source: Source + due_date: int + finalized_at: int + lines: List[InvoiceLineItem] status: str total: int @@ -67,6 +70,18 @@ class Invoice: def list(customer: str=..., limit: Optional[int]=...) -> List[Invoice]: ... + @staticmethod + def create(auto_advance: bool=..., billing: str=..., customer: str=..., + days_until_due: Optional[int]=..., statement_descriptor: str=...) -> Invoice: + ... + + @staticmethod + def finalize_invoice(invoice: Invoice) -> Invoice: + ... + + def get(self, key: str) -> Any: + ... + class Subscription: created: int status: str @@ -138,12 +153,34 @@ class Token: class Charge: amount: int + description: str + failure_code: str + receipt_email: str + source: Source + statement_descriptor: str @staticmethod def list(customer: Optional[str]) -> List[Charge]: ... + @staticmethod + def create(amount: int=..., currency: str=..., customer: str=..., description: str=..., + receipt_email: str=..., statement_descriptor: str=...) -> Charge: + ... + class InvoiceItem: @staticmethod - def create(amount: int, currency: str, customer: Customer, subscription: Subscription) -> Subscription: + def create(amount: int=..., currency: str=..., customer: str=..., description: str=..., + discountable: bool=..., period: Dict[str, int]=..., quantity: int=..., + unit_amount: int=...) -> InvoiceItem: + ... + + @staticmethod + def list(customer: Optional[str]) -> List[InvoiceItem]: + ... + +class InvoiceLineItem: + amount: int + + def get(self, key: str) -> Any: ... diff --git a/templates/corporate/billing.html b/templates/corporate/billing.html index aa33e8a006..bd7490e7fc 100644 --- a/templates/corporate/billing.html +++ b/templates/corporate/billing.html @@ -41,7 +41,7 @@

Current payment method: {{ payment_method }}

- {% if not billed_by_invoice %} + {% if charge_automatically %} diff --git a/templates/corporate/upgrade.html b/templates/corporate/upgrade.html index 2a6ffbae7f..a9a02c5d04 100644 --- a/templates/corporate/upgrade.html +++ b/templates/corporate/upgrade.html @@ -100,7 +100,8 @@

You’ll initially be charged $ for {{ seat_count }} - users. We'll automatically charge you for additional licenses as users + users.
+ We'll automatically charge you for additional licenses as users are added, and remove licenses not in use at the end of each billing period.

@@ -109,8 +110,9 @@

- Enter the number of users you would like to pay for. You'll need to - manually add licenses later to add or invite additional users. + Enter the number of users you would like to pay for.
+ You'll need to manually add licenses to add or invite + additional users.

Number of licenses (minimum {{ seat_count }})

@@ -121,9 +123,10 @@

- Enter the number of users you would like to initially pay for. We'll - automatically charge you for additional licenses as users are added, - and remove licenses not in use at the end of each billing period. + Enter the number of users you would like to pay for.
+ We'll automatically charge you for additional licenses as users are + added, and remove licenses not in use at the end of each billing + period.

Number of licenses (minimum {{ seat_count }})

- Enter the number of users you would like to pay for. We'll email you an - invoice in 1-2 hours. Invoices can be paid by ACH transfer or credit card. + Enter the number of users you would like to pay for.
+ We'll email you an invoice in 1-2 hours. Invoices can be paid by + ACH transfer or credit card.

Number of users (minimum {{ min_invoiced_licenses }})