flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Stripe webhooks, enterprise invoices, and sudo for both

Stripe now tells billing what happens while no one is looking, at api.g1t.sh/stripe/webhook: payment pages finished after the tab closed, plan renewals and failures, refunds (partial ones add up), disputes (which stop work until resolved), and enterprise invoices paid, overdue or voided. Events are signature-checked against the endpoint billing registered itself from sudo, whose secret stays in billing, refused when older than five minutes, and handled once each. Enterprises are invoiced: when a month closes, one Stripe invoice to the enterprise's billing email with a line per workspace, net 30, paid on Stripe's hosted page. Paid clears each workspace; overdue stops their work. Staff can send one now from sudo. sudo gains a Stripe page (mode, webhook, recent events), invoices on each enterprise, and pages its workspace list, with billing figures fetched for exactly that page.

syntaqxcommitted Parenta63e438Browse files
26 files+1626−1230/26 viewed
+1−0
913913 "g1t-kit",
914914 "getrandom 0.2.17",
915915 "hex",
916+ "hmac 0.12.1",
916917 "serde",
917918 "serde_json",
918919 "sha2 0.10.9",
+29−0
147147 Ok(Response::from_json(&json!({ "message": received.message }))?.with_status(received.status))
148148 }
149149
150+async fn receive_stripe(request: &mut Request, env: &Env) -> Result<Response> {
151+ let signature = request.headers().get("stripe-signature")?.unwrap_or_default();
152+ let payload = request.text().await.unwrap_or_default();
153+ if payload.len() > 1_000_000 || signature.is_empty() {
154+ return Ok(Response::from_json(&json!({ "message": "Not a Stripe event." }))?.with_status(400));
155+ }
156+ let handled: g1t_contracts::Outcome<bool> = g1t_kit::call(
157+ &env.service("BILLING")?,
158+ "stripe_webhook",
159+ &g1t_contracts::billing::StripeWebhookArgs { payload, signature },
160+ )
161+ .await?;
162+ // A refusal is a 400, so Stripe shows it as failed; anything handled,
163+ // or already handled, is a 200, so Stripe stops sending it.
164+ Ok(match handled {
165+ g1t_contracts::Outcome::Ok(_) => Response::from_json(&json!({ "received": true }))?,
166+ g1t_contracts::Outcome::Fail(failure) => {
167+ Response::from_json(&json!({ "message": failure.message }))?.with_status(400)
168+ }
169+ })
170+}
171+
150172 async fn device_code(request: &mut Request, services: &Services) -> Result<Response> {
151173 let body = json_body(request).await;
152174 let started: DeviceStart = g1t_kit::call(
339361 let on_mcp = url.host_str().is_some_and(|host| host.starts_with("mcp."));
340362 let mut services = Services::new(env)?;
341363
364+ // Stripe reporting to billing. Signed with the secret of the endpoint
365+ // billing registered; the body goes through exactly as received, since
366+ // the signature covers its bytes.
367+ if method == "POST" && !on_mcp && path == "/stripe/webhook" {
368+ return receive_stripe(&mut request, env).await;
369+ }
370+
342371 // Outside systems reporting to a connection. They sign what they send
343372 // with the connection's own secret, which is not a g1t token, so this
344373 // comes before anything that would read one.
+5−1
197197 - **Enterprise**: one billing account paying for several workspaces, as
198198 GitHub Enterprise does. Their usage and payments count together, against
199199 one limit, on one set of terms, and each workspace's Billing page says
200− which enterprise pays for it.
200+ which enterprise pays for it. An enterprise is invoiced: when each month
201+ closes, Stripe emails one invoice to the enterprise's billing address,
202+ with a line for each workspace, due in 30 days and paid on Stripe's
203+ invoice page by card or bank transfer. Paying it clears every workspace
204+ on it; if it goes overdue, their work stops until it is paid.
201205 - **Comped**: g1t covers the account's usage. Usage is still recorded with
202206 what it cost, so the Usage page stays accurate, and paid features are on
203207 without a plan.
+25−9
33 g1t's staff console, at <https://sudo.g1t.sh>. It is organised the way
44 customers know g1t: by **workspace**.
55
6−- **Workspaces** (the home page): every workspace, with its owners, members,
7− who it is billed to, its terms, this month's usage against its limit,
8− what it was charged and what it cost g1t. Search by workspace, owner,
9− email or enterprise; filter to stopped or warning, comped or custom, or
10− on an enterprise. A workspace's page shows its members, and under
6+- **Workspaces** (the home page): every workspace, newest first, 50 to a
7+ page, with its owners, members, who it is billed to, its terms, this
8+ month's usage against its limit, what it was charged and what it cost
9+ g1t. Search by workspace, owner, email or enterprise (across the whole
10+ list); filter to stopped or warning, comped or custom, or on an
11+ enterprise. Billing's figures are fetched for exactly the page shown, so
12+ the filters, and the totals over the list, cover that page; the page
13+ says so when there is more than one. A workspace's page shows its members, and under
1114 **Billing** its terms, who it is billed to (move it onto or off an
1215 enterprise), a credit form, a Stripe billing link, its ledger and its
1316 audit log.
1417 - **Enterprises**: customers that pay for several workspaces with one
1518 bill, one limit and one set of terms. Each has its workspaces (add or
16− remove them), combined usage, terms, credits, ledger and audit log.
19+ remove them), combined usage, terms, credits, ledger and audit log, and
20+ **Invoices**: where they go (the billing email, which also makes its
21+ Stripe customer), a "Send invoice now" button, and every invoice with
22+ its status (open, paid, overdue, void), a line per workspace, and a link
23+ to Stripe's hosted invoice page. An invoice also goes out on its own as
24+ each month closes: one Stripe invoice, a line per workspace for what it
25+ owes, net 30, emailed by Stripe.
26+- **Stripe**: whether billing's key is in test or live mode (or off), the
27+ webhook Stripe calls (URL, endpoint id, events, who registered it and
28+ when), and the events Stripe sent lately with what billing did with
29+ each. "Register webhook" (or "Replace") has billing delete the endpoint
30+ it made before, create a new one and keep its signing secret, which no
31+ one sees. Do it once per mode, and again after switching to live keys.
1732
1833 Billing's internal account ids (`ws_<slug>` for a workspace's own,
1934 `ent_…` for an enterprise) are never shown as names; an enterprise's id
4560 configured.
4661 4. **Changes** are POSTs only, and only from sudo's own pages (`Origin`, or
4762 `Referer`, must be `https://sudo.g1t.sh`). Terms, enterprise moves, new
48− enterprises and Stripe billing links show a confirmation step first; a
49− credit needs the workspace's slug typed out.
63+ enterprises, Stripe billing links, invoice emails, invoices and the
64+ webhook show a confirmation step first; a credit needs the workspace's
65+ slug typed out.
5066 5. **The pages ship no JavaScript.** The content security policy forbids
5167 every script and inline style; responses are `no-store`, `noindex` and
5268 cannot be framed. The worker has no `workers.dev` address or preview URLs.
95111
96112 ```sh
97113 npm run typecheck -w @g1t/sudo
98−npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join
114+npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging
99115 npm run build -w @g1t/sudo
100116 ```
101117
+36−1
7373 ];
7474 }
7575
76+/** Where a confirmed change lands, when its result is shown in place. */
77+const RESULT_ANCHOR: Partial<Record<Review["intent"], string>> = { "billing-link": "billing-link", invoice: "invoices" };
78+
7679 export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
7780 let title: string;
7881 let body: ReactNode;
130133 its trust gives it. It may stop at once if its own limit is lower than what it owes.
131134 </p>
132135 );
136+ } else if (review.intent === "billing-email") {
137+ title = `Send ${review.name}'s invoices to ${review.after}?`;
138+ body = (
139+ <p className="text-sm text-muted">
140+ Stripe emails its invoices to <span className="font-mono text-fg">{review.after}</span>
141+ {review.before ? (
142+ <>
143+ {" "}
144+ instead of <span className="font-mono">{review.before}</span>
145+ </>
146+ ) : null}
147+ , from the next invoice on.{!review.before && " This also sets the enterprise up as a customer on Stripe."}
148+ </p>
149+ );
150+ } else if (review.intent === "invoice") {
151+ title = `Invoice ${review.name} now?`;
152+ confirm = "Send the invoice";
153+ body = (
154+ <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
155+ <li>Makes one Stripe invoice, with a line for each of its {review.workspaces} workspace{review.workspaces === 1 ? "" : "s"} for what it owes now.</li>
156+ <li>Net 30: due in 30 days.</li>
157+ <li>
158+ Stripe emails it to <span className="font-mono text-fg">{review.email ?? "the invoice email (none set yet)"}</span>, with a link to
159+ pay on Stripe's page.
160+ </li>
161+ </ul>
162+ );
133163 } else {
134164 title = `Make a Stripe billing link for ${review.workspace}?`;
135165 confirm = "Make the link";
145175 <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-merged/40 bg-merged/5"}`}>
146176 <h2 className="font-semibold tracking-tight">{title}</h2>
147177 <div className="mt-3">{body}</div>
148− <form method="post" action={`${pathname}#${review.intent === "billing-link" ? "billing-link" : "top"}`} className="mt-4 flex flex-wrap items-center gap-2">
178+ <form method="post" action={`${pathname}#${RESULT_ANCHOR[review.intent] ?? "top"}`} className="mt-4 flex flex-wrap items-center gap-2">
149179 <Hidden values={review.fields} />
150180 <input type="hidden" name="intent" value={review.intent} />
151181 <input type="hidden" name="confirm" value="yes" />
415445 detach: "Workspace removed",
416446 credit: "Credit issued",
417447 billing_link: "Billing link made",
448+ billing_email: "Invoice email set",
449+ invoice: "Invoice sent",
450+ dispute: "Payment disputed",
451+ stripe: "Stripe",
452+ webhook: "Webhook registered",
418453 };
419454
420455 export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
+31−2
88 import type { Terms } from "@g1t/contracts";
99 import { data, redirect } from "react-router";
1010
11−import { fields, parseCredit, parseNote, parseSlug, parseTerms, text } from "./forms";
11+import { fields, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms";
1212 import type { ActionData } from "./review";
1313 import { admin, identity } from "./services.server";
1414 import type { Staff } from "./staff";
1717 /** What a page acts on. `accountId` is billing's internal id, never shown. */
1818 export type Subject =
1919 | { kind: "workspace"; slug: string; accountId: string; terms: Terms; billedTo: Enterprise | null }
20− | { kind: "enterprise"; accountId: string; name: string; terms: Terms; workspaces: string[] };
20+ | { kind: "enterprise"; accountId: string; name: string; terms: Terms; workspaces: string[]; billingEmail: string | null };
2121
2222 function failed(section: string, error: string, values?: Record<string, string>) {
2323 return data<ActionData>({ error, section, values }, { status: 422 });
114114 return { link: result.value, workspace: subject.slug } satisfies ActionData;
115115 }
116116
117+ if (intent === "billing-email") {
118+ const values = fields(form, "email");
119+ if (subject.kind !== "enterprise") return failed("top", "Only an enterprise has an invoice email.");
120+ const email = parseEmail(values.email);
121+ if (!email.ok) return failed("invoices", email.error, values);
122+ if (email.value === subject.billingEmail) return failed("invoices", "That is already where its invoices go.", values);
123+ if (!confirmed) {
124+ return {
125+ review: { intent, name: subject.name, before: subject.billingEmail, after: email.value, fields: { email: email.value } },
126+ } satisfies ActionData;
127+ }
128+ const result = await admin.enterpriseBilling(subject.accountId, email.value, staff.email);
129+ if (!result.ok) return failed("invoices", result.error.message, values);
130+ return back("billing-email");
131+ }
132+
133+ if (intent === "invoice") {
134+ if (subject.kind !== "enterprise") return failed("top", "Only an enterprise is invoiced from sudo.");
135+ if (!confirmed) {
136+ return {
137+ review: { intent, name: subject.name, email: subject.billingEmail, workspaces: subject.workspaces.length, fields: {} },
138+ } satisfies ActionData;
139+ }
140+ const result = await admin.invoiceEnterprise(subject.accountId, staff.email);
141+ if (!result.ok) return failed("invoices", result.error.message);
142+ // Shown in this response; the invoice is also in the list from now on.
143+ return { invoice: result.value } satisfies ActionData;
144+ }
145+
117146 return failed("top", "Unknown action.");
118147 }
+9−1
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { parseCredit, parseSlugList, parseTerms } from "./forms.ts";
4+import { parseCredit, parseEmail, parseSlugList, parseTerms } from "./forms.ts";
55
66 const NOW = new Date("2026-10-04T12:00:00Z");
77
4949 assert.equal(parseSlugList("../etc").ok, false);
5050 });
5151
52+test("invoice emails are one address", () => {
53+ assert.deepEqual(parseEmail(" Billing@Acme.com "), { ok: true, value: "billing@acme.com" });
54+ assert.equal(parseEmail("a@b").ok, false);
55+ assert.equal(parseEmail("a@b.com, c@d.com").ok, false);
56+ assert.equal(parseEmail("a@@b.com").ok, false);
57+ assert.equal(parseEmail("").ok, false);
58+});
59+
5260 test("credits are positive and capped", () => {
5361 assert.deepEqual(parseCredit("25.50"), { ok: true, value: 25_500_000 });
5462 assert.equal(parseCredit("0").ok, false);
+14−0
109109 };
110110 }
111111
112+/** An email address for invoices: one address, lowercased. */
113+export function parseEmail(raw: string): Parsed<string> {
114+ const email = raw.trim().toLowerCase();
115+ const [local, domain, ...rest] = email.split("@");
116+ const ok =
117+ rest.length === 0 &&
118+ email.length <= 254 &&
119+ !!local &&
120+ !!domain &&
121+ /^[^\s@,;<>"]+$/.test(local) &&
122+ /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain);
123+ return ok ? { ok: true, value: email } : { ok: false, error: "Enter one email address, such as billing@acme.com." };
124+}
125+
112126 /** A credit's amount: more than nothing, and no more than the cap. */
113127 export function parseCredit(raw: string): Parsed<number> {
114128 const micros = parseDollars(raw);
+9−5
11 /**
22 * What a billing form posts back: an error for one section, a change to
3− * confirm, or a Stripe billing link to hand on. Shared by the workspace and
4− * enterprise pages.
3+ * confirm, or a result to show once (a Stripe billing link, an invoice).
4+ * Shared by the workspace and enterprise pages.
55 */
6−import type { BillingLink, Terms } from "@g1t/contracts";
6+import type { BillingLink, EnterpriseInvoice, Terms } from "@g1t/contracts";
77
88 export type Review =
99 | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> }
1010 | { intent: "attach"; workspace: string; targetName: string; fields: Record<string, string> }
1111 | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> }
12− | { intent: "billing-link"; workspace: string; fields: Record<string, string> };
12+ | { intent: "billing-link"; workspace: string; fields: Record<string, string> }
13+ | { intent: "billing-email"; name: string; before: string | null; after: string; fields: Record<string, string> }
14+ | { intent: "invoice"; name: string; email: string | null; workspaces: number; fields: Record<string, string> };
1315
1416 export type ActionData =
1517 | { error: string; section: string; values?: Record<string, string> }
1618 | { review: Review }
17− | { link: BillingLink; workspace: string };
19+ | { link: BillingLink; workspace: string }
20+ | { invoice: EnterpriseInvoice };
1821
1922 export type SectionError = { error: string; values?: Record<string, string> } | null;
2023
2528 detach: "Workspace moved off the enterprise. It pays for itself again.",
2629 credit: "Credit issued.",
2730 created: "Enterprise created.",
31+ "billing-email": "Saved where the enterprise's invoices go.",
2832 };
2933
3034 export function doneMessage(url: string): string | null {
+16−15
33
44 import type { AccountSummary, AdminWorkspace, Limit, Terms } from "@g1t/contracts";
55
6−import { STANDARD_TERMS, joinWorkspaces, legacyAccountPath, matchesQuery } from "./workspaces.ts";
6+import { STANDARD_TERMS, joinWorkspaces, legacyAccountPath, paginate } from "./workspaces.ts";
77
88 function limit(workspace: string, account: string, exposureMicros = 0): Limit {
99 return {
7777 assert.equal(rows.find((row) => row.slug === "acme")?.billing.chargedMicros, 12_000_000);
7878 });
7979
80−test("a slug only billing knows is still listed, marked unknown", () => {
81− const rows = joinWorkspaces([], [ACME]);
80+test("a page lists only its own workspaces, not the rest of an enterprise", () => {
81+ const rows = joinWorkspaces([workspace("acme")], [ACME]);
8282 assert.deepEqual(
83− rows.map((row) => [row.slug, row.known]),
84− [
85− ["acme", false],
86− ["acme-labs", false],
87− ],
83+ rows.map((row) => row.slug),
84+ ["acme"],
8885 );
8986 });
9087
91−test("search covers slug, name, owners, their emails and the enterprise", () => {
92− const [row] = joinWorkspaces([workspace("acme-labs", "ada")], [ACME]);
93− assert.ok(matchesQuery(row, "LABS"));
94− assert.ok(matchesQuery(row, "ada@"));
95− assert.ok(matchesQuery(row, "acme corp"));
96− assert.ok(matchesQuery(row, ""));
97− assert.ok(!matchesQuery(row, "globex"));
88+test("pages stay within the list", () => {
89+ const items = Array.from({ length: 120 }, (_, i) => i);
90+ assert.deepEqual(paginate(items, null).items.slice(0, 2), [0, 1]);
91+ const third = paginate(items, "3");
92+ assert.equal(third.page, 3);
93+ assert.equal(third.pages, 3);
94+ assert.equal(third.items.length, 20);
95+ assert.equal(paginate(items, "99").page, 3);
96+ assert.equal(paginate(items, "-4").page, 1);
97+ assert.equal(paginate(items, "abc").page, 1);
98+ assert.deepEqual(paginate([], "2"), { page: 1, pages: 1, items: [] });
9899 });
99100
100101 test("old account links go to the workspace or the enterprise", () => {
+15−27
4343 export type WorkspaceRow = {
4444 slug: string;
4545 name: string;
46− /** Null for a workspace billing knows and identity returned nothing for. */
47− createdAt: string | null;
46+ createdAt: string;
4847 owners: AdminOwner[];
49− memberCount: number | null;
50− /** False when only billing knows the slug. */
51− known: boolean;
48+ memberCount: number;
5249 billing: WorkspaceBilling;
5350 };
5451
9390 }
9491
9592 /**
96− * Every workspace identity listed, in its order, with its billing; then any
97− * workspace only billing knows. A workspace with no billing activity is
98− * still listed, on standard terms at $0.
93+ * The workspaces identity listed, in its order, each with its billing. Only
94+ * these: an enterprise's other workspaces, which billing returns with it,
95+ * are not added. A workspace with no billing activity is still listed, on
96+ * standard terms at $0.
9997 */
10098 export function joinWorkspaces(workspaces: AdminWorkspace[], accounts: AccountSummary[]): WorkspaceRow[] {
10199 const billing = billingBySlug(accounts);
102− const rows: WorkspaceRow[] = workspaces.map((workspace) => ({
100+ return workspaces.map((workspace) => ({
103101 slug: workspace.slug,
104102 name: workspace.name,
105103 createdAt: workspace.createdAt,
106104 owners: workspace.owners,
107105 memberCount: workspace.memberCount,
108− known: true,
109106 billing: billing.get(workspace.slug) ?? noBilling(),
110107 }));
111− const listed = new Set(rows.map((row) => row.slug));
112− for (const [slug, entry] of billing) {
113− if (listed.has(slug)) continue;
114− rows.push({ slug, name: slug, createdAt: null, owners: [], memberCount: null, known: false, billing: entry });
115− }
116− return rows;
117108 }
118109
119−/** Whether a row matches a search: slug, name, owner, owner's email or enterprise. */
120−export function matchesQuery(row: WorkspaceRow, query: string): boolean {
121− const q = query.trim().toLowerCase();
122− if (!q) return true;
123− const haystack = [
124− row.slug,
125− row.name,
126− row.billing.billedTo?.name,
127− ...row.owners.flatMap((owner) => [owner.username, owner.email]),
128− ];
129− return haystack.some((value) => value?.toLowerCase().includes(q));
110+export const PAGE_SIZE = 50;
111+
112+/** One page of a list: the page asked for, kept within the pages there are. */
113+export function paginate<T>(items: T[], requested: string | null, size = PAGE_SIZE) {
114+ const pages = Math.max(1, Math.ceil(items.length / size));
115+ const asked = Number.parseInt(requested ?? "1", 10);
116+ const page = Number.isFinite(asked) ? Math.min(Math.max(asked, 1), pages) : 1;
117+ return { page, pages, items: items.slice((page - 1) * size, page * size) };
130118 }
131119
132120 /**
+5−1
1−import { Building2, Boxes, ShieldCheck } from "lucide-react";
1+import { Boxes, Building2, CreditCard, ShieldCheck } from "lucide-react";
22 import { isRouteErrorResponse, Link, Links, Meta, Outlet, useLocation, useRouteLoaderData } from "react-router";
33
44 import type { Route } from "./+types/root";
6969 <Building2 size={14} className="hidden sm:block" />
7070 Enterprises
7171 </NavItem>
72+ <NavItem to="/stripe" active={(path) => path.startsWith("/stripe")}>
73+ <CreditCard size={14} className="hidden sm:block" />
74+ Stripe
75+ </NavItem>
7276 </nav>
7377 {root?.email && (
7478 <span
+1−0
77 route("enterprises", "routes/enterprises.tsx"),
88 route("enterprises/new", "routes/new-enterprise.tsx"),
99 route("enterprises/:id", "routes/enterprise.tsx"),
10+ route("stripe", "routes/stripe.tsx"),
1011 // Before sudo was organised around workspaces, everything was an account.
1112 route("accounts/*", "routes/legacy-accounts.tsx"),
1213 ] satisfies RouteConfig;
+137−3
1−import { ArrowLeft, Plus, Trash2 } from "lucide-react";
1+import { ArrowLeft, ExternalLink, Mail, Plus, Send, Trash2 } from "lucide-react";
22 import { data, Link, redirect, useLocation } from "react-router";
33
4−import { type AdminOwner, type Limit, httpStatus } from "@g1t/contracts";
4+import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts";
55
66 import type { Route } from "./+types/enterprise";
77 import { AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
3333 const detail = result.value;
3434 const { account } = detail.summary;
3535 if (account.kind !== "enterprise") throw data("That is not an enterprise.", { status: 404 });
36− const subject: Subject = { kind: "enterprise", accountId: account.id, name: account.name, terms: account.terms, workspaces: account.workspaces };
36+ const subject: Subject = {
37+ kind: "enterprise",
38+ accountId: account.id,
39+ name: account.name,
40+ terms: account.terms,
41+ workspaces: account.workspaces,
42+ billingEmail: account.billingEmail ?? null,
43+ };
3744 return { detail, subject };
3845 }
3946
8188 const { pathname } = useLocation();
8289 const result = actionData as ActionData | undefined;
8390 const review = result && "review" in result ? result.review : null;
91+ const sent = result && "invoice" in result ? result.invoice : null;
8492 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
8593
8694 return (
134142 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
135143 <div className="space-y-6">
136144 <MembersSection members={members} pathname={pathname} error={error("members")} />
145+ <InvoicesSection
146+ email={account.billingEmail ?? null}
147+ invoices={account.invoices ?? []}
148+ sent={sent}
149+ pathname={pathname}
150+ error={error("invoices")}
151+ />
137152 <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
138153 <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
139154 </div>
233248 </form>
234249 );
235250 }
251+
252+// --- Invoices -----------------------------------------------------------------
253+
254+const INVOICE_STATUS: Record<string, { label: string; tone: "plain" | "mint" | "warn" | "danger" | "info" }> = {
255+ open: { label: "Open", tone: "info" },
256+ paid: { label: "Paid", tone: "mint" },
257+ overdue: { label: "Overdue", tone: "danger" },
258+ void: { label: "Void", tone: "plain" },
259+};
260+
261+function InvoiceStatus({ status }: { status: string }) {
262+ const { label, tone } = INVOICE_STATUS[status] ?? { label: status, tone: "plain" as const };
263+ return <Badge tone={tone}>{label}</Badge>;
264+}
265+
266+/** Stripe's hosted invoice page, the one the customer pays on; https only. */
267+function StripeLink({ url }: { url: string | null }) {
268+ if (!url || !url.startsWith("https://")) return <span className="text-faint">—</span>;
269+ return (
270+ <a href={url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-merged hover:underline">
271+ Stripe
272+ <ExternalLink size={12} />
273+ </a>
274+ );
275+}
276+
277+function InvoicesSection({
278+ email,
279+ invoices,
280+ sent,
281+ pathname,
282+ error,
283+}: {
284+ email: string | null;
285+ invoices: EnterpriseInvoice[];
286+ sent: EnterpriseInvoice | null;
287+ pathname: string;
288+ error: SectionError;
289+}) {
290+ return (
291+ <Section id="invoices" title="Invoices" description="One Stripe invoice for every workspace it pays for, net 30, emailed by Stripe.">
292+ <div className="space-y-4">
293+ {error && <Notice tone="error">{error.error}</Notice>}
294+ {sent && (
295+ <Notice tone="ok">
296+ Invoice sent: {usd(sent.amountMicros)} for {sent.period}. <StripeLink url={sent.hostedUrl} />
297+ </Notice>
298+ )}
299+
300+ <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
301+ <input type="hidden" name="intent" value="billing-email" />
302+ <Field label="Invoices go to" hint={email ? "Stripe emails each invoice here." : "Not set yet. Needed before an invoice can be sent."} className="grow">
303+ <Input name="email" type="email" required placeholder="billing@acme.com" defaultValue={error?.values?.email ?? email ?? ""} />
304+ </Field>
305+ <Button type="submit" variant="quiet">
306+ <Mail size={14} />
307+ {email ? "Change" : "Set"}
308+ </Button>
309+ </form>
310+
311+ <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 border-t border-line pt-4 sm:flex-row sm:items-center sm:justify-between">
312+ <input type="hidden" name="intent" value="invoice" />
313+ <p className="text-sm text-muted">An invoice goes out on its own when each month closes. Send one now for what it owes so far.</p>
314+ <Button type="submit" variant="quiet" className="shrink-0">
315+ <Send size={14} />
316+ Send invoice now
317+ </Button>
318+ </form>
319+
320+ {invoices.length === 0 ? (
321+ <EmptyState title="No invoices yet" />
322+ ) : (
323+ <div className="-mx-4 -mb-4 overflow-x-auto border-t border-line sm:-mx-5 sm:-mb-5">
324+ <table className="w-full min-w-xl text-sm">
325+ <thead>
326+ <tr className="border-b border-line text-left text-xs text-muted">
327+ <th className="px-4 py-2 font-medium sm:pl-5">Period</th>
328+ <th className="px-4 py-2 font-medium">Status</th>
329+ <th className="px-4 py-2 font-medium">Lines</th>
330+ <th className="px-4 py-2 text-right font-medium">Amount</th>
331+ <th className="px-4 py-2 font-medium sm:pr-5">Page</th>
332+ </tr>
333+ </thead>
334+ <tbody>
335+ {invoices.map((invoice) => (
336+ <tr key={invoice.invoiceId} className="border-b border-line align-top last:border-0">
337+ <td className="px-4 py-2.5 sm:pl-5">
338+ <p>{invoice.period}</p>
339+ <p className="text-xs text-faint">
340+ <When at={invoice.createdAt} />
341+ </p>
342+ </td>
343+ <td className="px-4 py-2.5">
344+ <InvoiceStatus status={invoice.status} />
345+ </td>
346+ <td className="px-4 py-2.5">
347+ <ul className="space-y-0.5 text-xs">
348+ {invoice.lines.map((line) => (
349+ <li key={line.workspace} className="tabular">
350+ <span className="font-mono text-fg-soft">{line.workspace}</span>
351+ <span className="text-faint">: {usd(line.amountMicros)}</span>
352+ </li>
353+ ))}
354+ </ul>
355+ </td>
356+ <td className="tabular px-4 py-2.5 text-right whitespace-nowrap">{usd(invoice.amountMicros)}</td>
357+ <td className="px-4 py-2.5 text-xs sm:pr-5">
358+ <StripeLink url={invoice.hostedUrl} />
359+ </td>
360+ </tr>
361+ ))}
362+ </tbody>
363+ </table>
364+ </div>
365+ )}
366+ </div>
367+ </Section>
368+ );
369+}
+152−0
1+import { Webhook } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { StripeStatus } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/stripe";
7+import { Badge, Button, EmptyState, Notice, Section, When } from "~/components/ui";
8+import { text } from "~/lib/forms";
9+import { admin } from "~/lib/services.server";
10+import { requireStaff } from "~/lib/staff";
11+
12+export const meta: Route.MetaFunction = () => [{ title: "Stripe · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13+
14+export async function loader({ context }: Route.LoaderArgs) {
15+ requireStaff(context);
16+ return { status: await admin.stripe() };
17+}
18+
19+type ActionData = { review: true } | { status: StripeStatus; registered: true };
20+
21+export async function action({ request, context }: Route.ActionArgs) {
22+ 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;
27+}
28+
29+const MODE: Record<string, { label: string; tone: "warn" | "mint" | "danger" }> = {
30+ test: { label: "Test mode", tone: "warn" },
31+ live: { label: "Live", tone: "mint" },
32+ off: { label: "Off", tone: "danger" },
33+};
34+
35+export default function Stripe({ loaderData, actionData }: Route.ComponentProps) {
36+ 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;
40+ const mode = MODE[status.mode] ?? { label: status.mode, tone: "warn" as const };
41+ const { webhook } = status;
42+ const verb = webhook ? "Replace" : "Register";
43+
44+ return (
45+ <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
46+ <div className="flex flex-wrap items-start justify-between gap-4">
47+ <div>
48+ <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>
50+ </div>
51+ <Badge tone={mode.tone}>{mode.label}</Badge>
52+ </div>
53+
54+ <div className="mt-6 space-y-3">
55+ {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+ )}
77+ </div>
78+
79+ <Section
80+ 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."
82+ className="mt-6"
83+ 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">
88+ <Webhook size={14} />
89+ {verb} webhook
90+ </Button>
91+ </form>
92+ ) : null
93+ }
94+ >
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+ )}
117+ </Section>
118+
119+ <Section title="Recent events" description="What Stripe sent, newest first, and what billing did with it." className="mt-6">
120+ {status.recentEvents.length === 0 ? (
121+ <EmptyState title="No events yet" />
122+ ) : (
123+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
124+ <table className="w-full min-w-xl text-sm">
125+ <thead>
126+ <tr className="border-b border-line text-left text-xs text-muted">
127+ <th className="px-4 py-2 font-medium sm:pl-5">When</th>
128+ <th className="px-4 py-2 font-medium">Type</th>
129+ <th className="px-4 py-2 font-medium sm:pr-5">Outcome</th>
130+ </tr>
131+ </thead>
132+ <tbody>
133+ {status.recentEvents.map((event) => (
134+ <tr key={event.id} className="border-b border-line align-top last:border-0">
135+ <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
136+ <When at={event.receivedAt} time />
137+ </td>
138+ <td className="px-4 py-2.5">
139+ <p className="font-mono text-xs">{event.kind}</p>
140+ <p className="font-mono text-xs text-faint">{event.id}</p>
141+ </td>
142+ <td className="px-4 py-2.5 wrap-break-word text-fg-soft sm:pr-5">{event.outcome}</td>
143+ </tr>
144+ ))}
145+ </tbody>
146+ </table>
147+ </div>
148+ )}
149+ </Section>
150+ </main>
151+ );
152+}
+88−49
1−import { ChevronRight, Search } from "lucide-react";
1+import { ChevronLeft, ChevronRight, Search } from "lucide-react";
22 import { Link } from "react-router";
33
44 import { ADMIN_WORKSPACES_LIMIT } from "@g1t/contracts";
99 import { usd } from "~/lib/money";
1010 import { admin, identity } from "~/lib/services.server";
1111 import { requireStaff } from "~/lib/staff";
12−import { type WorkspaceRow, joinWorkspaces, matchesQuery } from "~/lib/workspaces";
12+import { PAGE_SIZE, type WorkspaceRow, joinWorkspaces, paginate } from "~/lib/workspaces";
1313
1414 export const meta: Route.MetaFunction = () => [{ title: "Workspaces · sudo" }, { name: "robots", content: "noindex, nofollow" }];
1515
2121
2222 type Filter = keyof typeof FILTERS;
2323
24−const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const;
25−
2624 export async function loader({ request, context }: Route.LoaderArgs) {
2725 requireStaff(context);
2826 const url = new URL(request.url);
2927 const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100);
3028 const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS);
3129
32− const [found, accounts] = await Promise.all([identity.workspaces(q || undefined), admin.accounts()]);
33− let workspaces = found;
34− // A search for an enterprise's name finds the workspaces it pays for,
35− // which identity knows nothing about.
36− const lower = q.toLowerCase();
37− const enterpriseMembers = new Set(
38− q
39− ? accounts
40− .filter((row) => row.account.kind === "enterprise" && row.account.name.toLowerCase().includes(lower))
41− .flatMap((row) => row.account.workspaces)
42− : [],
43− );
44− if (enterpriseMembers.size > 0) {
45− const listed = new Set(found.map((workspace) => workspace.slug));
46− const extra = (await identity.workspaces()).filter((workspace) => enterpriseMembers.has(workspace.slug) && !listed.has(workspace.slug));
47− workspaces = [...found, ...extra];
30+ let workspaces = await identity.workspaces(q || undefined);
31+ const capped = workspaces.length >= ADMIN_WORKSPACES_LIMIT;
32+ // A search for an enterprise's name also finds the workspaces it pays
33+ // for, which identity knows nothing about.
34+ if (q) {
35+ const lower = q.toLowerCase();
36+ const members = new Set(
37+ (await admin.accounts(q))
38+ .filter((row) => row.account.kind === "enterprise" && row.account.name.toLowerCase().includes(lower))
39+ .flatMap((row) => row.account.workspaces),
40+ );
41+ const listed = new Set(workspaces.map((workspace) => workspace.slug));
42+ if ([...members].some((slug) => !listed.has(slug))) {
43+ const extra = (await identity.workspaces()).filter((workspace) => members.has(workspace.slug) && !listed.has(workspace.slug));
44+ workspaces = [...workspaces, ...extra];
45+ }
4846 }
4947
50− const all = joinWorkspaces(workspaces, accounts).filter((row) => matchesQuery(row, q));
51− const rows = all
52− .filter((row) => show.every((filter) => FILTERS[filter].test(row)))
53− .sort(
54− (a, b) =>
55− SEVERITY[a.billing.limit?.state ?? "ok"] - SEVERITY[b.billing.limit?.state ?? "ok"] ||
56− (b.createdAt ?? "").localeCompare(a.createdAt ?? ""),
57− );
48+ // Billing's figures for exactly this page's workspaces.
49+ const { page, pages, items } = paginate(workspaces, url.searchParams.get("page"));
50+ const accounts = items.length > 0 ? await admin.accountsFor(items.map((workspace) => workspace.slug)) : [];
51+ const onPage = joinWorkspaces(items, accounts);
52+ const rows = onPage.filter((row) => show.every((filter) => FILTERS[filter].test(row)));
5853
59− // Money is summed over billing's accounts, so an enterprise is counted once.
60− const sum = (pick: (row: (typeof accounts)[number]) => number) => accounts.reduce((total, row) => total + pick(row), 0);
54+ // This page's own figures: a workspace on an enterprise counts its share.
55+ const sum = (pick: (row: WorkspaceRow) => number) => onPage.reduce((total, row) => total + pick(row), 0);
6156 return {
6257 q,
6358 show,
6459 rows,
65− total: all.length,
66− capped: found.length >= ADMIN_WORKSPACES_LIMIT,
60+ page,
61+ pages,
62+ onPage: onPage.length,
63+ total: workspaces.length,
64+ capped,
6765 totals: {
68− charged: sum((row) => row.chargedMicros),
69− cost: sum((row) => row.costMicros),
70− paid: sum((row) => row.paidMicros),
71− exposure: sum((row) => row.limit.exposureMicros),
72− onEnterprise: all.filter((row) => row.billing.billedTo).length,
73− stopped: accounts.filter((row) => row.limit.state === "stopped").length,
74− warning: accounts.filter((row) => row.limit.state === "warning").length,
66+ charged: sum((row) => row.billing.chargedMicros),
67+ cost: sum((row) => row.billing.costMicros),
68+ onEnterprise: onPage.filter((row) => row.billing.billedTo).length,
69+ stopped: onPage.filter((row) => row.billing.limit?.state === "stopped").length,
70+ warning: onPage.filter((row) => row.billing.limit?.state === "warning").length,
7571 },
7672 };
7773 }
7874
75+/** A link to another page of the list, keeping the search and filters. */
76+function pageHref(q: string, show: string[], page: number) {
77+ const params = new URLSearchParams();
78+ if (q) params.set("q", q);
79+ for (const filter of show) params.append("show", filter);
80+ if (page > 1) params.set("page", String(page));
81+ const query = params.toString();
82+ return query ? `/?${query}` : "/";
83+}
84+
7985 function workspaceHref(row: WorkspaceRow) {
8086 return `/workspaces/${encodeURIComponent(row.slug)}`;
8187 }
8288
8389 export default function Workspaces({ loaderData }: Route.ComponentProps) {
84− const { q, show, rows, total, capped, totals } = loaderData;
90+ const { q, show, rows, page, pages, onPage, total, capped, totals } = loaderData;
91+ const scope = pages > 1 ? "this page" : null;
8592 const margin = totals.charged - totals.cost;
8693 const filtered = q !== "" || show.length > 0;
8794
93100 </div>
94101
95102 <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
96− <Stat label="Workspaces" value={String(total)} hint={`${totals.onEnterprise} billed to an enterprise`} />
97− <Stat label="Charged this month" value={usd(totals.charged)} hint={`Cost to g1t ${usd(totals.cost)} · margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} />
98− <Stat label="Unpaid usage this month" value={usd(totals.exposure)} hint={`Paid ever ${usd(totals.paid)}`} />
99103 <Stat
100− label="Needs attention"
104+ label={q ? "Workspaces found" : "Workspaces"}
105+ value={`${total}${capped ? "+" : ""}`}
106+ hint={`${totals.onEnterprise} on ${scope ?? "the list"} billed to an enterprise`}
107+ />
108+ <Stat label={scope ? "Charged this month, this page" : "Charged this month"} value={usd(totals.charged)} hint={`Margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} />
109+ <Stat label={scope ? "Cost to g1t, this page" : "Cost to g1t"} value={usd(totals.cost)} hint="What their usage cost g1t" />
110+ <Stat
111+ label={scope ? "Needs attention, this page" : "Needs attention"}
101112 value={`${totals.stopped} stopped`}
102113 hint={`${totals.warning} near the limit`}
103114 tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"}
141152 </form>
142153
143154 <p className="mt-4 text-xs text-faint">
144− {rows.length === total ? `${total} workspaces` : `${rows.length} of ${total} workspaces`}
155+ {total} workspace{total === 1 ? "" : "s"}
145156 {q && (
146157 <>
147158 {" "}
148159 matching <span className="font-mono text-muted">{q}</span>
149160 </>
150161 )}
151− . Stopped and warning first, then newest.
162+ , newest first{pages > 1 && `, ${PAGE_SIZE} a page`}.
163+ {show.length > 0 && pages > 1 && ` Filters apply to this page: ${rows.length} of its ${onPage} match.`}
152164 {capped && ` Only the newest ${ADMIN_WORKSPACES_LIMIT} are listed; search to find older ones.`}
153165 </p>
154166
155167 {rows.length === 0 ? (
156168 <div className="mt-3">
157− <EmptyState title={filtered ? "No workspaces match" : "No workspaces yet"}>
158− {filtered ? "Try another search, or clear the filters." : "Workspaces appear here as people create them."}
169+ <EmptyState title={filtered ? (pages > 1 ? "None on this page match" : "No workspaces match") : "No workspaces yet"}>
170+ {filtered
171+ ? pages > 1
172+ ? "Filters apply one page at a time. Try the next page, another search, or clear the filters."
173+ : "Try another search, or clear the filters."
174+ : "Workspaces appear here as people create them."}
159175 </EmptyState>
160176 </div>
161177 ) : (
222238 </div>
223239 </>
224240 )}
241+
242+ {pages > 1 && (
243+ <nav aria-label="Pages" className="mt-4 flex items-center justify-between gap-3 text-sm">
244+ {page > 1 ? (
245+ <Link to={pageHref(q, show, page - 1)} className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-muted hover:border-line-strong hover:text-fg">
246+ <ChevronLeft size={14} />
247+ Previous
248+ </Link>
249+ ) : (
250+ <span />
251+ )}
252+ <span className="text-xs text-faint">
253+ Page {page} of {pages}
254+ </span>
255+ {page < pages ? (
256+ <Link to={pageHref(q, show, page + 1)} className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-muted hover:border-line-strong hover:text-fg">
257+ Next
258+ <ChevronRight size={14} />
259+ </Link>
260+ ) : (
261+ <span />
262+ )}
263+ </nav>
264+ )}
225265 </main>
226266 );
227267 }
247287 {billing.billedTo && <Badge tone="lavender">Billed to {billing.billedTo.name}</Badge>}
248288 <TermsBadge terms={billing.terms} />
249289 {billing.limit && billing.terms.kind !== "comped" && <TrustBadge trust={billing.limit.trust} />}
250− {!row.known && <Badge tone="warn">Billing only</Badge>}
251290 </div>
252291 </div>
253292 </div>
+102−0
550550 pub terms: Terms,
551551 /// The workspaces it pays for.
552552 pub workspaces: Vec<String>,
553+ /// Where an enterprise's invoices go.
554+ #[serde(default)]
555+ pub billing_email: Option<String>,
556+ /// An enterprise's invoices, newest first. Empty for a workspace's own.
557+ #[serde(default)]
558+ pub invoices: Vec<EnterpriseInvoice>,
553559 pub created_at: String,
554560 }
555561
619625 Custom,
620626 }
621627
628+/// `stripe_webhook`: an event from Stripe, as the API received it: the raw
629+/// body and its `Stripe-Signature` header. Billing checks the signature
630+/// against the secret of the endpoint it registered, and handles each
631+/// event once. Returns `Outcome<bool>`: false for one already handled.
632+#[derive(Debug, Serialize, Deserialize)]
633+pub struct StripeWebhookArgs {
634+ pub payload: String,
635+ pub signature: String,
636+}
637+
638+/// `admin_stripe`: where billing stands with Stripe. Staff only. Returns
639+/// `StripeStatus`. With `setup: true`, registers (or replaces) the webhook
640+/// endpoint for the current mode first.
641+#[derive(Debug, Default, Serialize, Deserialize)]
642+pub struct AdminStripeArgs {
643+ #[serde(default)]
644+ pub setup: bool,
645+ #[serde(default)]
646+ pub by: Option<String>,
647+}
648+
649+#[derive(Clone, Debug, Serialize, Deserialize)]
650+#[serde(rename_all = "camelCase")]
651+pub struct StripeStatus {
652+ /// `test` or `live`, from the key; `off` without one.
653+ pub mode: String,
654+ pub webhook: Option<StripeWebhook>,
655+ /// The latest events handled, newest first.
656+ pub recent_events: Vec<StripeEventSummary>,
657+ /// What went wrong setting up, if it did.
658+ pub error: Option<String>,
659+}
660+
661+#[derive(Clone, Debug, Serialize, Deserialize)]
662+#[serde(rename_all = "camelCase")]
663+pub struct StripeWebhook {
664+ pub url: String,
665+ pub endpoint_id: String,
666+ pub events: Vec<String>,
667+ pub created_by: String,
668+ pub created_at: String,
669+}
670+
671+#[derive(Clone, Debug, Serialize, Deserialize)]
672+#[serde(rename_all = "camelCase")]
673+pub struct StripeEventSummary {
674+ pub id: String,
675+ pub kind: String,
676+ pub outcome: String,
677+ pub received_at: String,
678+}
679+
680+/// `admin_enterprise_billing`: where an enterprise's invoices go. Creates
681+/// or updates its Stripe customer. Returns `Outcome<BillingAccount>`.
682+#[derive(Debug, Serialize, Deserialize)]
683+pub struct AdminEnterpriseBillingArgs {
684+ pub id: String,
685+ pub email: String,
686+ pub by: String,
687+}
688+
689+/// `admin_invoice_enterprise`: sends an enterprise its invoice now, for
690+/// what its workspaces owe, rather than waiting for the month to close.
691+/// Returns `Outcome<EnterpriseInvoice>`.
692+#[derive(Debug, Serialize, Deserialize)]
693+pub struct AdminInvoiceEnterpriseArgs {
694+ pub id: String,
695+ pub by: String,
696+}
697+
698+/// An enterprise's invoice: one line per workspace, paid on Stripe.
699+#[derive(Clone, Debug, Serialize, Deserialize)]
700+#[serde(rename_all = "camelCase")]
701+pub struct EnterpriseInvoice {
702+ pub invoice_id: String,
703+ /// Stripe's page for it, where it is paid.
704+ pub hosted_url: Option<String>,
705+ pub amount_micros: i64,
706+ /// `open`, `paid`, `overdue` or `void`.
707+ pub status: String,
708+ pub period: String,
709+ pub lines: Vec<InvoiceLine>,
710+ pub created_at: String,
711+}
712+
713+#[derive(Clone, Debug, Serialize, Deserialize)]
714+#[serde(rename_all = "camelCase")]
715+pub struct InvoiceLine {
716+ pub workspace: String,
717+ pub amount_micros: i64,
718+}
719+
622720 // --- Staff (sudo.g1t.sh) ------------------------------------------------------
623721 //
624722 // Called only by the sudo app, which only g1t staff can reach (behind
631729 pub struct AdminAccountsArgs {
632730 #[serde(default)]
633731 pub query: Option<String>,
732+ /// Exactly these workspaces' accounts, such as one page of sudo's
733+ /// list; every account with activity when absent.
734+ #[serde(default)]
735+ pub workspaces: Option<Vec<String>>,
634736 }
635737
636738 #[derive(Clone, Debug, Serialize, Deserialize)]
+31−0
9797 name: string;
9898 terms: Terms;
9999 workspaces: string[];
100+ /** Where an enterprise's invoices go. */
101+ billingEmail?: string | null;
102+ /** An enterprise's invoices, newest first. */
103+ invoices?: EnterpriseInvoice[];
100104 createdAt: string;
101105 };
102106
113117 /** One workspace's share of an `AccountSummary`. */
114118 export type WorkspaceFigures = { workspace: string; chargedMicros: number; costMicros: number; paidMicros: number };
115119
120+export type StripeStatus = {
121+ /** `test` or `live`, from the key; `off` without one. */
122+ mode: "test" | "live" | "off" | string;
123+ webhook: { url: string; endpointId: string; events: string[]; createdBy: string; createdAt: string } | null;
124+ recentEvents: { id: string; kind: string; outcome: string; receivedAt: string }[];
125+ error: string | null;
126+};
127+
128+/** An enterprise's invoice: one line per workspace, paid on Stripe's page. */
129+export type EnterpriseInvoice = {
130+ invoiceId: string;
131+ hostedUrl: string | null;
132+ amountMicros: number;
133+ status: "open" | "paid" | "overdue" | "void" | string;
134+ period: string;
135+ lines: { workspace: string; amountMicros: number }[];
136+ createdAt: string;
137+};
138+
116139 /** A customer's Stripe billing page, for staff to send them. */
117140 export type BillingLink = {
118141 /** One-time and short-lived, signed in already. */
142165 credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>;
143166 /** The workspace's Stripe billing page, to send to the customer. Logged. */
144167 billingLink(workspace: string, by: string): Promise<Result<BillingLink>>;
168+ /** Where billing stands with Stripe; with `setup`, registers the webhook first. */
169+ stripe(setup?: boolean, by?: string): Promise<StripeStatus>;
170+ /** Where an enterprise's invoices go; makes its Stripe customer. */
171+ enterpriseBilling(id: string, email: string, by: string): Promise<Result<PayingAccount>>;
172+ /** Sends an enterprise its invoice now, for what its workspaces owe. */
173+ invoiceEnterprise(id: string, by: string): Promise<Result<EnterpriseInvoice>>;
174+ /** Exactly these workspaces' accounts, such as one page of the list. */
175+ accountsFor(workspaces: string[]): Promise<AccountSummary[]>;
145176 }
146177
147178 /** How much a workspace has earned g1t's trust with money. */
+4−0
229229 attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
230230 credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
231231 billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }),
232+ stripe: (setup = false, by) => call("admin_stripe", { setup, by: by ?? null }),
233+ enterpriseBilling: (id, email, by) => call("admin_enterprise_billing", { id, email, by }),
234+ invoiceEnterprise: (id, by) => call("admin_invoice_enterprise", { id, by }),
235+ accountsFor: (workspaces) => call("admin_accounts", { query: null, workspaces }),
232236 };
233237 }
234238
+1−0
1717 getrandom = { version = "0.2", features = ["js"] }
1818 hex = "0.4"
1919 sha2 = "0.10"
20+hmac = "0.12"
+52−0
1+-- Stripe telling billing what happened, as it happens. See src/webhooks.rs.
2+
3+-- The endpoint billing registered at Stripe, one per mode (test or live),
4+-- and the secret Stripe signs its events with. Made from sudo; the secret
5+-- is never shown anywhere.
6+CREATE TABLE stripe_webhooks (
7+ mode TEXT PRIMARY KEY,
8+ endpoint_id TEXT NOT NULL,
9+ secret TEXT NOT NULL,
10+ url TEXT NOT NULL,
11+ -- Comma-separated event types.
12+ events TEXT NOT NULL,
13+ created_by TEXT NOT NULL,
14+ created_at TEXT NOT NULL
15+);
16+
17+-- Every event handled, once: Stripe may send one more than once.
18+CREATE TABLE stripe_events (
19+ id TEXT PRIMARY KEY,
20+ type TEXT NOT NULL,
21+ -- handled, ignored, or what went wrong.
22+ outcome TEXT NOT NULL,
23+ received_at TEXT NOT NULL
24+);
25+CREATE INDEX stripe_events_by_time ON stripe_events (received_at);
26+
27+-- Where an enterprise's invoices go.
28+ALTER TABLE billing_accounts ADD COLUMN billing_email TEXT;
29+
30+-- One invoice per enterprise per month (or sooner, from sudo), itemised
31+-- by workspace. Paid on Stripe's hosted invoice page.
32+CREATE TABLE enterprise_invoices (
33+ invoice_id TEXT PRIMARY KEY,
34+ account_id TEXT NOT NULL,
35+ -- YYYY-MM it closes, or 'now' for one sent from sudo.
36+ period TEXT NOT NULL,
37+ amount_micros INTEGER NOT NULL,
38+ -- open, paid, overdue or void.
39+ status TEXT NOT NULL,
40+ hosted_url TEXT,
41+ created_by TEXT NOT NULL,
42+ created_at TEXT NOT NULL,
43+ paid_at TEXT
44+);
45+CREATE INDEX enterprise_invoices_by_account ON enterprise_invoices (account_id, created_at);
46+
47+CREATE TABLE enterprise_invoice_lines (
48+ invoice_id TEXT NOT NULL,
49+ workspace TEXT NOT NULL,
50+ amount_micros INTEGER NOT NULL,
51+ PRIMARY KEY (invoice_id, workspace)
52+);
+31−8
4141 terms_set_by: Option<String>,
4242 terms_set_at: Option<String>,
4343 created_at: String,
44+ #[serde(default)]
45+ billing_email: Option<String>,
4446 }
4547
4648 impl AccountRow {
147149 terms: row.terms(),
148150 workspaces,
149151 created_at: row.created_at.clone(),
152+ billing_email: row.billing_email.clone(),
153+ invoices: vec![],
150154 }
151155 }
152156
179183 terms: Terms::standard(),
180184 workspaces: vec![workspace],
181185 created_at: String::new(),
186+ billing_email: None,
187+ invoices: vec![],
182188 },
183189 })
184190 }
188194 Ok(self.account_of(workspace).await?.terms)
189195 }
190196
197+ /// An enterprise, with its invoices.
198+ pub(crate) async fn enterprise(&self, id: &str) -> Result<Option<BillingAccount>> {
199+ let Some(row) = self.account_row(id).await?.filter(|row| row.kind == "enterprise") else {
200+ return Ok(None);
201+ };
202+ let members = self.members(&row.id).await?;
203+ let mut account = self.to_account(&row, members);
204+ account.invoices = self.enterprise_invoices(&row.id).await?;
205+ Ok(Some(account))
206+ }
207+
191208 /// An account by id, or the account of a workspace by its slug.
192209 async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> {
193210 let id = id.trim().to_lowercase();
194211 if id.starts_with("ent_") {
195− return Ok(match self.account_row(&id).await? {
196− Some(row) => {
197− let members = self.members(&row.id).await?;
198− Some(self.to_account(&row, members))
199− }
200− None => None,
201− });
212+ return self.enterprise(&id).await;
202213 }
203214 let slug = id.strip_prefix("ws_").unwrap_or(&id);
204215 if slug.is_empty() {
207218 Ok(Some(self.account_of(slug).await?))
208219 }
209220
210− async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
221+ pub(crate) async fn audit(&self, account: &str, action: &str, detail: &str, by: &str) -> Result<()> {
211222 let now = now_ms();
212223 self.db
213224 .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)")
294305 // --- Staff ------------------------------------------------------------
295306
296307 pub(crate) async fn admin_accounts(&self, a: AdminAccountsArgs) -> Result<Vec<AccountSummary>> {
308+ // Exactly the workspaces asked for, such as one page of sudo's list.
309+ if let Some(workspaces) = &a.workspaces {
310+ let mut seen = std::collections::HashSet::new();
311+ let mut summaries = vec![];
312+ for slug in workspaces.iter().take(200) {
313+ let account = self.account_of(slug).await?;
314+ if seen.insert(account.id.clone()) {
315+ summaries.push(self.summary(account).await?);
316+ }
317+ }
318+ return Ok(summaries);
319+ }
297320 // Every workspace that has used or paid for anything, and every
298321 // account with terms of its own.
299322 #[derive(Deserialize)]
+1−1
132132 }
133133
134134 /// Writes down what the processor says about a plan.
135− async fn record(
135+ pub(crate) async fn record(
136136 &self,
137137 workspace: &str,
138138 feature: Feature,
+8−0
1717 //! the methods and their arguments.
1818
1919 mod accounts;
20+mod webhooks;
2021 mod features;
2122 mod keeper;
2223 mod limits;
963964 if let Err(error) = billing.close_months().await {
964965 worker::console_error!("closing the month failed: {error}");
965966 }
967+ if let Err(error) = billing.invoice_enterprises().await {
968+ worker::console_error!("invoicing enterprises failed: {error}");
969+ }
966970 if let Ok(identity) = env.service("IDENTITY") {
967971 if let Err(error) = billing.warn_limits(&identity).await {
968972 worker::console_error!("warning owners failed: {error}");
10081012 "prices" => reply(&billing.prices().await?),
10091013 "billing_portal" => reply(&billing.billing_portal(args(body)?).await?),
10101014 "admin_billing_link" => reply(&billing.admin_billing_link(args(body)?).await?),
1015+ "admin_stripe" => reply(&billing.admin_stripe(args(body)?).await?),
1016+ "admin_enterprise_billing" => reply(&billing.admin_enterprise_billing(args(body)?).await?),
1017+ "admin_invoice_enterprise" => reply(&billing.admin_invoice_enterprise(args(body)?).await?),
1018+ "stripe_webhook" => reply(&billing.stripe_webhook(args(body)?).await?),
10111019 "note_pending" => reply(&billing.note_pending(args(body)?).await?),
10121020 "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?),
10131021 "admin_account" => reply(&billing.admin_account(args(body)?).await?),
+14−0
132132 is_live(&self.key)
133133 }
134134
135+ /// A GET of any Stripe resource, for the webhook handlers.
136+ pub(crate) async fn get<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
137+ self.call(Method::Get, path, None).await
138+ }
139+
140+ /// A form POST to any Stripe resource.
141+ pub(crate) async fn post<T: for<'a> Deserialize<'a>>(&self, path: &str, fields: &[(&str, String)]) -> Result<T> {
142+ self.call(Method::Post, path, Some(form(fields))).await
143+ }
144+
145+ pub(crate) async fn delete<T: for<'a> Deserialize<'a>>(&self, path: &str) -> Result<T> {
146+ self.call(Method::Delete, path, None).await
147+ }
148+
135149 async fn call<T: for<'a> Deserialize<'a>>(
136150 &self,
137151 method: Method,
+809−0
1+//! Stripe telling billing what happened, and enterprise invoices.
2+//!
3+//! Most of billing asks Stripe when it needs to know: a payment page is
4+//! confirmed when the person comes back, a plan is checked when its period
5+//! ends. That misses whatever happens while no one is looking: a page paid
6+//! for and closed, a renewal that failed, a refund, a dispute, an invoice
7+//! paid by bank transfer a week later. Stripe sends each as an event to
8+//! `https://api.g1t.sh/stripe/webhook`; the API passes the raw body and its
9+//! signature here, untouched.
10+//!
11+//! - The endpoint is registered by billing itself, from sudo, once per
12+//! mode, and its signing secret is kept in billing's database. It is never
13+//! shown, and nothing can be posted here without it.
14+//! - Each event is handled once, by id, and recorded with what was done.
15+//! - Every handler is safe alongside the paths that ask Stripe directly:
16+//! both claim the same rows.
17+//!
18+//! Enterprises are invoiced: one Stripe invoice per month (or sooner, from
19+//! sudo), with a line per workspace for what it owes, sent to the
20+//! enterprise's billing email and paid on Stripe's hosted invoice page.
21+//! When it is paid, each workspace is credited its line; when it goes
22+//! overdue, their work stops until it is paid.
23+
24+use g1t_contracts::billing::{
25+ AdminEnterpriseBillingArgs, AdminInvoiceEnterpriseArgs, AdminStripeArgs, BillingAccount, EnterpriseInvoice,
26+ EntryKind, InvoiceLine, StripeEventSummary, StripeStatus, StripeWebhook, StripeWebhookArgs,
27+};
28+use g1t_contracts::time::rfc3339;
29+use g1t_contracts::{FailureCode, Outcome};
30+use g1t_kit::now_ms;
31+use hmac::{Hmac, Mac};
32+use serde::Deserialize;
33+use serde_json::Value;
34+use sha2::Sha256;
35+use worker::Result;
36+use worker::wasm_bindgen::JsValue;
37+
38+use crate::Billing;
39+
40+/// Where Stripe sends events.
41+pub(crate) const WEBHOOK_URL: &str = "https://api.g1t.sh/stripe/webhook";
42+
43+/// The events billing acts on.
44+pub(crate) const EVENTS: &[&str] = &[
45+ "checkout.session.completed",
46+ "customer.subscription.updated",
47+ "customer.subscription.deleted",
48+ "invoice.paid",
49+ "invoice.payment_failed",
50+ "invoice.overdue",
51+ "invoice.voided",
52+ "charge.refunded",
53+ "charge.dispute.created",
54+ "charge.dispute.closed",
55+];
56+
57+/// How old a signed event may be, so a captured one cannot be replayed.
58+const TOLERANCE_SECONDS: i64 = 5 * 60;
59+
60+/// Whether `header` (`t=…,v1=…`) signs `payload` with `secret`, within the
61+/// tolerance of `now_seconds`.
62+pub(crate) fn verify(payload: &str, header: &str, secret: &str, now_seconds: i64) -> bool {
63+ let mut timestamp = None;
64+ let mut signatures = vec![];
65+ for part in header.split(',') {
66+ match part.trim().split_once('=') {
67+ Some(("t", value)) => timestamp = value.parse::<i64>().ok(),
68+ Some(("v1", value)) => signatures.push(value.to_owned()),
69+ _ => {}
70+ }
71+ }
72+ let Some(timestamp) = timestamp else { return false };
73+ if (now_seconds - timestamp).abs() > TOLERANCE_SECONDS {
74+ return false;
75+ }
76+ let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else { return false };
77+ mac.update(format!("{timestamp}.{payload}").as_bytes());
78+ let expected = mac.finalize().into_bytes();
79+ signatures.iter().any(|signature| {
80+ hex::decode(signature).is_ok_and(|given| {
81+ // Constant time: compare every byte whatever the first difference.
82+ given.len() == expected.len() && given.iter().zip(expected.iter()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
83+ })
84+ })
85+}
86+
87+#[derive(Deserialize)]
88+struct WebhookRow {
89+ endpoint_id: String,
90+ secret: String,
91+ url: String,
92+ events: String,
93+ created_by: String,
94+ created_at: String,
95+}
96+
97+#[derive(Deserialize)]
98+struct EventRow {
99+ id: String,
100+ r#type: String,
101+ outcome: String,
102+ received_at: String,
103+}
104+
105+#[derive(Deserialize)]
106+struct InvoiceRow {
107+ invoice_id: String,
108+ period: String,
109+ amount_micros: i64,
110+ status: String,
111+ hosted_url: Option<String>,
112+ created_at: String,
113+}
114+
115+#[derive(Deserialize)]
116+struct LineRow {
117+ workspace: String,
118+ amount_micros: i64,
119+}
120+
121+impl Billing {
122+ fn mode(&self) -> &'static str {
123+ match &self.stripe {
124+ None => "off",
125+ Some(stripe) if stripe.live() => "live",
126+ Some(_) => "test",
127+ }
128+ }
129+
130+ async fn webhook_row(&self) -> Result<Option<WebhookRow>> {
131+ self.db
132+ .prepare("SELECT * FROM stripe_webhooks WHERE mode = ?")
133+ .bind(&[self.mode().into()])?
134+ .first::<WebhookRow>(None)
135+ .await
136+ }
137+
138+ // --- Staff ------------------------------------------------------------
139+
140+ pub(crate) async fn admin_stripe(&self, a: AdminStripeArgs) -> Result<StripeStatus> {
141+ let mut error = None;
142+ if a.setup {
143+ if let Err(e) = self.register_webhook(a.by.as_deref().unwrap_or("sudo")).await {
144+ error = Some(e.to_string());
145+ }
146+ }
147+ let webhook = self.webhook_row().await?.map(|row| StripeWebhook {
148+ url: row.url,
149+ endpoint_id: row.endpoint_id,
150+ events: row.events.split(',').map(str::to_owned).collect(),
151+ created_by: row.created_by,
152+ created_at: row.created_at,
153+ });
154+ let recent_events = self
155+ .db
156+ .prepare("SELECT * FROM stripe_events ORDER BY received_at DESC LIMIT 25")
157+ .all()
158+ .await?
159+ .results::<EventRow>()?
160+ .into_iter()
161+ .map(|row| StripeEventSummary { id: row.id, kind: row.r#type, outcome: row.outcome, received_at: row.received_at })
162+ .collect();
163+ Ok(StripeStatus { mode: self.mode().to_owned(), webhook, recent_events, error })
164+ }
165+
166+ /// Registers billing's endpoint at Stripe for the current mode,
167+ /// replacing any it made before, and keeps the new signing secret.
168+ async fn register_webhook(&self, by: &str) -> Result<()> {
169+ let Some(stripe) = &self.stripe else {
170+ return Err(worker::Error::RustError("payments are not set up".into()));
171+ };
172+ #[derive(Deserialize)]
173+ struct Endpoint {
174+ id: String,
175+ url: String,
176+ #[serde(default)]
177+ secret: Option<String>,
178+ }
179+ #[derive(Deserialize)]
180+ struct List {
181+ data: Vec<Endpoint>,
182+ }
183+ // Ours from before, whose secret cannot be read again: replaced.
184+ let existing: List = stripe.get("/webhook_endpoints?limit=100").await?;
185+ for endpoint in existing.data.iter().filter(|e| e.url == WEBHOOK_URL) {
186+ let _: Value = stripe.delete(&format!("/webhook_endpoints/{}", endpoint.id)).await?;
187+ }
188+ let mut fields = vec![
189+ ("url", WEBHOOK_URL.to_owned()),
190+ ("description", "g1t billing".to_owned()),
191+ ("metadata[g1t]", "billing".to_owned()),
192+ ];
193+ let names: Vec<String> = (0..EVENTS.len()).map(|i| format!("enabled_events[{i}]")).collect();
194+ for (name, event) in names.iter().zip(EVENTS) {
195+ fields.push((name.as_str(), (*event).to_owned()));
196+ }
197+ let created: Endpoint = stripe.post("/webhook_endpoints", &fields).await?;
198+ let Some(secret) = created.secret else {
199+ return Err(worker::Error::RustError("Stripe returned no signing secret".into()));
200+ };
201+ self.db
202+ .prepare(
203+ "INSERT INTO stripe_webhooks (mode, endpoint_id, secret, url, events, created_by, created_at)
204+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
205+ ON CONFLICT (mode) DO UPDATE SET endpoint_id = ?2, secret = ?3, url = ?4, events = ?5,
206+ created_by = ?6, created_at = ?7",
207+ )
208+ .bind(&[
209+ self.mode().into(),
210+ created.id.as_str().into(),
211+ secret.as_str().into(),
212+ created.url.as_str().into(),
213+ EVENTS.join(",").into(),
214+ by.into(),
215+ rfc3339(now_ms()).into(),
216+ ])?
217+ .run()
218+ .await?;
219+ self.audit("stripe", "webhook", &format!("Registered {WEBHOOK_URL} ({} mode)", self.mode()), by).await?;
220+ Ok(())
221+ }
222+
223+ // --- Events -----------------------------------------------------------
224+
225+ pub(crate) async fn stripe_webhook(&self, a: StripeWebhookArgs) -> Result<Outcome<bool>> {
226+ let Some(webhook) = self.webhook_row().await? else {
227+ return Ok(Outcome::fail(FailureCode::Conflict, "No webhook is registered for this mode."));
228+ };
229+ let now_seconds = (now_ms() / 1000) as i64;
230+ if !verify(&a.payload, &a.signature, &webhook.secret, now_seconds) {
231+ return Ok(Outcome::fail(FailureCode::Forbidden, "The signature does not match."));
232+ }
233+ let event: Value = serde_json::from_str(&a.payload).map_err(|e| worker::Error::RustError(e.to_string()))?;
234+ let id = event["id"].as_str().unwrap_or_default().to_owned();
235+ let kind = event["type"].as_str().unwrap_or_default().to_owned();
236+ if id.is_empty() {
237+ return Ok(Outcome::fail(FailureCode::Invalid, "Not an event."));
238+ }
239+ // Once each: the first to record it handles it.
240+ let claimed = self
241+ .db
242+ .prepare("INSERT OR IGNORE INTO stripe_events (id, type, outcome, received_at) VALUES (?, ?, 'handling', ?) RETURNING id")
243+ .bind(&[id.as_str().into(), kind.as_str().into(), rfc3339(now_ms()).into()])?
244+ .first::<Value>(None)
245+ .await?;
246+ if claimed.is_none() {
247+ return Ok(Outcome::Ok(false));
248+ }
249+ let object = &event["data"]["object"];
250+ let outcome = match self.handle(&kind, object).await {
251+ Ok(outcome) => outcome,
252+ Err(error) => {
253+ // Let Stripe send it again: forget it was seen.
254+ self.db.prepare("DELETE FROM stripe_events WHERE id = ?").bind(&[id.as_str().into()])?.run().await?;
255+ return Err(error);
256+ }
257+ };
258+ self.db
259+ .prepare("UPDATE stripe_events SET outcome = ? WHERE id = ?")
260+ .bind(&[outcome.as_str().into(), id.as_str().into()])?
261+ .run()
262+ .await?;
263+ Ok(Outcome::Ok(true))
264+ }
265+
266+ async fn handle(&self, kind: &str, object: &Value) -> Result<String> {
267+ let text = |key: &str| object[key].as_str().unwrap_or_default().to_owned();
268+ Ok(match kind {
269+ "checkout.session.completed" => self.settle_checkout(&text("id")).await?,
270+ "customer.subscription.updated" | "customer.subscription.deleted" => {
271+ self.settle_subscription(&text("id")).await?
272+ }
273+ "invoice.paid" => {
274+ if let Some(subscription) = object["subscription"].as_str() {
275+ self.settle_subscription(subscription).await?
276+ } else {
277+ self.enterprise_invoice_paid(&text("id")).await?
278+ }
279+ }
280+ "invoice.payment_failed" => match object["subscription"].as_str() {
281+ Some(subscription) => self.settle_subscription(subscription).await?,
282+ None => "ignored: not a plan".to_owned(),
283+ },
284+ "invoice.overdue" => self.enterprise_invoice_status(&text("id"), "overdue").await?,
285+ "invoice.voided" => self.enterprise_invoice_status(&text("id"), "void").await?,
286+ "charge.refunded" => self.refunded(object).await?,
287+ "charge.dispute.created" => self.disputed(object, true).await?,
288+ "charge.dispute.closed" => self.disputed(object, object["status"].as_str() == Some("lost")).await?,
289+ _ => "ignored".to_owned(),
290+ })
291+ }
292+
293+ /// A payment page done, whether or not the person came back to g1t.
294+ async fn settle_checkout(&self, session_id: &str) -> Result<String> {
295+ #[derive(Deserialize)]
296+ struct Open {
297+ workspace: String,
298+ created_by: String,
299+ feature: Option<String>,
300+ }
301+ let Some(open) = self
302+ .db
303+ .prepare("SELECT workspace, created_by, feature FROM checkouts WHERE id = ? AND status = 'open'")
304+ .bind(&[session_id.into()])?
305+ .first::<Open>(None)
306+ .await?
307+ else {
308+ return Ok("ignored: already settled or not g1t's".to_owned());
309+ };
310+ let Some(stripe) = &self.stripe else { return Ok("ignored: payments off".to_owned()) };
311+ let session = stripe.session(session_id).await?;
312+ if session.payment_status != "paid" && open.feature.is_none() {
313+ return Ok("ignored: not paid".to_owned());
314+ }
315+ let claimed = self
316+ .db
317+ .prepare("UPDATE checkouts SET status = 'paid' WHERE id = ? AND status = 'open' RETURNING id")
318+ .bind(&[session_id.into()])?
319+ .first::<Value>(None)
320+ .await?;
321+ if claimed.is_none() {
322+ return Ok("ignored: settled meanwhile".to_owned());
323+ }
324+ match open.feature.as_deref() {
325+ None => {
326+ let cents = i64::from(session.amount_total.unwrap_or(0));
327+ self.enter(
328+ &open.workspace,
329+ EntryKind::TopUp,
330+ cents * 10_000,
331+ "Credit added by card",
332+ &session.id,
333+ None,
334+ None,
335+ Some(&open.created_by),
336+ session.customer.as_deref(),
337+ )
338+ .await?;
339+ Ok(format!("credited {} to {}", crate::features::dollars(cents * 10_000), open.workspace))
340+ }
341+ Some(feature) => {
342+ let Some(feature) = g1t_contracts::billing::Feature::parse(feature) else {
343+ return Ok("ignored: unknown feature".to_owned());
344+ };
345+ if let Some(subscription_id) = &session.subscription {
346+ let subscription = stripe.subscription(subscription_id).await?;
347+ self.record(&open.workspace, feature, &subscription, &open.created_by).await?;
348+ }
349+ self.db
350+ .prepare(
351+ "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3)
352+ ON CONFLICT (workspace) DO UPDATE SET customer_id = COALESCE(customer_id, ?2)",
353+ )
354+ .bind(&[open.workspace.as_str().into(), crate::optional(session.customer.as_deref()), rfc3339(now_ms()).into()])?
355+ .run()
356+ .await?;
357+ Ok(format!("{} plan started for {}", feature.title(), open.workspace))
358+ }
359+ }
360+ }
361+
362+ /// A plan that changed at Stripe: renewed, failed, canceled.
363+ async fn settle_subscription(&self, subscription_id: &str) -> Result<String> {
364+ #[derive(Deserialize)]
365+ struct Plan {
366+ workspace: String,
367+ feature: String,
368+ started_by: String,
369+ }
370+ let Some(plan) = self
371+ .db
372+ .prepare("SELECT workspace, feature, started_by FROM subscriptions WHERE subscription_id = ?")
373+ .bind(&[subscription_id.into()])?
374+ .first::<Plan>(None)
375+ .await?
376+ else {
377+ return Ok("ignored: not a g1t plan".to_owned());
378+ };
379+ let (Some(stripe), Some(feature)) = (&self.stripe, g1t_contracts::billing::Feature::parse(&plan.feature)) else {
380+ return Ok("ignored".to_owned());
381+ };
382+ let subscription = stripe.subscription(subscription_id).await?;
383+ self.record(&plan.workspace, feature, &subscription, &plan.started_by).await?;
384+ Ok(format!("{} plan for {} is {}", feature.title(), plan.workspace, subscription.status))
385+ }
386+
387+ /// The workspace a Stripe customer belongs to.
388+ async fn workspace_of_customer(&self, customer: &str) -> Result<Option<String>> {
389+ #[derive(Deserialize)]
390+ struct Row {
391+ workspace: String,
392+ }
393+ Ok(self
394+ .db
395+ .prepare("SELECT workspace FROM accounts WHERE customer_id = ?")
396+ .bind(&[customer.into()])?
397+ .first::<Row>(None)
398+ .await?
399+ .map(|row| row.workspace))
400+ }
401+
402+ /// Money given back: what was paid is less, by the refund.
403+ async fn refunded(&self, charge: &Value) -> Result<String> {
404+ let Some(customer) = charge["customer"].as_str() else { return Ok("ignored: no customer".to_owned()) };
405+ let Some(workspace) = self.workspace_of_customer(customer).await? else {
406+ return Ok("ignored: not a workspace's customer".to_owned());
407+ };
408+ let refunded = charge["amount_refunded"].as_i64().unwrap_or(0);
409+ if refunded <= 0 {
410+ return Ok("ignored: nothing refunded".to_owned());
411+ }
412+ // Each refund total once, so partial refunds add up correctly.
413+ let charge_id = charge["id"].as_str().unwrap_or_default();
414+ #[derive(Deserialize)]
415+ struct Sum {
416+ micros: Option<i64>,
417+ }
418+ let already = self
419+ .db
420+ .prepare("SELECT -SUM(amount_micros) AS micros FROM ledger WHERE reference LIKE ?")
421+ .bind(&[format!("refund/{charge_id}/%").into()])?
422+ .first::<Sum>(None)
423+ .await?
424+ .and_then(|s| s.micros)
425+ .unwrap_or(0);
426+ let new = refunded * 10_000 - already;
427+ if new <= 0 {
428+ return Ok("ignored: refund already recorded".to_owned());
429+ }
430+ self.enter(
431+ &workspace,
432+ EntryKind::TopUp,
433+ -new,
434+ "Refunded to the card",
435+ &format!("refund/{charge_id}/{refunded}"),
436+ None,
437+ None,
438+ None,
439+ None,
440+ )
441+ .await?;
442+ Ok(format!("refund of {} recorded for {workspace}", crate::features::dollars(new)))
443+ }
444+
445+ /// A disputed payment stops the workspace's work until it is resolved;
446+ /// one closed in the workspace's favour lets it go on.
447+ async fn disputed(&self, dispute: &Value, stop: bool) -> Result<String> {
448+ let Some(stripe) = &self.stripe else { return Ok("ignored".to_owned()) };
449+ let Some(charge_id) = dispute["charge"].as_str() else { return Ok("ignored: no charge".to_owned()) };
450+ let charge: Value = stripe.get(&format!("/charges/{charge_id}")).await?;
451+ let Some(workspace) = (match charge["customer"].as_str() {
452+ Some(customer) => self.workspace_of_customer(customer).await?,
453+ None => None,
454+ }) else {
455+ return Ok("ignored: not a workspace's customer".to_owned());
456+ };
457+ let now = rfc3339(now_ms());
458+ if stop {
459+ self.db
460+ .prepare(
461+ "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
462+ ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
463+ )
464+ .bind(&[workspace.as_str().into(), now.as_str().into(), "a payment was disputed with the card's bank".into()])?
465+ .run()
466+ .await?;
467+ } else {
468+ self.db
469+ .prepare("UPDATE limits SET autopay_failed_at = NULL, autopay_error = NULL WHERE workspace = ?")
470+ .bind(&[workspace.as_str().into()])?
471+ .run()
472+ .await?;
473+ }
474+ let account = self.account_of(&workspace).await?;
475+ let what = if stop { "dispute: work stopped" } else { "dispute closed in the workspace's favour" };
476+ self.audit(&account.id, "dispute", &format!("{workspace}: {what}"), "stripe").await?;
477+ Ok(format!("{workspace}: {what}"))
478+ }
479+
480+ // --- Enterprise invoices ------------------------------------------------
481+
482+ pub(crate) async fn admin_enterprise_billing(&self, a: AdminEnterpriseBillingArgs) -> Result<Outcome<BillingAccount>> {
483+ let email = a.email.trim().to_lowercase();
484+ if !email.contains('@') || email.contains(char::is_whitespace) || a.by.trim().is_empty() {
485+ return Ok(Outcome::fail(FailureCode::Invalid, "Give the email the enterprise's invoices go to."));
486+ }
487+ let Some(stripe) = &self.stripe else {
488+ return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
489+ };
490+ #[derive(Deserialize)]
491+ struct Row {
492+ name: String,
493+ kind: String,
494+ customer_id: Option<String>,
495+ }
496+ let Some(row) = self
497+ .db
498+ .prepare("SELECT name, kind, customer_id FROM billing_accounts WHERE id = ?")
499+ .bind(&[a.id.as_str().into()])?
500+ .first::<Row>(None)
501+ .await?
502+ .filter(|row| row.kind == "enterprise")
503+ else {
504+ return Ok(Outcome::fail(FailureCode::NotFound, "No such enterprise."));
505+ };
506+ #[derive(Deserialize)]
507+ struct Customer {
508+ id: String,
509+ }
510+ let fields = [
511+ ("name", row.name.clone()),
512+ ("email", email.clone()),
513+ ("metadata[g1t_enterprise]", a.id.clone()),
514+ ];
515+ let customer: Customer = match &row.customer_id {
516+ Some(id) => stripe.post(&format!("/customers/{id}"), &fields).await?,
517+ None => stripe.post("/customers", &fields).await?,
518+ };
519+ self.db
520+ .prepare("UPDATE billing_accounts SET billing_email = ?, customer_id = ? WHERE id = ?")
521+ .bind(&[email.as_str().into(), customer.id.as_str().into(), a.id.as_str().into()])?
522+ .run()
523+ .await?;
524+ self.audit(&a.id, "billing_email", &format!("Invoices go to {email}"), &a.by).await?;
525+ Ok(match self.enterprise(&a.id).await? {
526+ Some(account) => Outcome::Ok(account),
527+ None => Outcome::fail(FailureCode::NotFound, "No such enterprise."),
528+ })
529+ }
530+
531+ pub(crate) async fn admin_invoice_enterprise(&self, a: AdminInvoiceEnterpriseArgs) -> Result<Outcome<EnterpriseInvoice>> {
532+ if a.by.trim().is_empty() {
533+ return Ok(Outcome::fail(FailureCode::Invalid, "Say who is sending it."));
534+ }
535+ match self.invoice_enterprise(&a.id, "now", &a.by).await? {
536+ Ok(invoice) => Ok(Outcome::Ok(invoice)),
537+ Err(why) => Ok(Outcome::fail(FailureCode::Conflict, why)),
538+ }
539+ }
540+
541+ /// Invoices each enterprise for the month that closed. Live payments
542+ /// only; sudo can send one sooner in test mode.
543+ pub(crate) async fn invoice_enterprises(&self) -> Result<()> {
544+ if !self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live) {
545+ return Ok(());
546+ }
547+ let closing = crate::limits::previous_month(&rfc3339(now_ms())[..7]);
548+ #[derive(Deserialize)]
549+ struct Id {
550+ id: String,
551+ }
552+ let due = self
553+ .db
554+ .prepare(
555+ "SELECT id FROM billing_accounts WHERE kind = 'enterprise' AND customer_id IS NOT NULL
556+ AND terms_kind <> 'comped'
557+ AND NOT EXISTS (SELECT 1 FROM enterprise_invoices i WHERE i.account_id = billing_accounts.id AND i.period = ?)",
558+ )
559+ .bind(&[closing.as_str().into()])?
560+ .all()
561+ .await?
562+ .results::<Id>()?;
563+ for Id { id } in due {
564+ if let Err(why) = self.invoice_enterprise(&id, &closing, "month close").await? {
565+ worker::console_log!("enterprise {id} not invoiced for {closing}: {why}");
566+ }
567+ }
568+ Ok(())
569+ }
570+
571+ /// One invoice for what each of the enterprise's workspaces owes now.
572+ async fn invoice_enterprise(&self, id: &str, period: &str, by: &str) -> Result<std::result::Result<EnterpriseInvoice, String>> {
573+ let Some(stripe) = &self.stripe else { return Ok(Err("Payments are not set up.".into())) };
574+ let Some(account) = self.enterprise(id).await? else { return Ok(Err("No such enterprise.".into())) };
575+ #[derive(Deserialize)]
576+ struct Customer {
577+ customer_id: Option<String>,
578+ }
579+ let Some(customer) = self
580+ .db
581+ .prepare("SELECT customer_id FROM billing_accounts WHERE id = ?")
582+ .bind(&[id.into()])?
583+ .first::<Customer>(None)
584+ .await?
585+ .and_then(|row| row.customer_id)
586+ else {
587+ return Ok(Err("Set where the enterprise's invoices go first.".into()));
588+ };
589+ // What each workspace owes: its charges less what it has paid, and
590+ // less what is on invoices still open.
591+ let mut lines = vec![];
592+ for workspace in &account.workspaces {
593+ let balance = self.row(workspace).await?.map_or(0, |row| row.balance_micros);
594+ #[derive(Deserialize)]
595+ struct Sum {
596+ micros: Option<i64>,
597+ }
598+ let invoiced = self
599+ .db
600+ .prepare(
601+ "SELECT SUM(l.amount_micros) AS micros FROM enterprise_invoice_lines l
602+ JOIN enterprise_invoices i ON i.invoice_id = l.invoice_id
603+ WHERE l.workspace = ? AND i.status IN ('open', 'overdue')",
604+ )
605+ .bind(&[workspace.as_str().into()])?
606+ .first::<Sum>(None)
607+ .await?
608+ .and_then(|s| s.micros)
609+ .unwrap_or(0);
610+ let owed = (-balance).max(0) - invoiced;
611+ if owed >= 10_000 {
612+ lines.push(InvoiceLine { workspace: workspace.clone(), amount_micros: owed });
613+ }
614+ }
615+ if lines.is_empty() {
616+ return Ok(Err("Its workspaces owe nothing to invoice.".into()));
617+ }
618+ for line in &lines {
619+ let cents = (line.amount_micros + 9_999) / 10_000;
620+ let fields = [
621+ ("customer", customer.clone()),
622+ ("amount", cents.to_string()),
623+ ("currency", "usd".to_owned()),
624+ ("description", format!("{}: g1t usage", line.workspace)),
625+ ("metadata[workspace]", line.workspace.clone()),
626+ ];
627+ let _: Value = stripe.post("/invoiceitems", &fields).await?;
628+ }
629+ let fields = [
630+ ("customer", customer.clone()),
631+ ("collection_method", "send_invoice".to_owned()),
632+ ("days_until_due", "30".to_owned()),
633+ ("pending_invoice_items_behavior", "include".to_owned()),
634+ ("description", format!("g1t usage for the {} enterprise", account.name)),
635+ ("metadata[g1t_enterprise]", id.to_owned()),
636+ ("metadata[period]", period.to_owned()),
637+ ];
638+ #[derive(Deserialize)]
639+ struct Invoice {
640+ id: String,
641+ #[serde(default)]
642+ hosted_invoice_url: Option<String>,
643+ #[serde(default)]
644+ amount_due: i64,
645+ }
646+ let draft: Invoice = stripe.post("/invoices", &fields).await?;
647+ let _: Value = stripe.post(&format!("/invoices/{}/finalize", draft.id), &[]).await?;
648+ let sent: Invoice = stripe.post(&format!("/invoices/{}/send", draft.id), &[]).await?;
649+ let now = rfc3339(now_ms());
650+ let total = lines.iter().map(|l| l.amount_micros).sum::<i64>().max(sent.amount_due * 10_000);
651+ let mut writes = vec![self
652+ .db
653+ .prepare(
654+ "INSERT INTO enterprise_invoices (invoice_id, account_id, period, amount_micros, status, hosted_url, created_by, created_at)
655+ VALUES (?, ?, ?, ?, 'open', ?, ?, ?)",
656+ )
657+ .bind(&[
658+ sent.id.as_str().into(),
659+ id.into(),
660+ period.into(),
661+ (total as f64).into(),
662+ crate::optional(sent.hosted_invoice_url.as_deref()),
663+ by.into(),
664+ now.as_str().into(),
665+ ])?];
666+ for line in &lines {
667+ writes.push(
668+ self.db
669+ .prepare("INSERT INTO enterprise_invoice_lines (invoice_id, workspace, amount_micros) VALUES (?, ?, ?)")
670+ .bind(&[sent.id.as_str().into(), line.workspace.as_str().into(), (line.amount_micros as f64).into()])?,
671+ );
672+ }
673+ self.db.batch(writes).await?;
674+ self.audit(id, "invoice", &format!("Invoice {} for {} sent ({period})", sent.id, crate::features::dollars(total)), by)
675+ .await?;
676+ Ok(Ok(EnterpriseInvoice {
677+ invoice_id: sent.id,
678+ hosted_url: sent.hosted_invoice_url,
679+ amount_micros: total,
680+ status: "open".to_owned(),
681+ period: period.to_owned(),
682+ lines,
683+ created_at: now,
684+ }))
685+ }
686+
687+ /// An enterprise invoice paid: each workspace is credited its line, and
688+ /// any stop for the invoice is lifted.
689+ async fn enterprise_invoice_paid(&self, invoice_id: &str) -> Result<String> {
690+ let claimed = self
691+ .db
692+ .prepare(
693+ "UPDATE enterprise_invoices SET status = 'paid', paid_at = ? WHERE invoice_id = ? AND status <> 'paid'
694+ RETURNING invoice_id",
695+ )
696+ .bind(&[rfc3339(now_ms()).into(), invoice_id.into()])?
697+ .first::<Value>(None)
698+ .await?;
699+ if claimed.is_none() {
700+ return Ok("ignored: not an open enterprise invoice".to_owned());
701+ }
702+ let lines = self.invoice_lines(invoice_id).await?;
703+ for line in &lines {
704+ self.enter(
705+ &line.workspace,
706+ EntryKind::TopUp,
707+ line.amount_micros,
708+ &format!("Paid on the enterprise's invoice {invoice_id}"),
709+ &format!("inv/{invoice_id}/{}", line.workspace),
710+ None,
711+ None,
712+ None,
713+ None,
714+ )
715+ .await?;
716+ }
717+ Ok(format!("invoice {invoice_id} paid; {} workspaces credited", lines.len()))
718+ }
719+
720+ /// An enterprise invoice that went overdue stops its workspaces' work;
721+ /// one voided is simply closed.
722+ async fn enterprise_invoice_status(&self, invoice_id: &str, status: &str) -> Result<String> {
723+ let updated = self
724+ .db
725+ .prepare("UPDATE enterprise_invoices SET status = ? WHERE invoice_id = ? AND status <> 'paid' RETURNING invoice_id")
726+ .bind(&[status.into(), invoice_id.into()])?
727+ .first::<Value>(None)
728+ .await?;
729+ if updated.is_none() {
730+ return Ok("ignored: not an open enterprise invoice".to_owned());
731+ }
732+ if status == "overdue" {
733+ let now = rfc3339(now_ms());
734+ for line in self.invoice_lines(invoice_id).await? {
735+ self.db
736+ .prepare(
737+ "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
738+ ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
739+ )
740+ .bind(&[line.workspace.as_str().into(), now.as_str().into(), format!("the enterprise's invoice {invoice_id} is overdue").into()])?
741+ .run()
742+ .await?;
743+ }
744+ }
745+ Ok(format!("invoice {invoice_id} is {status}"))
746+ }
747+
748+ async fn invoice_lines(&self, invoice_id: &str) -> Result<Vec<InvoiceLine>> {
749+ Ok(self
750+ .db
751+ .prepare("SELECT workspace, amount_micros FROM enterprise_invoice_lines WHERE invoice_id = ?")
752+ .bind(&[invoice_id.into()])?
753+ .all()
754+ .await?
755+ .results::<LineRow>()?
756+ .into_iter()
757+ .map(|row| InvoiceLine { workspace: row.workspace, amount_micros: row.amount_micros })
758+ .collect())
759+ }
760+
761+ /// An enterprise's invoices, newest first.
762+ pub(crate) async fn enterprise_invoices(&self, id: &str) -> Result<Vec<EnterpriseInvoice>> {
763+ let rows = self
764+ .db
765+ .prepare("SELECT * FROM enterprise_invoices WHERE account_id = ? ORDER BY created_at DESC LIMIT 24")
766+ .bind(&[JsValue::from(id)])?
767+ .all()
768+ .await?
769+ .results::<InvoiceRow>()?;
770+ let mut invoices = vec![];
771+ for row in rows {
772+ invoices.push(EnterpriseInvoice {
773+ lines: self.invoice_lines(&row.invoice_id).await?,
774+ invoice_id: row.invoice_id,
775+ hosted_url: row.hosted_url,
776+ amount_micros: row.amount_micros,
777+ status: row.status,
778+ period: row.period,
779+ created_at: row.created_at,
780+ });
781+ }
782+ Ok(invoices)
783+ }
784+}
785+
786+#[cfg(test)]
787+mod tests {
788+ use super::*;
789+
790+ fn sign(payload: &str, secret: &str, t: i64) -> String {
791+ let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
792+ mac.update(format!("{t}.{payload}").as_bytes());
793+ format!("t={t},v1={}", hex::encode(mac.finalize().into_bytes()))
794+ }
795+
796+ #[test]
797+ fn a_signed_event_is_believed_only_as_signed_and_only_fresh() {
798+ let payload = r#"{"id":"evt_1","type":"invoice.paid"}"#;
799+ let header = sign(payload, "whsec_test", 1_000_000);
800+ assert!(verify(payload, &header, "whsec_test", 1_000_010));
801+ assert!(!verify(payload, &header, "whsec_other", 1_000_010));
802+ assert!(!verify(&payload.replace("paid", "voided"), &header, "whsec_test", 1_000_010));
803+ assert!(!verify(payload, &header, "whsec_test", 1_000_000 + 301));
804+ assert!(!verify(payload, "v1=abc", "whsec_test", 1_000_000));
805+ // Stripe may sign with more than one secret while one is rolled.
806+ let both = format!("{},v1=00ff", sign(payload, "whsec_test", 1_000_000));
807+ assert!(verify(payload, &both, "whsec_test", 1_000_000));
808+ }
809+}