snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
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
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
This commit is contained in:
385
zerver/webhooks/stripe/view.py
Normal file
385
zerver/webhooks/stripe/view.py
Normal file
@@ -0,0 +1,385 @@
|
||||
# Webhooks for external integrations.
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from zerver.decorator import webhook_view
|
||||
from zerver.lib.exceptions import UnsupportedWebhookEventTypeError
|
||||
from zerver.lib.response import json_success
|
||||
from zerver.lib.timestamp import datetime_to_global_time, timestamp_to_datetime
|
||||
from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint
|
||||
from zerver.lib.validator import WildValue, check_bool, check_int, check_none_or, check_string
|
||||
from zerver.lib.webhooks.common import check_send_webhook_message
|
||||
from zerver.models import UserProfile
|
||||
|
||||
|
||||
class SuppressedEventError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotImplementedEventTypeError(SuppressedEventError):
|
||||
pass
|
||||
|
||||
|
||||
ALL_EVENT_TYPES = [
|
||||
"charge.dispute.closed",
|
||||
"charge.dispute.created",
|
||||
"charge.failed",
|
||||
"charge.succeeded",
|
||||
"customer.created",
|
||||
"customer.deleted",
|
||||
"customer.discount.created",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.trial_will_end",
|
||||
"customer.subscription.updated",
|
||||
"customer.updated",
|
||||
"invoice.created",
|
||||
"invoice.updated",
|
||||
"invoice.payment_failed",
|
||||
"invoiceitem.created",
|
||||
"charge.refund.updated",
|
||||
]
|
||||
|
||||
|
||||
@webhook_view("Stripe", all_event_types=ALL_EVENT_TYPES)
|
||||
@typed_endpoint
|
||||
def api_stripe_webhook(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
payload: JsonBodyPayload[WildValue],
|
||||
stream: str = "test",
|
||||
) -> HttpResponse:
|
||||
try:
|
||||
topic_name, body = topic_and_body(payload)
|
||||
except SuppressedEventError: # nocoverage
|
||||
return json_success(request)
|
||||
check_send_webhook_message(
|
||||
request, user_profile, topic_name, body, payload["type"].tame(check_string)
|
||||
)
|
||||
return json_success(request)
|
||||
|
||||
|
||||
def topic_and_body(payload: WildValue) -> tuple[str, str]:
|
||||
event_type = payload["type"].tame(
|
||||
check_string
|
||||
) # invoice.created, customer.subscription.created, etc
|
||||
if len(event_type.split(".")) == 3:
|
||||
category, resource, event = event_type.split(".")
|
||||
else:
|
||||
resource, event = event_type.split(".")
|
||||
category = resource
|
||||
|
||||
object_ = payload["data"]["object"] # The full, updated Stripe object
|
||||
|
||||
# Set the topic to the customer_id when we can
|
||||
topic_name = ""
|
||||
customer_id = object_.get("customer").tame(check_none_or(check_string))
|
||||
if customer_id is not None:
|
||||
# Running into the 60 character topic limit.
|
||||
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (customer_id, customer_id)
|
||||
topic_name = customer_id
|
||||
body = None
|
||||
|
||||
def charge_object_type(charge_id: str) -> str:
|
||||
# Legacy ACH-style payments report object_type "charge" but use a "py_" id prefix.
|
||||
return "payment" if charge_id.startswith("py_") else "charge"
|
||||
|
||||
def update_string(blacklist: Sequence[str] = []) -> str:
|
||||
assert "previous_attributes" in payload["data"]
|
||||
previous_attributes = set(payload["data"]["previous_attributes"].keys()).difference(
|
||||
blacklist
|
||||
)
|
||||
if not previous_attributes: # nocoverage
|
||||
raise SuppressedEventError
|
||||
return "".join(
|
||||
"\n* "
|
||||
+ attribute.replace("_", " ").capitalize()
|
||||
+ " is now "
|
||||
+ stringify(object_[attribute].value)
|
||||
for attribute in sorted(previous_attributes)
|
||||
)
|
||||
|
||||
def default_body(update_blacklist: Sequence[str] = []) -> str:
|
||||
id = object_["id"].tame(check_string)
|
||||
type = object_["object"].tame(check_string)
|
||||
body = "{resource} {verbed}".format(
|
||||
resource=linkified_id(id, type),
|
||||
verbed=event.replace("_", " "),
|
||||
)
|
||||
if event == "updated":
|
||||
return body + update_string(blacklist=update_blacklist)
|
||||
return body
|
||||
|
||||
if category == "account": # nocoverage
|
||||
if resource == "account":
|
||||
if event == "updated":
|
||||
if "previous_attributes" not in payload["data"]:
|
||||
raise SuppressedEventError
|
||||
topic_name = "account updates"
|
||||
body = update_string()
|
||||
else:
|
||||
# Part of Stripe Connect
|
||||
raise NotImplementedEventTypeError
|
||||
if category == "application_fee": # nocoverage
|
||||
# Part of Stripe Connect
|
||||
raise NotImplementedEventTypeError
|
||||
if category == "balance": # nocoverage
|
||||
# Not that interesting to most businesses, I think
|
||||
raise NotImplementedEventTypeError
|
||||
if category == "charge":
|
||||
if resource == "charge":
|
||||
if not topic_name: # only in legacy fixtures
|
||||
topic_name = "charges"
|
||||
object_id = object_["id"].tame(check_string)
|
||||
charge_type = charge_object_type(object_id)
|
||||
body = "{resource} for {amount} {verbed}".format(
|
||||
resource=linkified_id(object_id, charge_type),
|
||||
amount=amount_string(
|
||||
object_["amount"].tame(check_int), object_["currency"].tame(check_string)
|
||||
),
|
||||
verbed=event,
|
||||
)
|
||||
if object_["failure_code"]: # nocoverage
|
||||
body += ". Failure code: {}".format(object_["failure_code"].tame(check_string))
|
||||
if resource == "dispute":
|
||||
topic_name = "disputes"
|
||||
body = default_body() + ". Current status: {status}.".format(
|
||||
status=object_["status"].tame(check_string).replace("_", " ")
|
||||
)
|
||||
if resource == "refund":
|
||||
topic_name = "refunds"
|
||||
id = object_["id"].tame(check_string)
|
||||
type = object_["object"].tame(check_string)
|
||||
charge_id = object_["charge"].tame(check_string)
|
||||
charge_type = charge_object_type(charge_id)
|
||||
body = "A {resource} for a {charge} of {amount} was updated.".format(
|
||||
resource=linkified_id(id, type, lower=True),
|
||||
charge=linkified_id(charge_id, charge_type, lower=True),
|
||||
amount=amount_string(
|
||||
object_["amount"].tame(check_int), object_["currency"].tame(check_string)
|
||||
),
|
||||
)
|
||||
if category == "checkout_beta": # nocoverage
|
||||
# Not sure what this is
|
||||
raise NotImplementedEventTypeError
|
||||
if category == "coupon": # nocoverage
|
||||
# Not something that likely happens programmatically
|
||||
raise NotImplementedEventTypeError
|
||||
if category == "customer":
|
||||
if resource == "customer":
|
||||
# Running into the 60 character topic limit.
|
||||
# topic = '[{}](https://dashboard.stripe.com/customers/{})' % (object_['id'], object_['id'])
|
||||
topic_name = object_["id"].tame(check_string)
|
||||
body = default_body(update_blacklist=["delinquent", "currency", "default_source"])
|
||||
if event == "created":
|
||||
if object_["email"]:
|
||||
body += "\nEmail: {}".format(object_["email"].tame(check_string))
|
||||
if object_["metadata"]: # nocoverage
|
||||
for key, value in object_["metadata"].items():
|
||||
body += f"\n{key}: {value.tame(check_string)}"
|
||||
if resource == "discount":
|
||||
body = "Discount {verbed} ([{coupon_name}]({coupon_url})).".format(
|
||||
verbed=event.replace("_", " "),
|
||||
coupon_name=object_["coupon"]["name"].tame(check_string),
|
||||
coupon_url="https://dashboard.stripe.com/{}/{}".format(
|
||||
"coupons", object_["coupon"]["id"].tame(check_string)
|
||||
),
|
||||
)
|
||||
if resource == "source": # nocoverage
|
||||
body = default_body()
|
||||
if resource == "subscription":
|
||||
body = default_body()
|
||||
if event == "trial_will_end":
|
||||
DAY = 60 * 60 * 24 # seconds in a day
|
||||
# Basically always three: https://stripe.com/docs/api/python#event_types
|
||||
body += " in {days} days".format(
|
||||
days=int((object_["trial_end"].tame(check_int) - time.time() + DAY // 2) // DAY)
|
||||
)
|
||||
if event == "created":
|
||||
if object_["plan"]:
|
||||
nickname = object_["plan"]["nickname"].tame(check_none_or(check_string))
|
||||
if nickname is not None:
|
||||
body += "\nPlan: [{plan_nickname}](https://dashboard.stripe.com/plans/{plan_id})".format(
|
||||
plan_nickname=object_["plan"]["nickname"].tame(check_string),
|
||||
plan_id=object_["plan"]["id"].tame(check_string),
|
||||
)
|
||||
else:
|
||||
body += "\nPlan: https://dashboard.stripe.com/plans/{plan_id}".format(
|
||||
plan_id=object_["plan"]["id"].tame(check_string),
|
||||
)
|
||||
if object_["quantity"]:
|
||||
body += "\nQuantity: {}".format(object_["quantity"].tame(check_int))
|
||||
if "billing" in object_: # nocoverage
|
||||
body += "\nBilling method: {}".format(
|
||||
object_["billing"].tame(check_string).replace("_", " ")
|
||||
)
|
||||
if category == "file": # nocoverage
|
||||
topic_name = "files"
|
||||
body = default_body() + " ({purpose}). \nTitle: {title}".format(
|
||||
purpose=object_["purpose"].tame(check_string).replace("_", " "),
|
||||
title=object_["title"].tame(check_string),
|
||||
)
|
||||
if category == "invoice":
|
||||
if event == "upcoming": # nocoverage
|
||||
body = "Upcoming invoice created"
|
||||
elif (
|
||||
event == "updated"
|
||||
and payload["data"]["previous_attributes"].get("paid").tame(check_none_or(check_bool))
|
||||
is False
|
||||
and object_["paid"].tame(check_bool) is True
|
||||
and object_["amount_paid"].tame(check_int) != 0
|
||||
and object_["amount_remaining"].tame(check_int) == 0
|
||||
):
|
||||
# We are taking advantage of logical AND short circuiting here since we need the else
|
||||
# statement below.
|
||||
object_id = object_["id"].tame(check_string)
|
||||
invoice_link = f"https://dashboard.stripe.com/invoices/{object_id}"
|
||||
body = f"[Invoice]({invoice_link}) is now paid"
|
||||
else:
|
||||
body = default_body(
|
||||
update_blacklist=[
|
||||
"lines",
|
||||
"description",
|
||||
"number",
|
||||
"finalized_at",
|
||||
"status_transitions",
|
||||
"payment_intent",
|
||||
]
|
||||
)
|
||||
if event == "created":
|
||||
# Could potentially add link to invoice PDF here
|
||||
body += " ({reason})\nTotal: {total}\nAmount due: {due}".format(
|
||||
reason=object_["billing_reason"].tame(check_string).replace("_", " "),
|
||||
total=amount_string(
|
||||
object_["total"].tame(check_int), object_["currency"].tame(check_string)
|
||||
),
|
||||
due=amount_string(
|
||||
object_["amount_due"].tame(check_int), object_["currency"].tame(check_string)
|
||||
),
|
||||
)
|
||||
if category == "invoiceitem":
|
||||
body = default_body(update_blacklist=["description", "invoice"])
|
||||
if event == "created":
|
||||
body += " for {amount}".format(
|
||||
amount=amount_string(
|
||||
object_["amount"].tame(check_int), object_["currency"].tame(check_string)
|
||||
)
|
||||
)
|
||||
if category.startswith("issuing"): # nocoverage
|
||||
# Not implemented
|
||||
raise NotImplementedEventTypeError
|
||||
if category.startswith("order"): # nocoverage
|
||||
# Not implemented
|
||||
raise NotImplementedEventTypeError
|
||||
if category in [
|
||||
"payment_intent",
|
||||
"payout",
|
||||
"plan",
|
||||
"product",
|
||||
"recipient",
|
||||
"reporting",
|
||||
"review",
|
||||
"sigma",
|
||||
"sku",
|
||||
"source",
|
||||
"subscription_schedule",
|
||||
"topup",
|
||||
"transfer",
|
||||
]: # nocoverage
|
||||
# Not implemented. In theory doing something like
|
||||
# body = default_body()
|
||||
# may not be hard for some of these
|
||||
raise NotImplementedEventTypeError
|
||||
|
||||
if body is None:
|
||||
raise UnsupportedWebhookEventTypeError(event_type)
|
||||
return (topic_name, body)
|
||||
|
||||
|
||||
def amount_string(amount: int, currency: str) -> str:
|
||||
zero_decimal_currencies = [
|
||||
"bif",
|
||||
"djf",
|
||||
"jpy",
|
||||
"krw",
|
||||
"pyg",
|
||||
"vnd",
|
||||
"xaf",
|
||||
"xpf",
|
||||
"clp",
|
||||
"gnf",
|
||||
"kmf",
|
||||
"mga",
|
||||
"rwf",
|
||||
"vuv",
|
||||
"xof",
|
||||
]
|
||||
if currency in zero_decimal_currencies:
|
||||
decimal_amount = str(amount) # nocoverage
|
||||
else:
|
||||
decimal_amount = f"{float(amount) * 0.01:.02f}"
|
||||
|
||||
if currency == "usd": # nocoverage
|
||||
return "$" + decimal_amount
|
||||
return decimal_amount + f" {currency.upper()}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisplayInfo:
|
||||
name: str
|
||||
url_prefix: str | None = None
|
||||
|
||||
|
||||
STRIPE_OBJECT_TYPES: dict[str, DisplayInfo] = {
|
||||
# CORE RESOURCES
|
||||
"charge": DisplayInfo("Charge", "charges"),
|
||||
"customer": DisplayInfo("Customer", "customers"),
|
||||
"dispute": DisplayInfo("Dispute", "disputes"),
|
||||
"file": DisplayInfo("File", "files"),
|
||||
"file_link": DisplayInfo("File link", "file_links"),
|
||||
"payment_intent": DisplayInfo("Payment intent", "payment_intents"),
|
||||
"payout": DisplayInfo("Payout", "payouts"),
|
||||
"product": DisplayInfo("Product", "products"),
|
||||
"refund": DisplayInfo("Refund", "refunds"),
|
||||
"token": DisplayInfo("Token", "tokens"),
|
||||
# PAYMENT METHODS
|
||||
# Payment methods have URL prefixes like /customers/cus_id/sources.
|
||||
"bank_account": DisplayInfo("Bank account"),
|
||||
"card": DisplayInfo("Card"),
|
||||
"source": DisplayInfo("Source"),
|
||||
# BILLING
|
||||
# Coupons have a configurable id, but the URL prefix is /coupons.
|
||||
# It's not clear if discounts have a URL.
|
||||
"invoice": DisplayInfo("Invoice", "invoices"),
|
||||
"invoiceitem": DisplayInfo("Invoice item", "invoiceitems"),
|
||||
# Products are covered in core resources.
|
||||
# Plans have a configurable id, though by default they are created with this pattern:
|
||||
# 'plan': ('Plan', 'plans').
|
||||
"subscription": DisplayInfo("Subscription", "subscriptions"),
|
||||
"subscription_item": DisplayInfo("Subscription item", "subscription_items"),
|
||||
# Usage records seem to have URL prefixes like /subscription_items/si_id/usage_record_summaries.
|
||||
"usage_record": DisplayInfo("Usage record"),
|
||||
# UNDOCUMENTED
|
||||
"payment": DisplayInfo("Payment", "payments"),
|
||||
# Connect, Fraud, Orders, etc not implemented.
|
||||
}
|
||||
|
||||
|
||||
def linkified_id(id: str, type: str, lower: bool = False) -> str:
|
||||
display_info = STRIPE_OBJECT_TYPES[type]
|
||||
name = display_info.name
|
||||
if lower:
|
||||
name = name.lower()
|
||||
if display_info.url_prefix is None: # nocoverage
|
||||
return name
|
||||
return f"[{name}](https://dashboard.stripe.com/{display_info.url_prefix}/{id})"
|
||||
|
||||
|
||||
def stringify(value: object) -> str:
|
||||
if isinstance(value, int) and value > 1500000000 and value < 2000000000:
|
||||
return datetime_to_global_time(timestamp_to_datetime(value))
|
||||
return str(value)
|
||||
Reference in New Issue
Block a user