Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
223 lines
9.1 KiB
Python
223 lines
9.1 KiB
Python
import logging
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
import stripe
|
|
from django.conf import settings
|
|
|
|
from corporate.lib.stripe import (
|
|
BILLING_SUPPORT_EMAIL,
|
|
BillingError,
|
|
RealmBillingSession,
|
|
RemoteRealmBillingSession,
|
|
RemoteServerBillingSession,
|
|
get_configured_fixed_price_plan_offer,
|
|
)
|
|
from corporate.models.customers import Customer
|
|
from corporate.models.plans import CustomerPlan, get_current_plan_by_customer
|
|
from corporate.models.stripe_state import Event, Invoice, Session
|
|
from zerver.lib.send_email import FromAddress, send_email
|
|
from zerver.models.users import get_active_user_profile_by_id_in_realm
|
|
|
|
billing_logger = logging.getLogger("corporate.stripe")
|
|
|
|
|
|
def stripe_event_handler_decorator(
|
|
func: Callable[[Any, Any], None],
|
|
) -> Callable[[stripe.checkout.Session | stripe.Invoice, Event], None]:
|
|
def wrapper(
|
|
stripe_object: stripe.checkout.Session | stripe.Invoice,
|
|
event: Event,
|
|
) -> None:
|
|
event.status = Event.EVENT_HANDLER_STARTED
|
|
event.save(update_fields=["status"])
|
|
|
|
try:
|
|
func(stripe_object, event.content_object)
|
|
except BillingError as e:
|
|
billing_logger.warning(
|
|
"BillingError in %s event handler: %s. stripe_object_id=%s, customer_id=%s metadata=%s",
|
|
event.type,
|
|
e.error_description,
|
|
stripe_object.id,
|
|
stripe_object.customer,
|
|
(
|
|
None
|
|
if stripe_object.metadata is None
|
|
else stripe_object.metadata.to_dict(for_json=True)
|
|
),
|
|
)
|
|
event.status = Event.EVENT_HANDLER_FAILED
|
|
event.handler_error = {
|
|
"message": e.msg,
|
|
"description": e.error_description,
|
|
}
|
|
event.save(update_fields=["status", "handler_error"])
|
|
if isinstance(stripe_object, stripe.Invoice):
|
|
# For Invoice processing errors, send email to billing support.
|
|
message = f"BillingError in {event.type} event handler: {e.error_description}. stripe_object_id={stripe_object.id}, customer_id={stripe_object.customer} metadata={stripe_object.metadata}"
|
|
send_email(
|
|
"zerver/emails/error_processing_invoice",
|
|
to_emails=[BILLING_SUPPORT_EMAIL],
|
|
from_address=FromAddress.tokenized_no_reply_address(),
|
|
context={
|
|
"message": message,
|
|
},
|
|
)
|
|
except Exception:
|
|
billing_logger.exception(
|
|
"Uncaught exception in %s event handler:",
|
|
event.type,
|
|
stack_info=True,
|
|
)
|
|
event.status = Event.EVENT_HANDLER_FAILED
|
|
event.handler_error = {
|
|
"description": f"uncaught exception in {event.type} event handler",
|
|
"message": BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR),
|
|
}
|
|
event.save(update_fields=["status", "handler_error"])
|
|
else:
|
|
event.status = Event.EVENT_HANDLER_SUCCEEDED
|
|
event.save()
|
|
|
|
return wrapper
|
|
|
|
|
|
def get_billing_session_for_stripe_webhook(
|
|
customer: Customer, user_id: str | None
|
|
) -> RealmBillingSession | RemoteRealmBillingSession | RemoteServerBillingSession:
|
|
if customer.remote_realm is not None:
|
|
return RemoteRealmBillingSession(customer.remote_realm)
|
|
elif customer.remote_server is not None:
|
|
return RemoteServerBillingSession(customer.remote_server)
|
|
else:
|
|
assert customer.realm is not None
|
|
if user_id:
|
|
user = get_active_user_profile_by_id_in_realm(int(user_id), customer.realm)
|
|
return RealmBillingSession(user)
|
|
else:
|
|
return RealmBillingSession(user=None, realm=customer.realm) # nocoverage
|
|
|
|
|
|
@stripe_event_handler_decorator
|
|
def handle_checkout_session_completed_event(
|
|
stripe_session: stripe.checkout.Session, session: Session
|
|
) -> None:
|
|
session.status = Session.COMPLETED
|
|
session.save()
|
|
|
|
assert isinstance(stripe_session.setup_intent, str)
|
|
assert stripe_session.metadata is not None
|
|
stripe_setup_intent = stripe.SetupIntent.retrieve(stripe_session.setup_intent)
|
|
billing_session = get_billing_session_for_stripe_webhook(
|
|
session.customer,
|
|
stripe_session.metadata.user_id if "user_id" in stripe_session.metadata else None,
|
|
)
|
|
payment_method = stripe_setup_intent.payment_method
|
|
assert isinstance(payment_method, str | None)
|
|
|
|
if session.type in [
|
|
Session.CARD_UPDATE_FROM_BILLING_PAGE,
|
|
Session.CARD_UPDATE_FROM_UPGRADE_PAGE,
|
|
]:
|
|
billing_session.update_or_create_stripe_customer(payment_method)
|
|
|
|
|
|
@stripe_event_handler_decorator
|
|
def handle_invoice_paid_event(stripe_invoice: stripe.Invoice, invoice: Invoice) -> None:
|
|
invoice.status = Invoice.PAID
|
|
invoice.save(update_fields=["status"])
|
|
|
|
customer = invoice.customer
|
|
|
|
configured_fixed_price_plan = None
|
|
if customer.required_plan_tier:
|
|
configured_fixed_price_plan = get_configured_fixed_price_plan_offer(
|
|
customer, customer.required_plan_tier
|
|
)
|
|
|
|
if (
|
|
stripe_invoice.collection_method == "send_invoice"
|
|
and configured_fixed_price_plan
|
|
and configured_fixed_price_plan.sent_invoice_id == invoice.stripe_invoice_id
|
|
):
|
|
billing_session = get_billing_session_for_stripe_webhook(customer, user_id=None)
|
|
complimentary_access_plan = billing_session.get_complimentary_access_plan(customer)
|
|
assert customer.required_plan_tier is not None
|
|
billing_session.process_initial_upgrade(
|
|
plan_tier=customer.required_plan_tier,
|
|
# TODO: Currently licenses don't play any role for fixed price plan.
|
|
# We plan to introduce max_licenses allowed soon.
|
|
licenses=0,
|
|
automanage_licenses=True,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
charge_automatically=False,
|
|
free_trial=False,
|
|
complimentary_access_plan=complimentary_access_plan,
|
|
stripe_invoice_paid=True,
|
|
)
|
|
else:
|
|
metadata = stripe_invoice.metadata
|
|
# Only process upgrade required if metadata has the required keys.
|
|
# This is a safeguard to avoid processing custom invoices.
|
|
if (
|
|
metadata is None or "billing_schedule" not in metadata or "plan_tier" not in metadata
|
|
): # nocoverage
|
|
return
|
|
|
|
billing_session = get_billing_session_for_stripe_webhook(
|
|
customer, metadata.user_id if "user_id" in metadata else None
|
|
)
|
|
complimentary_access_plan = billing_session.get_complimentary_access_plan(customer)
|
|
billing_schedule = int(metadata["billing_schedule"])
|
|
plan_tier = int(metadata["plan_tier"])
|
|
charge_automatically = stripe_invoice.collection_method != "send_invoice"
|
|
if configured_fixed_price_plan and customer.required_plan_tier == plan_tier:
|
|
assert customer.required_plan_tier is not None
|
|
billing_session.process_initial_upgrade(
|
|
plan_tier=customer.required_plan_tier,
|
|
# TODO: Currently licenses don't play any role for fixed price plan.
|
|
# We plan to introduce max_licenses allowed soon.
|
|
licenses=0,
|
|
automanage_licenses=True,
|
|
billing_schedule=billing_schedule,
|
|
charge_automatically=charge_automatically,
|
|
free_trial=False,
|
|
complimentary_access_plan=complimentary_access_plan,
|
|
stripe_invoice_paid=True,
|
|
)
|
|
return
|
|
elif (
|
|
"on_free_trial" in metadata
|
|
and metadata.on_free_trial == "True"
|
|
and invoice.is_created_for_free_trial_upgrade
|
|
):
|
|
free_trial_plan = invoice.plan
|
|
assert free_trial_plan is not None
|
|
if free_trial_plan.is_free_trial():
|
|
# We don't need to do anything here. When the free trial ends we will
|
|
# check if user has paid the invoice, if not we downgrade the user.
|
|
return
|
|
|
|
# If customer paid after end of free trial, we just upgrade via default method below.
|
|
assert free_trial_plan.status == CustomerPlan.ENDED
|
|
# Also check if customer is not on any other active plan.
|
|
assert get_current_plan_by_customer(customer) is None
|
|
|
|
billing_session.process_initial_upgrade(
|
|
plan_tier=plan_tier,
|
|
licenses=int(metadata["licenses"]),
|
|
automanage_licenses=metadata["license_management"] == "automatic",
|
|
billing_schedule=billing_schedule,
|
|
charge_automatically=charge_automatically,
|
|
free_trial=False,
|
|
complimentary_access_plan=complimentary_access_plan,
|
|
stripe_invoice_paid=True,
|
|
)
|
|
|
|
|
|
@stripe_event_handler_decorator
|
|
def handle_invoice_voided_event(stripe_invoice: stripe.Invoice, invoice: Invoice) -> None:
|
|
invoice.status = Invoice.VOID
|
|
invoice.save(update_fields=["status"])
|