Stripe's webhook secret is a Worker secret, STRIPE_WEBHOOK_SECRET, from a destination made in Stripe's dashboard
Billing made its own destination through Stripe's API and kept the signing secret in D1, so a secret rolled or a destination made in the dashboard could never reach it. Now it is the usual way: the destination is made in Stripe, and its whsec_ secret is the billing Worker's secret, like STRIPE_SECRET_KEY. - stripe_webhooks is dropped (0028), with the register flow and Stripe::delete. - sudo -> Stripe checks instead of registering: whether the secret is set, the destination at api.g1t.sh/stripe/webhook as Stripe has it, its status and missing events, and one Fix destination action. - The daily upkeep finds the destination by its address, enables it and adds missing events; it never touches the secret. - deploy/stack.jsonc lists STRIPE_WEBHOOK_SECRET, so doctor checks it. - docs/BILLING_OPERATIONS.md says how to set it up and roll it.
| 1 | 1 | import { Webhook } from "lucide-react"; | |
| 2 | − | import { Link } from "react-router"; | |
| 3 | 2 | ||
| 4 | 3 | import type { StripeStatus } from "@g1t/contracts"; | |
| 5 | 4 | ||
| 6 | 5 | import type { Route } from "./+types/stripe"; | |
| 7 | 6 | import { Badge, Button, EmptyState, Notice, Section, When } from "~/components/ui"; | |
| 8 | − | import { text } from "~/lib/forms"; | |
| 9 | 7 | import { admin } from "~/lib/services.server"; | |
| 10 | 8 | import { requireStaff } from "~/lib/staff"; | |
| 11 | 9 | ||
| 16 | 14 | return { status: await admin.stripe() }; | |
| 17 | 15 | } | |
| 18 | 16 | ||
| 19 | − | type ActionData = { review: true } | { status: StripeStatus; registered: true }; | |
| 17 | + | type ActionData = { status: StripeStatus; fixed: true }; | |
| 20 | 18 | ||
| 21 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 19 | + | export async function action({ context }: Route.ActionArgs) { | |
| 22 | 20 | const staff = requireStaff(context); | |
| 23 | − | const form = await request.formData(); | |
| 24 | − | if (text(form, "intent") !== "webhook") return { review: true } satisfies ActionData; | |
| 25 | − | if (text(form, "confirm") !== "yes") return { review: true } satisfies ActionData; | |
| 26 | − | return { status: await admin.stripe(true, staff.email), registered: true } satisfies ActionData; | |
| 21 | + | return { status: await admin.stripe(true, staff.email), fixed: true } satisfies ActionData; | |
| 27 | 22 | } | |
| 28 | 23 | ||
| 29 | 24 | const MODE: Record<string, { label: string; tone: "warn" | "mint" | "danger" }> = { | |
| 32 | 27 | off: { label: "Off", tone: "danger" }, | |
| 33 | 28 | }; | |
| 34 | 29 | ||
| 30 | + | const WEBHOOK_URL = "https://api.g1t.sh/stripe/webhook"; | |
| 31 | + | ||
| 35 | 32 | export default function Stripe({ loaderData, actionData }: Route.ComponentProps) { | |
| 36 | 33 | const result = actionData as ActionData | undefined; | |
| 37 | − | const status = result && "status" in result ? result.status : loaderData.status; | |
| 38 | − | const reviewing = result != null && "review" in result; | |
| 39 | − | const registered = result != null && "registered" in result; | |
| 34 | + | const status = result?.status ?? loaderData.status; | |
| 35 | + | const fixed = result != null; | |
| 40 | 36 | const mode = MODE[status.mode] ?? { label: status.mode, tone: "warn" as const }; | |
| 41 | 37 | const { webhook } = status; | |
| 42 | − | const verb = webhook ? "Replace" : "Register"; | |
| 38 | + | const needsFix = webhook != null && (webhook.status !== "enabled" || status.missingEvents.length > 0); | |
| 39 | + | const ready = status.mode !== "off" && status.secretSet && webhook != null && !needsFix; | |
| 43 | 40 | ||
| 44 | 41 | return ( | |
| 45 | 42 | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 46 | 43 | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 47 | 44 | <div> | |
| 48 | 45 | <h1 className="text-2xl font-semibold tracking-tight">Stripe</h1> | |
| 49 | − | <p className="mt-1 text-sm text-muted">How billing talks to Stripe: its keys' mode, the webhook Stripe calls, and what it sent lately.</p> | |
| 46 | + | <p className="mt-1 text-sm text-muted">How billing hears from Stripe: the destination in Stripe, its signing secret here, and what it sent lately.</p> | |
| 50 | 47 | </div> | |
| 51 | 48 | <Badge tone={mode.tone}>{mode.label}</Badge> | |
| 52 | 49 | </div> | |
| 53 | 50 | ||
| 54 | 51 | <div className="mt-6 space-y-3"> | |
| 55 | 52 | {status.error && <Notice tone="error">{status.error}</Notice>} | |
| 56 | − | {registered && !status.error && <Notice tone="ok">Webhook registered. Stripe sends its events to it from now on.</Notice>} | |
| 57 | − | {status.mode === "off" && <Notice tone="warn">Billing has no Stripe key, so nothing reaches Stripe and no webhook can be registered.</Notice>} | |
| 58 | − | {reviewing && ( | |
| 59 | − | <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${webhook ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}> | |
| 60 | − | <h2 className="font-semibold tracking-tight">{verb} the {mode.label.toLowerCase()} webhook?</h2> | |
| 61 | − | <p className="mt-2 text-sm text-muted"> | |
| 62 | − | Billing {webhook ? "deletes the endpoint it made before, then asks" : "asks"} Stripe for a new webhook endpoint, and keeps | |
| 63 | − | its signing secret itself; nobody sees it. | |
| 64 | − | </p> | |
| 65 | − | <form method="post" action="/stripe#top" className="mt-4 flex flex-wrap items-center gap-2"> | |
| 66 | − | <input type="hidden" name="intent" value="webhook" /> | |
| 67 | − | <input type="hidden" name="confirm" value="yes" /> | |
| 68 | − | <Button type="submit" variant={webhook ? "danger" : "lavender"}> | |
| 69 | − | {verb} webhook | |
| 70 | − | </Button> | |
| 71 | − | <Link to="/stripe" className="px-2 text-sm text-muted hover:text-fg"> | |
| 72 | − | Cancel | |
| 73 | − | </Link> | |
| 74 | − | </form> | |
| 75 | − | </section> | |
| 76 | − | )} | |
| 53 | + | {fixed && !status.error && <Notice tone="ok">Destination checked: enabled, and sending every event billing handles.</Notice>} | |
| 54 | + | {status.mode === "off" && <Notice tone="warn">Billing has no Stripe key (STRIPE_SECRET_KEY), so nothing reaches Stripe.</Notice>} | |
| 55 | + | {ready && <Notice tone="ok">Ready: Stripe sends to billing, and billing can check what it sends.</Notice>} | |
| 77 | 56 | </div> | |
| 78 | 57 | ||
| 79 | 58 | <Section | |
| 80 | 59 | title="Webhook" | |
| 81 | − | description="Replacing it makes a new signing secret, kept only in billing. Do it once per mode, and again after switching to live keys." | |
| 60 | + | description="Made in Stripe's dashboard; its signing secret is the billing Worker's secret STRIPE_WEBHOOK_SECRET. Billing keeps the destination's events and enables it again if Stripe turns it off; it never changes the secret." | |
| 82 | 61 | className="mt-6" | |
| 83 | 62 | actions={ | |
| 84 | − | status.mode !== "off" && !reviewing ? ( | |
| 85 | − | <form method="post" action="/stripe#review"> | |
| 86 | − | <input type="hidden" name="intent" value="webhook" /> | |
| 87 | − | <Button type="submit" variant="quiet"> | |
| 63 | + | needsFix ? ( | |
| 64 | + | <form method="post" action="/stripe#top"> | |
| 65 | + | <Button type="submit" variant="lavender"> | |
| 88 | 66 | <Webhook size={14} /> | |
| 89 | − | {verb} webhook | |
| 67 | + | Fix destination | |
| 90 | 68 | </Button> | |
| 91 | 69 | </form> | |
| 92 | 70 | ) : null | |
| 93 | 71 | } | |
| 94 | 72 | > | |
| 95 | − | {webhook ? ( | |
| 96 | − | <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]"> | |
| 97 | − | <dt className="text-muted">URL</dt> | |
| 98 | − | <dd className="font-mono text-xs break-all">{webhook.url}</dd> | |
| 99 | − | <dt className="text-muted">Endpoint id</dt> | |
| 100 | − | <dd className="font-mono text-xs break-all text-fg-soft">{webhook.endpointId}</dd> | |
| 101 | − | <dt className="text-muted">Events</dt> | |
| 102 | − | <dd className="flex flex-wrap gap-1.5"> | |
| 103 | − | {webhook.events.map((event) => ( | |
| 104 | − | <span key={event} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs"> | |
| 105 | − | {event} | |
| 106 | − | </span> | |
| 107 | − | ))} | |
| 108 | − | </dd> | |
| 109 | − | <dt className="text-muted">Registered</dt> | |
| 110 | − | <dd className="text-fg-soft"> | |
| 111 | − | <When at={webhook.createdAt} time /> by <span className="font-mono">{webhook.createdBy}</span> | |
| 112 | − | </dd> | |
| 113 | − | </dl> | |
| 114 | − | ) : ( | |
| 115 | − | <p className="text-sm text-muted">Not registered. Until it is, billing does not hear about payments, disputes or invoices from Stripe.</p> | |
| 116 | − | )} | |
| 73 | + | <dl className="grid gap-x-6 gap-y-3 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]"> | |
| 74 | + | <dt className="text-muted">Signing secret</dt> | |
| 75 | + | <dd> | |
| 76 | + | {status.secretSet ? ( | |
| 77 | + | <Badge tone="mint">Set</Badge> | |
| 78 | + | ) : ( | |
| 79 | + | <span className="text-fg-soft"> | |
| 80 | + | <Badge tone="danger">Not set</Badge> Every event is refused until it is. In Stripe: the destination, Signing secret, Reveal; then, in{" "} | |
| 81 | + | <span className="font-mono text-xs">services/billing</span>,{" "} | |
| 82 | + | <span className="font-mono text-xs">npx wrangler secret put STRIPE_WEBHOOK_SECRET</span>. | |
| 83 | + | </span> | |
| 84 | + | )} | |
| 85 | + | </dd> | |
| 86 | + | <dt className="text-muted">Destination</dt> | |
| 87 | + | <dd className="min-w-0"> | |
| 88 | + | {webhook ? ( | |
| 89 | + | <span className="flex flex-wrap items-center gap-2"> | |
| 90 | + | <span className="font-mono text-xs break-all">{webhook.url}</span> | |
| 91 | + | <Badge tone={webhook.status === "enabled" ? "mint" : "danger"}>{webhook.status}</Badge> | |
| 92 | + | <span className="font-mono text-xs text-faint">{webhook.endpointId}</span> | |
| 93 | + | </span> | |
| 94 | + | ) : status.mode === "off" ? ( | |
| 95 | + | <span className="text-muted">Not checked without a key.</span> | |
| 96 | + | ) : ( | |
| 97 | + | <span className="text-fg-soft"> | |
| 98 | + | None in {mode.label.toLowerCase()}. In Stripe: Developers, Webhooks, Add destination, endpoint URL{" "} | |
| 99 | + | <span className="font-mono text-xs">{WEBHOOK_URL}</span>, any events (billing adds what it needs here). Then set its secret. | |
| 100 | + | </span> | |
| 101 | + | )} | |
| 102 | + | </dd> | |
| 103 | + | {webhook && ( | |
| 104 | + | <> | |
| 105 | + | <dt className="text-muted">Made</dt> | |
| 106 | + | <dd className="text-fg-soft"> | |
| 107 | + | <When at={webhook.createdAt} time /> | |
| 108 | + | </dd> | |
| 109 | + | <dt className="text-muted">Events</dt> | |
| 110 | + | <dd className="flex flex-wrap gap-1.5"> | |
| 111 | + | {webhook.events.map((event) => ( | |
| 112 | + | <span key={event} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-xs"> | |
| 113 | + | {event} | |
| 114 | + | </span> | |
| 115 | + | ))} | |
| 116 | + | {status.missingEvents.map((event) => ( | |
| 117 | + | <span key={event} className="rounded border border-danger/40 bg-danger/5 px-1.5 py-0.5 font-mono text-xs text-danger" title="Billing handles this; the destination does not send it"> | |
| 118 | + | {event} missing | |
| 119 | + | </span> | |
| 120 | + | ))} | |
| 121 | + | </dd> | |
| 122 | + | </> | |
| 123 | + | )} | |
| 124 | + | </dl> | |
| 117 | 125 | </Section> | |
| 118 | 126 | ||
| 119 | 127 | <Section title="Recent events" description="What Stripe sent, newest first, and what billing did with it." className="mt-6"> |
| 1444 | 1444 | } | |
| 1445 | 1445 | ||
| 1446 | 1446 | /// `admin_stripe`: where billing stands with Stripe. Staff only. Returns | |
| 1447 | − | /// `StripeStatus`. With `setup: true`, registers (or replaces) the webhook | |
| 1448 | − | /// endpoint for the current mode first. | |
| 1447 | + | /// `StripeStatus`. With `fix: true`, first enables the destination at | |
| 1448 | + | /// billing's address and gives it the events billing needs. | |
| 1449 | 1449 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 1450 | 1450 | pub struct AdminStripeArgs { | |
| 1451 | 1451 | #[serde(default)] | |
| 1452 | − | pub setup: bool, | |
| 1452 | + | pub fix: bool, | |
| 1453 | 1453 | #[serde(default)] | |
| 1454 | 1454 | pub by: Option<String>, | |
| 1455 | 1455 | } | |
| 1459 | 1459 | pub struct StripeStatus { | |
| 1460 | 1460 | /// `test` or `live`, from the key; `off` without one. | |
| 1461 | 1461 | pub mode: String, | |
| 1462 | + | /// Whether `STRIPE_WEBHOOK_SECRET` is set, so events can be checked. | |
| 1463 | + | pub secret_set: bool, | |
| 1464 | + | /// The destination at billing's address in Stripe, as Stripe has it. | |
| 1462 | 1465 | pub webhook: Option<StripeWebhook>, | |
| 1466 | + | /// Events billing handles that the destination does not send. | |
| 1467 | + | pub missing_events: Vec<String>, | |
| 1463 | 1468 | /// The latest events handled, newest first. | |
| 1464 | 1469 | pub recent_events: Vec<StripeEventSummary>, | |
| 1465 | − | /// What went wrong setting up, if it did. | |
| 1470 | + | /// What went wrong reading or fixing the destination, if it did. | |
| 1466 | 1471 | pub error: Option<String>, | |
| 1467 | 1472 | } | |
| 1468 | 1473 | ||
| 1471 | 1476 | pub struct StripeWebhook { | |
| 1472 | 1477 | pub url: String, | |
| 1473 | 1478 | pub endpoint_id: String, | |
| 1479 | + | /// `enabled` or `disabled`. | |
| 1480 | + | pub status: String, | |
| 1474 | 1481 | pub events: Vec<String>, | |
| 1475 | − | pub created_by: String, | |
| 1476 | 1482 | pub created_at: String, | |
| 1477 | 1483 | } | |
| 1478 | 1484 |
| 113 | 113 | "worker": "g1t-billing", | |
| 114 | 114 | "d1": { "database": "g1t-billing", "migrations": "migrations" }, | |
| 115 | 115 | "stage": "core", | |
| 116 | − | "secrets": ["STRIPE_SECRET_KEY", "CLOUDFLARE_USAGE_TOKEN"], | |
| 116 | + | "secrets": ["STRIPE_SECRET_KEY", "STRIPE_WEBHOOK_SECRET", "CLOUDFLARE_USAGE_TOKEN"], | |
| 117 | 117 | "self_host": "run" | |
| 118 | 118 | }, | |
| 119 | 119 | "integrations": { |
| 287 | 287 | Billing keeps what it needs from Stripe so reads never wait on it, and | |
| 288 | 288 | hears of changes three ways (`webhooks.rs`, `stripe_sync.rs`). | |
| 289 | 289 | ||
| 290 | − | **The webhook.** sudo → Stripe → **Register webhook** creates the endpoint | |
| 291 | − | `https://api.g1t.sh/stripe/webhook` through Stripe's API. Stripe returns the | |
| 292 | − | signing secret only in that answer; billing stores it in `stripe_webhooks`, | |
| 293 | − | one row per key mode (test, live). Never add the endpoint in Stripe's | |
| 294 | − | dashboard: its secret would not reach billing, and every event would fail | |
| 295 | − | with "The signature does not match". Register again in each mode (at | |
| 296 | − | launch, after the key changes to live). Events are claimed once each in | |
| 297 | − | `stripe_events`; a handler that fails forgets its claim, and Stripe retries. | |
| 290 | + | **The webhook.** A destination made in Stripe's dashboard (Developers → | |
| 291 | + | Webhooks → Add destination) with the endpoint URL | |
| 292 | + | `https://api.g1t.sh/stripe/webhook`, in the mode of billing's key (at | |
| 293 | + | launch, make one in live mode and put its secret). Its signing secret | |
| 294 | + | (`whsec_…`: the destination, Signing secret, Reveal) is the billing | |
| 295 | + | Worker's secret: | |
| 296 | + | ||
| 297 | + | ```sh | |
| 298 | + | cd services/billing && npx wrangler secret put STRIPE_WEBHOOK_SECRET | |
| 299 | + | ``` | |
| 300 | + | ||
| 301 | + | Without it every event is refused with 400. After rolling the secret in | |
| 302 | + | Stripe, put the new one; during the roll Stripe signs with both, so there | |
| 303 | + | is no gap. The event list need not be exact: billing adds any event it | |
| 304 | + | handles that the destination does not send (daily, or **Fix destination** | |
| 305 | + | in sudo → Stripe), and enables it again if Stripe disabled it. It never | |
| 306 | + | changes the secret. sudo → Stripe shows whether the secret is set, the | |
| 307 | + | destination and its status, missing events, and the latest events. | |
| 308 | + | Events are claimed once each in `stripe_events`; a handler that fails | |
| 309 | + | forgets its claim, and Stripe retries. | |
| 298 | 310 | ||
| 299 | 311 | **What is kept, and how it stays current** | |
| 300 | 312 | ||
| 311 | 323 | 1,000 events were listed. Claims stuck at `handling` for 10 minutes are | |
| 312 | 324 | dropped so the replay retries them. The first run reads 3 days back. | |
| 313 | 325 | ||
| 314 | − | **Daily** (`keeper::DAILY`): the endpoint is given billing's event list in | |
| 315 | − | place (its secret stays) and enabled again if Stripe disabled it, both | |
| 316 | − | audited as `stripe`/`webhook`; then up to 25 stale cards and 25 stale plans | |
| 317 | − | are read again. | |
| 326 | + | **Daily** (`keeper::DAILY`): the destination at billing's address is | |
| 327 | + | enabled again if Stripe disabled it and given any missing event, audited as | |
| 328 | + | `stripe`/`webhook`; then up to 25 stale cards and 25 stale plans are read | |
| 329 | + | again. | |
| 318 | 330 | ||
| 319 | 331 | **What still calls Stripe on a request**: starting a payment page, a plan | |
| 320 | 332 | or a card check; opening the billing portal; settling a page the person |
| 342 | 342 | export type StripeStatus = { | |
| 343 | 343 | /** `test` or `live`, from the key; `off` without one. */ | |
| 344 | 344 | mode: "test" | "live" | "off" | string; | |
| 345 | − | webhook: { url: string; endpointId: string; events: string[]; createdBy: string; createdAt: string } | null; | |
| 345 | + | /** Whether `STRIPE_WEBHOOK_SECRET` is set, so events can be checked. */ | |
| 346 | + | secretSet: boolean; | |
| 347 | + | /** The destination at billing's address in Stripe, as Stripe has it. */ | |
| 348 | + | webhook: { url: string; endpointId: string; status: "enabled" | "disabled" | string; events: string[]; createdAt: string } | null; | |
| 349 | + | /** Events billing handles that the destination does not send. */ | |
| 350 | + | missingEvents: string[]; | |
| 346 | 351 | recentEvents: { id: string; kind: string; outcome: string; receivedAt: string }[]; | |
| 347 | 352 | error: string | null; | |
| 348 | 353 | }; | |
| 513 | 518 | /** The workspace's Stripe billing page, to send to the customer. Logged. */ | |
| 514 | 519 | billingLink(workspace: string, by: string): Promise<Result<BillingLink>>; | |
| 515 | 520 | /** Where billing stands with Stripe; with `setup`, registers the webhook first. */ | |
| 516 | − | stripe(setup?: boolean, by?: string): Promise<StripeStatus>; | |
| 521 | + | /** Stripe's state for billing; `fix` enables the destination and adds missing events first. */ | |
| 522 | + | stripe(fix?: boolean, by?: string): Promise<StripeStatus>; | |
| 517 | 523 | /** Where an enterprise's invoices go; makes its Stripe customer. */ | |
| 518 | 524 | enterpriseBilling(id: string, email: string, by: string): Promise<Result<PayingAccount>>; | |
| 519 | 525 | /** Sends an enterprise its invoice now, for what its workspaces owe. */ |
| 421 | 421 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 422 | 422 | credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }), | |
| 423 | 423 | billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }), | |
| 424 | − | stripe: (setup = false, by) => call("admin_stripe", { setup, by: by ?? null }), | |
| 424 | + | stripe: (fix = false, by) => call("admin_stripe", { fix, by: by ?? null }), | |
| 425 | 425 | enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }), | |
| 426 | 426 | invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }), | |
| 427 | 427 | accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }), |
| 1 | + | -- The webhook's signing secret is the Worker secret STRIPE_WEBHOOK_SECRET, | |
| 2 | + | -- copied from the destination made in Stripe's dashboard. Billing no | |
| 3 | + | -- longer makes destinations or keeps their secrets. | |
| 4 | + | DROP TABLE IF EXISTS stripe_webhooks; |
| 167 | 167 | db: D1Database, | |
| 168 | 168 | /// Absent when no card processor is configured. | |
| 169 | 169 | stripe: Option<Stripe>, | |
| 170 | + | /// The destination's signing secret from Stripe (`STRIPE_WEBHOOK_SECRET`); | |
| 171 | + | /// without it no event is believed. | |
| 172 | + | webhook_secret: Option<String>, | |
| 170 | 173 | margin_percent: u32, | |
| 171 | 174 | /// While g1t is being built out, nothing is charged (`FREE_WHILE_BUILDING`). | |
| 172 | 175 | free: bool, | |
| 958 | 961 | .map(|key| key.to_string()) | |
| 959 | 962 | .filter(|key| !key.is_empty()) | |
| 960 | 963 | .map(Stripe::new), | |
| 964 | + | webhook_secret: env | |
| 965 | + | .secret("STRIPE_WEBHOOK_SECRET") | |
| 966 | + | .ok() | |
| 967 | + | .map(|secret| secret.to_string().trim().to_owned()) | |
| 968 | + | .filter(|secret| !secret.is_empty()), | |
| 961 | 969 | margin_percent: env | |
| 962 | 970 | .var("MARGIN_PERCENT") | |
| 963 | 971 | .ok() | |
| 1018 | 1026 | // Once a day: Stripe's endpoint kept listening to billing's events and | |
| 1019 | 1027 | // enabled, and saved cards and plans not read in a while read again. | |
| 1020 | 1028 | if event.cron() == keeper::DAILY { | |
| 1021 | − | match billing.keep_endpoint().await { | |
| 1029 | + | match billing.keep_endpoint("billing").await { | |
| 1022 | 1030 | Ok(done) => worker::console_log!("stripe endpoint: {done}"), | |
| 1023 | 1031 | Err(error) => worker::console_error!("keeping Stripe's endpoint failed: {error}"), | |
| 1024 | 1032 | } |
| 163 | 163 | self.send(Method::Post, path, Some(form(fields)), Some(key)).await | |
| 164 | 164 | } | |
| 165 | 165 | ||
| 166 | − | pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> { | |
| 167 | − | self.call(Method::Delete, path, None).await | |
| 168 | − | } | |
| 169 | − | ||
| 170 | 166 | async fn call<T: for<'a> Deserialize<'a>>( | |
| 171 | 167 | &self, | |
| 172 | 168 | method: Method, |
| 9 | 9 | //! through the same once-only claim the webhook uses. A delivery that | |
| 10 | 10 | //! failed for good, or an endpoint not registered yet, costs at most one | |
| 11 | 11 | //! cron interval. | |
| 12 | − | //! - The endpoint, kept: once a day its events are made billing's list in | |
| 13 | − | //! place (its signing secret stays), and it is enabled again if Stripe | |
| 14 | − | //! turned it off after failures. | |
| 12 | + | //! - The destination, kept: once a day it is enabled again if Stripe turned | |
| 13 | + | //! it off after failures, and given any event billing handles that it | |
| 14 | + | //! does not send. Its signing secret, `STRIPE_WEBHOOK_SECRET`, is not | |
| 15 | + | //! touched. | |
| 15 | 16 | ||
| 16 | 17 | use crate::Billing; | |
| 17 | 18 | use crate::stripe::form; | |
| 18 | − | use crate::webhooks::EVENTS; | |
| 19 | + | use crate::webhooks::{EVENTS, WEBHOOK_URL, missing_events}; | |
| 19 | 20 | use g1t_contracts::billing::Card; | |
| 20 | 21 | use g1t_contracts::time::rfc3339; | |
| 21 | 22 | use g1t_kit::now_ms; | |
| 225 | 226 | Ok(format!("{} listed, {handled} not seen before and handled", listed.len())) | |
| 226 | 227 | } | |
| 227 | 228 | ||
| 228 | − | /// Keeps the registered endpoint listening to billing's events and | |
| 229 | − | /// enabled. Nothing when no endpoint is registered for this mode. | |
| 230 | − | pub(crate) async fn keep_endpoint(&self) -> Result<String> { | |
| 231 | − | let (Some(stripe), Some(webhook)) = (&self.stripe, self.webhook_row().await?) else { | |
| 232 | − | return Ok("no endpoint registered".to_owned()); | |
| 229 | + | /// Keeps the destination at billing's address enabled and sending | |
| 230 | + | /// every event billing handles. Its signing secret is not touched. | |
| 231 | + | /// Nothing when Stripe has no destination there. | |
| 232 | + | pub(crate) async fn keep_endpoint(&self, by: &str) -> Result<String> { | |
| 233 | + | let (Some(stripe), Some(destination)) = (&self.stripe, self.destination().await?) else { | |
| 234 | + | return Ok(format!("no destination at {WEBHOOK_URL}")); | |
| 233 | 235 | }; | |
| 234 | − | #[derive(Deserialize)] | |
| 235 | − | struct Endpoint { | |
| 236 | − | status: String, | |
| 237 | − | enabled_events: Vec<String>, | |
| 238 | − | } | |
| 239 | − | let path = format!("/webhook_endpoints/{}", webhook.endpoint_id); | |
| 240 | − | let endpoint: Endpoint = stripe.get(&path).await?; | |
| 236 | + | let path = format!("/webhook_endpoints/{}", destination.id); | |
| 241 | 237 | let mut fields: Vec<(String, String)> = Vec::new(); | |
| 242 | 238 | let mut changes = Vec::new(); | |
| 243 | − | if endpoint.status != "enabled" { | |
| 239 | + | if destination.status != "enabled" { | |
| 244 | 240 | fields.push(("disabled".to_owned(), "false".to_owned())); | |
| 245 | 241 | changes.push("enabled again".to_owned()); | |
| 246 | 242 | } | |
| 247 | − | let mut wanted: Vec<&str> = EVENTS.to_vec(); | |
| 248 | − | let mut has: Vec<&str> = endpoint.enabled_events.iter().map(String::as_str).collect(); | |
| 249 | − | wanted.sort_unstable(); | |
| 250 | − | has.sort_unstable(); | |
| 251 | − | if wanted != has { | |
| 252 | − | fields.extend(EVENTS.iter().enumerate().map(|(i, event)| (format!("enabled_events[{i}]"), (*event).to_owned()))); | |
| 253 | − | changes.push(format!("events set to billing's {}", EVENTS.len())); | |
| 243 | + | let missing = missing_events(&destination.enabled_events); | |
| 244 | + | if !missing.is_empty() { | |
| 245 | + | // Stripe replaces the list: what it sends now, plus what is missing. | |
| 246 | + | let all = destination.enabled_events.iter().cloned().chain(missing.iter().cloned()); | |
| 247 | + | fields.extend(all.enumerate().map(|(i, event)| (format!("enabled_events[{i}]"), event))); | |
| 248 | + | changes.push(format!("added {}", missing.join(", "))); | |
| 254 | 249 | } | |
| 255 | 250 | if changes.is_empty() { | |
| 256 | − | return Ok("endpoint as it should be".to_owned()); | |
| 251 | + | return Ok("destination as it should be".to_owned()); | |
| 257 | 252 | } | |
| 258 | 253 | let fields: Vec<(&str, String)> = fields.iter().map(|(name, value)| (name.as_str(), value.clone())).collect(); | |
| 259 | 254 | let _: Value = stripe.post(&path, &fields).await?; | |
| 260 | − | self.db | |
| 261 | − | .prepare("UPDATE stripe_webhooks SET events = ? WHERE mode = ?") | |
| 262 | − | .bind(&[EVENTS.join(",").into(), self.mode().into()])? | |
| 263 | − | .run() | |
| 264 | − | .await?; | |
| 265 | − | let done = changes.join(", "); | |
| 266 | − | self.audit("stripe", "webhook", &format!("Endpoint {}: {done}", webhook.endpoint_id), "billing").await?; | |
| 255 | + | let done = changes.join("; "); | |
| 256 | + | self.audit("stripe", "webhook", &format!("Destination {}: {done}", destination.id), by).await?; | |
| 267 | 257 | Ok(done) | |
| 268 | 258 | } | |
| 269 | 259 |
| 64 | 64 | "setup_intent.succeeded", | |
| 65 | 65 | ]; | |
| 66 | 66 | ||
| 67 | + | /// Events billing handles that `has` does not include, in billing's order. | |
| 68 | + | pub(crate) fn missing_events(has: &[String]) -> Vec<String> { | |
| 69 | + | // `*` is every event. | |
| 70 | + | if has.iter().any(|event| event == "*") { | |
| 71 | + | return Vec::new(); | |
| 72 | + | } | |
| 73 | + | EVENTS.iter().filter(|event| !has.iter().any(|h| h == *event)).map(|event| (*event).to_owned()).collect() | |
| 74 | + | } | |
| 75 | + | ||
| 67 | 76 | /// How old a signed event may be, so a captured one cannot be replayed. | |
| 68 | 77 | const TOLERANCE_SECONDS: i64 = 5 * 60; | |
| 69 | 78 | ||
| 94 | 103 | }) | |
| 95 | 104 | } | |
| 96 | 105 | ||
| 106 | + | /// The destination at billing's address, as Stripe lists it. | |
| 97 | 107 | #[derive(Deserialize)] | |
| 98 | − | pub(crate) struct WebhookRow { | |
| 99 | − | pub(crate) endpoint_id: String, | |
| 100 | − | secret: String, | |
| 108 | + | pub(crate) struct Destination { | |
| 109 | + | pub(crate) id: String, | |
| 101 | 110 | url: String, | |
| 102 | − | pub(crate) events: String, | |
| 103 | − | created_by: String, | |
| 104 | − | created_at: String, | |
| 111 | + | /// `enabled` or `disabled`. | |
| 112 | + | pub(crate) status: String, | |
| 113 | + | pub(crate) enabled_events: Vec<String>, | |
| 114 | + | created: i64, | |
| 105 | 115 | } | |
| 106 | 116 | ||
| 107 | 117 | #[derive(Deserialize)] | |
| 137 | 147 | } | |
| 138 | 148 | } | |
| 139 | 149 | ||
| 140 | − | pub(crate) async fn webhook_row(&self) -> Result<Option<WebhookRow>> { | |
| 141 | − | self.db | |
| 142 | − | .prepare("SELECT * FROM stripe_webhooks WHERE mode = ?") | |
| 143 | − | .bind(&[self.mode().into()])? | |
| 144 | − | .first::<WebhookRow>(None) | |
| 145 | − | .await | |
| 150 | + | /// The destination at billing's address in Stripe, for this key's | |
| 151 | + | /// mode: an enabled one first. None when there is none. | |
| 152 | + | pub(crate) async fn destination(&self) -> Result<Option<Destination>> { | |
| 153 | + | let Some(stripe) = &self.stripe else { return Ok(None) }; | |
| 154 | + | #[derive(Deserialize)] | |
| 155 | + | struct List { | |
| 156 | + | data: Vec<Destination>, | |
| 157 | + | } | |
| 158 | + | let mut ours: Vec<Destination> = | |
| 159 | + | stripe.get::<List>("/webhook_endpoints?limit=100").await?.data.into_iter().filter(|d| d.url == WEBHOOK_URL).collect(); | |
| 160 | + | ours.sort_by_key(|d| d.status != "enabled"); | |
| 161 | + | Ok(ours.into_iter().next()) | |
| 146 | 162 | } | |
| 147 | 163 | ||
| 148 | 164 | // --- Staff ------------------------------------------------------------ | |
| 149 | 165 | ||
| 150 | 166 | pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> { | |
| 151 | 167 | let mut error = None; | |
| 152 | − | if a.setup | |
| 153 | − | && let Err(e) = self.register_webhook(a.by.as_deref().unwrap_or("sudo")).await { | |
| 154 | − | error = Some(e.to_string()); | |
| 168 | + | if a.fix | |
| 169 | + | && let Err(e) = self.keep_endpoint(a.by.as_deref().unwrap_or("sudo")).await | |
| 170 | + | { | |
| 171 | + | error = Some(e.to_string()); | |
| 172 | + | } | |
| 173 | + | let (webhook, missing_events) = match self.destination().await { | |
| 174 | + | Ok(Some(d)) => { | |
| 175 | + | let missing = missing_events(&d.enabled_events); | |
| 176 | + | let webhook = StripeWebhook { | |
| 177 | + | url: d.url, | |
| 178 | + | endpoint_id: d.id, | |
| 179 | + | status: d.status, | |
| 180 | + | events: d.enabled_events, | |
| 181 | + | created_at: rfc3339(d.created.max(0) as u64 * 1000), | |
| 182 | + | }; | |
| 183 | + | (Some(webhook), missing) | |
| 155 | 184 | } | |
| 156 | − | let webhook = self.webhook_row().await?.map(|row| StripeWebhook { | |
| 157 | − | url: row.url, | |
| 158 | − | endpoint_id: row.endpoint_id, | |
| 159 | − | events: row.events.split(',').map(str::to_owned).collect(), | |
| 160 | − | created_by: row.created_by, | |
| 161 | − | created_at: row.created_at, | |
| 162 | − | }); | |
| 185 | + | Ok(None) => (None, Vec::new()), | |
| 186 | + | Err(e) => { | |
| 187 | + | error = error.or(Some(format!("Stripe could not be read: {e}"))); | |
| 188 | + | (None, Vec::new()) | |
| 189 | + | } | |
| 190 | + | }; | |
| 163 | 191 | let recent_events = self | |
| 164 | 192 | .db | |
| 165 | 193 | .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25") | |
| 169 | 197 | .into_iter() | |
| 170 | 198 | .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at }) | |
| 171 | 199 | .collect(); | |
| 172 | − | Ok(StripeStatus { mode: self.mode().to_owned(), webhook, recent_events, error }) | |
| 173 | − | } | |
| 174 | − | ||
| 175 | − | /// Registers billing's endpoint at Stripe for the current mode, | |
| 176 | − | /// replacing any it made before, and keeps the new signing secret. | |
| 177 | − | async fn register_webhook(&self, by: &str) -> Result<()> { | |
| 178 | − | let Some(stripe) = &self.stripe else { | |
| 179 | − | return Err(worker::Error::RustError("payments are not set up".into())); | |
| 180 | − | }; | |
| 181 | − | #[derive(Deserialize)] | |
| 182 | − | struct Endpoint { | |
| 183 | − | id: String, | |
| 184 | − | url: String, | |
| 185 | − | #[serde(default)] | |
| 186 | − | secret: Option<String>, | |
| 187 | − | } | |
| 188 | − | #[derive(Deserialize)] | |
| 189 | − | struct List { | |
| 190 | − | data: Vec<Endpoint>, | |
| 191 | − | } | |
| 192 | − | // Ours from before, whose secret cannot be read again: replaced. | |
| 193 | − | let existing: List = stripe.get("/webhook_endpoints?limit=100").await?; | |
| 194 | − | for endpoint in existing.data.iter().filter(|e| e.url == WEBHOOK_URL) { | |
| 195 | − | let _: Value = stripe.delete(&format!("/webhook_endpoints/{}", endpoint.id)).await?; | |
| 196 | − | } | |
| 197 | − | let mut fields = vec![ | |
| 198 | − | ("url", WEBHOOK_URL.to_owned()), | |
| 199 | − | ("description", "g1t billing".to_owned()), | |
| 200 | − | ("metadata[g1t]", "billing".to_owned()), | |
| 201 | − | ]; | |
| 202 | − | let names: Vec<String> = (0..EVENTS.len()).map(|i| format!("enabled_events[{i}]")).collect(); | |
| 203 | − | for (name, event) in names.iter().zip(EVENTS) { | |
| 204 | − | fields.push((name.as_str(), (*event).to_owned())); | |
| 205 | − | } | |
| 206 | − | let created: Endpoint = stripe.post("/webhook_endpoints", &fields).await?; | |
| 207 | − | let Some(secret) = created.secret else { | |
| 208 | − | return Err(worker::Error::RustError("Stripe returned no signing secret".into())); | |
| 209 | − | }; | |
| 210 | − | self.db | |
| 211 | − | .prepare( | |
| 212 | − | "INSERT INTO stripe_webhooks (mode, endpoint_id, secret, url, events, created_by, created_at) | |
| 213 | − | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 214 | − | ON CONFLICT (mode) DO UPDATE SET endpoint_id = ?2, secret = ?3, url = ?4, events = ?5, | |
| 215 | − | created_by = ?6, created_at = ?7", | |
| 216 | − | ) | |
| 217 | − | .bind(&[ | |
| 218 | − | self.mode().into(), | |
| 219 | − | created.id.as_str().into(), | |
| 220 | − | secret.as_str().into(), | |
| 221 | − | created.url.as_str().into(), | |
| 222 | − | EVENTS.join(",").into(), | |
| 223 | − | by.into(), | |
| 224 | − | rfc3339(now_ms()).into(), | |
| 225 | − | ])? | |
| 226 | − | .run() | |
| 227 | − | .await?; | |
| 228 | − | self.audit("stripe", "webhook", &format!("Registered {WEBHOOK_URL} ({} mode)", self.mode()), by).await?; | |
| 229 | − | Ok(()) | |
| 200 | + | Ok(StripeStatus { | |
| 201 | + | mode: self.mode().to_owned(), | |
| 202 | + | secret_set: self.webhook_secret.is_some(), | |
| 203 | + | webhook, | |
| 204 | + | missing_events, | |
| 205 | + | recent_events, | |
| 206 | + | error, | |
| 207 | + | }) | |
| 230 | 208 | } | |
| 231 | 209 | ||
| 232 | 210 | // --- Events ----------------------------------------------------------- | |
| 233 | 211 | ||
| 234 | 212 | pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> { | |
| 235 | − | let Some(webhook) = self.webhook_row().await? else { | |
| 236 | − | return Ok(Outcome::fail(FailureCode::Conflict, "No webhook is registered for this mode.")); | |
| 213 | + | let Some(secret) = &self.webhook_secret else { | |
| 214 | + | return Ok(Outcome::fail( | |
| 215 | + | FailureCode::Conflict, | |
| 216 | + | "STRIPE_WEBHOOK_SECRET is not set, so billing cannot check that events come from Stripe.", | |
| 217 | + | )); | |
| 237 | 218 | }; | |
| 238 | 219 | let now_seconds = (now_ms() / 1000) as i64; | |
| 239 | − | if !verify(&a.payload, &a.signature, &webhook.secret, now_seconds) { | |
| 220 | + | if !verify(&a.payload, &a.signature, secret, now_seconds) { | |
| 240 | 221 | return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match.")); | |
| 241 | 222 | } | |
| 242 | 223 | let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?; | |
| 890 | 871 | } | |
| 891 | 872 | ||
| 892 | 873 | #[test] | |
| 874 | + | fn a_destination_misses_the_events_billing_handles_that_it_does_not_send() { | |
| 875 | + | let has: Vec<String> = EVENTS.iter().take(11).map(|e| (*e).to_owned()).collect(); | |
| 876 | + | assert_eq!(missing_events(&has), EVENTS[11..].iter().map(|e| (*e).to_owned()).collect::<Vec<_>>()); | |
| 877 | + | let all: Vec<String> = EVENTS.iter().map(|e| (*e).to_owned()).collect(); | |
| 878 | + | assert!(missing_events(&all).is_empty()); | |
| 879 | + | assert!(missing_events(&["*".to_owned()]).is_empty()); | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | #[test] | |
| 893 | 883 | fn a_signed_event_is_believed_only_as_signed_and_only_fresh() { | |
| 894 | 884 | let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#; | |
| 895 | 885 | let header = sign(payload, "whsec_test", 1_000_000); |
| 128 | 128 | }, | |
| 129 | 129 | // Settling runs every 15 minutes; checking costs daily (keeper::DAILY). | |
| 130 | 130 | "triggers": { "crons": ["*/15 * * * *", "17 4 * * *"] }, | |
| 131 | − | // Secret: STRIPE_SECRET_KEY. Without it nothing is charged and the | |
| 131 | + | // Secrets: STRIPE_SECRET_KEY. Without it nothing is charged and the | |
| 132 | 132 | // runner decides who may start agents some other way. | |
| 133 | + | // STRIPE_WEBHOOK_SECRET: the signing secret (whsec_…) of the destination | |
| 134 | + | // made in Stripe's dashboard for https://api.g1t.sh/stripe/webhook. | |
| 135 | + | // Without it every event is refused (the replay still reads them). | |
| 133 | 136 | "observability": { "enabled": true } | |
| 134 | 137 | } |