billing: Migrate to Stripe hosted checkout page.

This commit is contained in:
Vishnu KS
2021-08-29 13:33:29 +00:00
committed by Tim Abbott
parent 1a1b9b28ff
commit 6c06858e02
693 changed files with 60248 additions and 9126 deletions

View File

@@ -34,7 +34,6 @@ from zerver.lib.utils import assert_is_not_none
from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot
from zproject.config import get_secret
STRIPE_PUBLISHABLE_KEY = get_secret("stripe_publishable_key")
stripe.api_key = get_secret("stripe_secret_key")
BILLING_LOG_PATH = os.path.join(
@@ -223,6 +222,14 @@ class StripeConnectionError(BillingError):
pass
class UpgradeWithExistingPlanError(BillingError):
def __init__(self) -> None:
super().__init__(
"subscribing with existing subscription",
"The organization is already subscribed to a plan. Please reload the billing page.",
)
class InvalidBillingSchedule(Exception):
def __init__(self, billing_schedule: int) -> None:
self.message = f"Unknown billing_schedule: {billing_schedule}"
@@ -238,13 +245,6 @@ class InvalidTier(Exception):
def catch_stripe_errors(func: CallableT) -> CallableT:
@wraps(func)
def wrapped(*args: object, **kwargs: object) -> object:
if settings.DEVELOPMENT and not settings.TEST_SUITE: # nocoverage
if STRIPE_PUBLISHABLE_KEY is None:
raise BillingError(
"missing stripe config",
"Missing Stripe config. "
"See https://zulip.readthedocs.io/en/latest/subsystems/billing.html.",
)
try:
return func(*args, **kwargs)
# See https://stripe.com/docs/api/python#error_handling, though
@@ -283,11 +283,13 @@ def catch_stripe_errors(func: CallableT) -> CallableT:
@catch_stripe_errors
def stripe_get_customer(stripe_customer_id: str) -> stripe.Customer:
return stripe.Customer.retrieve(stripe_customer_id, expand=["default_source", "sources"])
return stripe.Customer.retrieve(
stripe_customer_id, expand=["invoice_settings", "invoice_settings.default_payment_method"]
)
@catch_stripe_errors
def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str] = None) -> Customer:
def do_create_stripe_customer(user: UserProfile, payment_method: 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
@@ -297,7 +299,10 @@ def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str] = N
description=f"{realm.string_id} ({realm.name})",
email=user.delivery_email,
metadata={"realm_id": realm.id, "realm_str": realm.string_id},
source=stripe_token,
payment_method=payment_method,
)
stripe.Customer.modify(
stripe_customer.id, invoice_settings={"default_payment_method": payment_method}
)
event_time = timestamp_to_datetime(stripe_customer.created)
with transaction.atomic():
@@ -307,7 +312,7 @@ def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str] = N
event_type=RealmAuditLog.STRIPE_CUSTOMER_CREATED,
event_time=event_time,
)
if stripe_token is not None:
if payment_method is not None:
RealmAuditLog.objects.create(
realm=user.realm,
acting_user=user,
@@ -324,17 +329,17 @@ def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str] = N
@catch_stripe_errors
def do_replace_payment_source(
user: UserProfile, stripe_token: str, pay_invoices: bool = False
) -> stripe.Customer:
def do_replace_payment_method(
user: UserProfile, payment_method: str, pay_invoices: bool = False
) -> None:
customer = get_customer_by_realm(user.realm)
assert customer is not None # for mypy
assert customer.stripe_customer_id is not None # for mypy
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
stripe_customer.source = stripe_token
# Deletes existing card: https://stripe.com/docs/api#update_customer-source
updated_stripe_customer = stripe.Customer.save(stripe_customer)
stripe.Customer.modify(
customer.stripe_customer_id, invoice_settings={"default_payment_method": payment_method}
)
RealmAuditLog.objects.create(
realm=user.realm,
acting_user=user,
@@ -343,27 +348,30 @@ def do_replace_payment_source(
)
if pay_invoices:
for stripe_invoice in stripe.Invoice.list(
collection_method="charge_automatically", customer=stripe_customer.id, status="open"
collection_method="charge_automatically",
customer=customer.stripe_customer_id,
status="open",
):
# The user will get either a receipt or a "failed payment" email, but the in-app
# messaging could be clearer here (e.g. it could explicitly tell the user that there
# were payment(s) and that they succeeded or failed).
# Worth fixing if we notice that a lot of cards end up failing at this step.
stripe.Invoice.pay(stripe_invoice)
return updated_stripe_customer
def stripe_customer_has_credit_card_as_default_source(stripe_customer: stripe.Customer) -> bool:
if not stripe_customer.default_source:
def stripe_customer_has_credit_card_as_default_payment_method(
stripe_customer: stripe.Customer,
) -> bool:
if not stripe_customer.invoice_settings.default_payment_method:
return False
return stripe_customer.default_source.object == "card"
return stripe_customer.invoice_settings.default_payment_method.type == "card"
def customer_has_credit_card_as_default_source(customer: Customer) -> bool:
def customer_has_credit_card_as_default_payment_method(customer: Customer) -> bool:
if not customer.stripe_customer_id:
return False
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
return stripe_customer_has_credit_card_as_default_source(stripe_customer)
return stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)
# event_time should roughly be timezone_now(). Not designed to handle
@@ -508,15 +516,16 @@ def make_end_of_cycle_updates_if_needed(
# Returns Customer instead of stripe_customer so that we don't make a Stripe
# API call if there's nothing to update
@catch_stripe_errors
def update_or_create_stripe_customer(
user: UserProfile, stripe_token: Optional[str] = None
user: UserProfile, payment_method: Optional[str] = None
) -> Customer:
realm = user.realm
customer = get_customer_by_realm(realm)
if customer is None or customer.stripe_customer_id is None:
return do_create_stripe_customer(user, stripe_token=stripe_token)
if stripe_token is not None:
do_replace_payment_source(user, stripe_token)
return do_create_stripe_customer(user, payment_method=payment_method)
if payment_method is not None:
do_replace_payment_method(user, payment_method, True)
return customer
@@ -595,6 +604,18 @@ def is_free_trial_offer_enabled() -> bool:
return settings.FREE_TRIAL_DAYS not in (None, 0)
def ensure_realm_does_not_have_active_plan(realm: Customer) -> None:
if get_current_plan_by_realm(realm) is not None:
# 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.warning(
"Upgrade of %s failed because of existing active plan.",
realm.string_id,
)
raise UpgradeWithExistingPlanError()
# Only used for cloud signups
@catch_stripe_errors
def process_initial_upgrade(
@@ -602,27 +623,13 @@ def process_initial_upgrade(
licenses: int,
automanage_licenses: bool,
billing_schedule: int,
stripe_token: Optional[str],
charge_automatically: bool,
free_trial: bool,
) -> None:
realm = user.realm
customer = update_or_create_stripe_customer(user, stripe_token=stripe_token)
customer = update_or_create_stripe_customer(user)
assert customer.stripe_customer_id is not None # for mypy
charge_automatically = stripe_token is not None
free_trial = is_free_trial_offer_enabled()
if get_current_plan_by_customer(customer) is not None:
# 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.warning(
"Customer %s trying to upgrade, but has an active subscription",
customer,
)
raise BillingError(
"subscribing with existing subscription", str(BillingError.TRY_RELOADING)
)
ensure_realm_does_not_have_active_plan(customer.realm)
(
billing_cycle_anchor,
next_invoice_date,
@@ -635,32 +642,6 @@ def process_initial_upgrade(
customer.default_discount,
free_trial,
)
# 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.
if charge_automatically:
if not free_trial:
stripe_charge = stripe.Charge.create(
amount=price_per_license * licenses,
currency="usd",
customer=customer.stripe_customer_id,
description=f"Upgrade to Zulip Standard, ${price_per_license/100} x {licenses}",
receipt_email=user.delivery_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.
assert isinstance(stripe_charge.source, stripe.Card)
description = f"Payment (Card ending in {stripe_charge.source.last4})"
stripe.InvoiceItem.create(
amount=price_per_license * licenses * -1,
currency="usd",
customer=customer.stripe_customer_id,
description=description,
discountable=False,
)
# TODO: The correctness of this relies on user creation, deactivation, etc being
# in a transaction.atomic() with the relevant RealmAuditLog entries