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.
| 913 | 913 | "g1t-kit", | |
| 914 | 914 | "getrandom 0.2.17", | |
| 915 | 915 | "hex", | |
| 916 | + | "hmac 0.12.1", | |
| 916 | 917 | "serde", | |
| 917 | 918 | "serde_json", | |
| 918 | 919 | "sha2 0.10.9", |
| 147 | 147 | Ok(Response::from_json(&json!({ "message": received.message }))?.with_status(received.status)) | |
| 148 | 148 | } | |
| 149 | 149 | ||
| 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 | + | ||
| 150 | 172 | async fn device_code(request: &mut Request, services: &Services) -> Result<Response> { | |
| 151 | 173 | let body = json_body(request).await; | |
| 152 | 174 | let started: DeviceStart = g1t_kit::call( | |
| 339 | 361 | let on_mcp = url.host_str().is_some_and(|host| host.starts_with("mcp.")); | |
| 340 | 362 | let mut services = Services::new(env)?; | |
| 341 | 363 | ||
| 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 | + | ||
| 342 | 371 | // Outside systems reporting to a connection. They sign what they send | |
| 343 | 372 | // with the connection's own secret, which is not a g1t token, so this | |
| 344 | 373 | // comes before anything that would read one. |
| 197 | 197 | - **Enterprise**: one billing account paying for several workspaces, as | |
| 198 | 198 | GitHub Enterprise does. Their usage and payments count together, against | |
| 199 | 199 | 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. | |
| 201 | 205 | - **Comped**: g1t covers the account's usage. Usage is still recorded with | |
| 202 | 206 | what it cost, so the Usage page stays accurate, and paid features are on | |
| 203 | 207 | without a plan. |
| 3 | 3 | g1t's staff console, at <https://sudo.g1t.sh>. It is organised the way | |
| 4 | 4 | customers know g1t: by **workspace**. | |
| 5 | 5 | ||
| 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 | |
| 11 | 14 | **Billing** its terms, who it is billed to (move it onto or off an | |
| 12 | 15 | enterprise), a credit form, a Stripe billing link, its ledger and its | |
| 13 | 16 | audit log. | |
| 14 | 17 | - **Enterprises**: customers that pay for several workspaces with one | |
| 15 | 18 | 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. | |
| 17 | 32 | ||
| 18 | 33 | Billing's internal account ids (`ws_<slug>` for a workspace's own, | |
| 19 | 34 | `ent_…` for an enterprise) are never shown as names; an enterprise's id | |
| 45 | 60 | configured. | |
| 46 | 61 | 4. **Changes** are POSTs only, and only from sudo's own pages (`Origin`, or | |
| 47 | 62 | `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. | |
| 50 | 66 | 5. **The pages ship no JavaScript.** The content security policy forbids | |
| 51 | 67 | every script and inline style; responses are `no-store`, `noindex` and | |
| 52 | 68 | cannot be framed. The worker has no `workers.dev` address or preview URLs. | |
| 95 | 111 | ||
| 96 | 112 | ```sh | |
| 97 | 113 | 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 | |
| 99 | 115 | npm run build -w @g1t/sudo | |
| 100 | 116 | ``` | |
| 101 | 117 |
| 73 | 73 | ]; | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 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 | + | ||
| 76 | 79 | export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) { | |
| 77 | 80 | let title: string; | |
| 78 | 81 | let body: ReactNode; | |
| 130 | 133 | its trust gives it. It may stop at once if its own limit is lower than what it owes. | |
| 131 | 134 | </p> | |
| 132 | 135 | ); | |
| 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 | + | ); | |
| 133 | 163 | } else { | |
| 134 | 164 | title = `Make a Stripe billing link for ${review.workspace}?`; | |
| 135 | 165 | confirm = "Make the link"; | |
| 145 | 175 | <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"}`}> | |
| 146 | 176 | <h2 className="font-semibold tracking-tight">{title}</h2> | |
| 147 | 177 | <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"> | |
| 149 | 179 | <Hidden values={review.fields} /> | |
| 150 | 180 | <input type="hidden" name="intent" value={review.intent} /> | |
| 151 | 181 | <input type="hidden" name="confirm" value="yes" /> | |
| 415 | 445 | detach: "Workspace removed", | |
| 416 | 446 | credit: "Credit issued", | |
| 417 | 447 | 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", | |
| 418 | 453 | }; | |
| 419 | 454 | ||
| 420 | 455 | export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) { |
| 8 | 8 | import type { Terms } from "@g1t/contracts"; | |
| 9 | 9 | import { data, redirect } from "react-router"; | |
| 10 | 10 | ||
| 11 | − | import { fields, parseCredit, parseNote, parseSlug, parseTerms, text } from "./forms"; | |
| 11 | + | import { fields, parseCredit, parseEmail, parseNote, parseSlug, parseTerms, text } from "./forms"; | |
| 12 | 12 | import type { ActionData } from "./review"; | |
| 13 | 13 | import { admin, identity } from "./services.server"; | |
| 14 | 14 | import type { Staff } from "./staff"; | |
| 17 | 17 | /** What a page acts on. `accountId` is billing's internal id, never shown. */ | |
| 18 | 18 | export type Subject = | |
| 19 | 19 | | { 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 }; | |
| 21 | 21 | ||
| 22 | 22 | function failed(section: string, error: string, values?: Record<string, string>) { | |
| 23 | 23 | return data<ActionData>({ error, section, values }, { status: 422 }); | |
| 114 | 114 | return { link: result.value, workspace: subject.slug } satisfies ActionData; | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 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 | + | ||
| 117 | 146 | return failed("top", "Unknown action."); | |
| 118 | 147 | } |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { parseCredit, parseSlugList, parseTerms } from "./forms.ts"; | |
| 4 | + | import { parseCredit, parseEmail, parseSlugList, parseTerms } from "./forms.ts"; | |
| 5 | 5 | ||
| 6 | 6 | const NOW = new Date("2026-10-04T12:00:00Z"); | |
| 7 | 7 | ||
| 49 | 49 | assert.equal(parseSlugList("../etc").ok, false); | |
| 50 | 50 | }); | |
| 51 | 51 | ||
| 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 | + | ||
| 52 | 60 | test("credits are positive and capped", () => { | |
| 53 | 61 | assert.deepEqual(parseCredit("25.50"), { ok: true, value: 25_500_000 }); | |
| 54 | 62 | assert.equal(parseCredit("0").ok, false); |
| 109 | 109 | }; | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 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 | + | ||
| 112 | 126 | /** A credit's amount: more than nothing, and no more than the cap. */ | |
| 113 | 127 | export function parseCredit(raw: string): Parsed<number> { | |
| 114 | 128 | const micros = parseDollars(raw); |
| 1 | 1 | /** | |
| 2 | 2 | * 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. | |
| 5 | 5 | */ | |
| 6 | − | import type { BillingLink, Terms } from "@g1t/contracts"; | |
| 6 | + | import type { BillingLink, EnterpriseInvoice, Terms } from "@g1t/contracts"; | |
| 7 | 7 | ||
| 8 | 8 | export type Review = | |
| 9 | 9 | | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> } | |
| 10 | 10 | | { intent: "attach"; workspace: string; targetName: string; fields: Record<string, string> } | |
| 11 | 11 | | { 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> }; | |
| 13 | 15 | ||
| 14 | 16 | export type ActionData = | |
| 15 | 17 | | { error: string; section: string; values?: Record<string, string> } | |
| 16 | 18 | | { review: Review } | |
| 17 | − | | { link: BillingLink; workspace: string }; | |
| 19 | + | | { link: BillingLink; workspace: string } | |
| 20 | + | | { invoice: EnterpriseInvoice }; | |
| 18 | 21 | ||
| 19 | 22 | export type SectionError = { error: string; values?: Record<string, string> } | null; | |
| 20 | 23 | ||
| 25 | 28 | detach: "Workspace moved off the enterprise. It pays for itself again.", | |
| 26 | 29 | credit: "Credit issued.", | |
| 27 | 30 | created: "Enterprise created.", | |
| 31 | + | "billing-email": "Saved where the enterprise's invoices go.", | |
| 28 | 32 | }; | |
| 29 | 33 | ||
| 30 | 34 | export function doneMessage(url: string): string | null { |
| 3 | 3 | ||
| 4 | 4 | import type { AccountSummary, AdminWorkspace, Limit, Terms } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { STANDARD_TERMS, joinWorkspaces, legacyAccountPath, matchesQuery } from "./workspaces.ts"; | |
| 6 | + | import { STANDARD_TERMS, joinWorkspaces, legacyAccountPath, paginate } from "./workspaces.ts"; | |
| 7 | 7 | ||
| 8 | 8 | function limit(workspace: string, account: string, exposureMicros = 0): Limit { | |
| 9 | 9 | return { | |
| 77 | 77 | assert.equal(rows.find((row) => row.slug === "acme")?.billing.chargedMicros, 12_000_000); | |
| 78 | 78 | }); | |
| 79 | 79 | ||
| 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]); | |
| 82 | 82 | 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"], | |
| 88 | 85 | ); | |
| 89 | 86 | }); | |
| 90 | 87 | ||
| 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: [] }); | |
| 98 | 99 | }); | |
| 99 | 100 | ||
| 100 | 101 | test("old account links go to the workspace or the enterprise", () => { |
| 43 | 43 | export type WorkspaceRow = { | |
| 44 | 44 | slug: string; | |
| 45 | 45 | name: string; | |
| 46 | − | /** Null for a workspace billing knows and identity returned nothing for. */ | |
| 47 | − | createdAt: string | null; | |
| 46 | + | createdAt: string; | |
| 48 | 47 | owners: AdminOwner[]; | |
| 49 | − | memberCount: number | null; | |
| 50 | − | /** False when only billing knows the slug. */ | |
| 51 | − | known: boolean; | |
| 48 | + | memberCount: number; | |
| 52 | 49 | billing: WorkspaceBilling; | |
| 53 | 50 | }; | |
| 54 | 51 | ||
| 93 | 90 | } | |
| 94 | 91 | ||
| 95 | 92 | /** | |
| 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. | |
| 99 | 97 | */ | |
| 100 | 98 | export function joinWorkspaces(workspaces: AdminWorkspace[], accounts: AccountSummary[]): WorkspaceRow[] { | |
| 101 | 99 | const billing = billingBySlug(accounts); | |
| 102 | − | const rows: WorkspaceRow[] = workspaces.map((workspace) => ({ | |
| 100 | + | return workspaces.map((workspace) => ({ | |
| 103 | 101 | slug: workspace.slug, | |
| 104 | 102 | name: workspace.name, | |
| 105 | 103 | createdAt: workspace.createdAt, | |
| 106 | 104 | owners: workspace.owners, | |
| 107 | 105 | memberCount: workspace.memberCount, | |
| 108 | − | known: true, | |
| 109 | 106 | billing: billing.get(workspace.slug) ?? noBilling(), | |
| 110 | 107 | })); | |
| 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; | |
| 117 | 108 | } | |
| 118 | 109 | ||
| 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) }; | |
| 130 | 118 | } | |
| 131 | 119 | ||
| 132 | 120 | /** |
| 1 | − | import { Building2, Boxes, ShieldCheck } from "lucide-react"; | |
| 1 | + | import { Boxes, Building2, CreditCard, ShieldCheck } from "lucide-react"; | |
| 2 | 2 | import { isRouteErrorResponse, Link, Links, Meta, Outlet, useLocation, useRouteLoaderData } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/root"; | |
| 69 | 69 | <Building2 size={14} className="hidden sm:block" /> | |
| 70 | 70 | Enterprises | |
| 71 | 71 | </NavItem> | |
| 72 | + | <NavItem to="/stripe" active={(path) => path.startsWith("/stripe")}> | |
| 73 | + | <CreditCard size={14} className="hidden sm:block" /> | |
| 74 | + | Stripe | |
| 75 | + | </NavItem> | |
| 72 | 76 | </nav> | |
| 73 | 77 | {root?.email && ( | |
| 74 | 78 | <span |
| 7 | 7 | route("enterprises", "routes/enterprises.tsx"), | |
| 8 | 8 | route("enterprises/new", "routes/new-enterprise.tsx"), | |
| 9 | 9 | route("enterprises/:id", "routes/enterprise.tsx"), | |
| 10 | + | route("stripe", "routes/stripe.tsx"), | |
| 10 | 11 | // Before sudo was organised around workspaces, everything was an account. | |
| 11 | 12 | route("accounts/*", "routes/legacy-accounts.tsx"), | |
| 12 | 13 | ] satisfies RouteConfig; |
| 1 | − | import { ArrowLeft, Plus, Trash2 } from "lucide-react"; | |
| 1 | + | import { ArrowLeft, ExternalLink, Mail, Plus, Send, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { data, Link, redirect, useLocation } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { type AdminOwner, type Limit, httpStatus } from "@g1t/contracts"; | |
| 4 | + | import { type AdminOwner, type EnterpriseInvoice, type Limit, httpStatus } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/enterprise"; | |
| 7 | 7 | import { AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing"; | |
| 33 | 33 | const detail = result.value; | |
| 34 | 34 | const { account } = detail.summary; | |
| 35 | 35 | 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 | + | }; | |
| 37 | 44 | return { detail, subject }; | |
| 38 | 45 | } | |
| 39 | 46 | ||
| 81 | 88 | const { pathname } = useLocation(); | |
| 82 | 89 | const result = actionData as ActionData | undefined; | |
| 83 | 90 | const review = result && "review" in result ? result.review : null; | |
| 91 | + | const sent = result && "invoice" in result ? result.invoice : null; | |
| 84 | 92 | const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null); | |
| 85 | 93 | ||
| 86 | 94 | return ( | |
| 134 | 142 | <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 135 | 143 | <div className="space-y-6"> | |
| 136 | 144 | <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 | + | /> | |
| 137 | 152 | <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} /> | |
| 138 | 153 | <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." /> | |
| 139 | 154 | </div> | |
| 233 | 248 | </form> | |
| 234 | 249 | ); | |
| 235 | 250 | } | |
| 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 | + | } |
| 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 | + | } |
| 1 | − | import { ChevronRight, Search } from "lucide-react"; | |
| 1 | + | import { ChevronLeft, ChevronRight, Search } from "lucide-react"; | |
| 2 | 2 | import { Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { ADMIN_WORKSPACES_LIMIT } from "@g1t/contracts"; | |
| 9 | 9 | import { usd } from "~/lib/money"; | |
| 10 | 10 | import { admin, identity } from "~/lib/services.server"; | |
| 11 | 11 | 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"; | |
| 13 | 13 | ||
| 14 | 14 | export const meta: Route.MetaFunction = () => [{ title: "Workspaces · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 15 | 15 | ||
| 21 | 21 | ||
| 22 | 22 | type Filter = keyof typeof FILTERS; | |
| 23 | 23 | ||
| 24 | − | const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const; | |
| 25 | − | ||
| 26 | 24 | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 27 | 25 | requireStaff(context); | |
| 28 | 26 | const url = new URL(request.url); | |
| 29 | 27 | const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100); | |
| 30 | 28 | const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS); | |
| 31 | 29 | ||
| 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 | + | } | |
| 48 | 46 | } | |
| 49 | 47 | ||
| 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))); | |
| 58 | 53 | ||
| 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); | |
| 61 | 56 | return { | |
| 62 | 57 | q, | |
| 63 | 58 | show, | |
| 64 | 59 | 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, | |
| 67 | 65 | 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, | |
| 75 | 71 | }, | |
| 76 | 72 | }; | |
| 77 | 73 | } | |
| 78 | 74 | ||
| 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 | + | ||
| 79 | 85 | function workspaceHref(row: WorkspaceRow) { | |
| 80 | 86 | return `/workspaces/${encodeURIComponent(row.slug)}`; | |
| 81 | 87 | } | |
| 82 | 88 | ||
| 83 | 89 | 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; | |
| 85 | 92 | const margin = totals.charged - totals.cost; | |
| 86 | 93 | const filtered = q !== "" || show.length > 0; | |
| 87 | 94 | ||
| 93 | 100 | </div> | |
| 94 | 101 | ||
| 95 | 102 | <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)}`} /> | |
| 99 | 103 | <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"} | |
| 101 | 112 | value={`${totals.stopped} stopped`} | |
| 102 | 113 | hint={`${totals.warning} near the limit`} | |
| 103 | 114 | tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"} | |
| 141 | 152 | </form> | |
| 142 | 153 | ||
| 143 | 154 | <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"} | |
| 145 | 156 | {q && ( | |
| 146 | 157 | <> | |
| 147 | 158 | {" "} | |
| 148 | 159 | matching <span className="font-mono text-muted">{q}</span> | |
| 149 | 160 | </> | |
| 150 | 161 | )} | |
| 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.`} | |
| 152 | 164 | {capped && ` Only the newest ${ADMIN_WORKSPACES_LIMIT} are listed; search to find older ones.`} | |
| 153 | 165 | </p> | |
| 154 | 166 | ||
| 155 | 167 | {rows.length === 0 ? ( | |
| 156 | 168 | <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."} | |
| 159 | 175 | </EmptyState> | |
| 160 | 176 | </div> | |
| 161 | 177 | ) : ( | |
| 222 | 238 | </div> | |
| 223 | 239 | </> | |
| 224 | 240 | )} | |
| 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 | + | )} | |
| 225 | 265 | </main> | |
| 226 | 266 | ); | |
| 227 | 267 | } | |
| 247 | 287 | {billing.billedTo && <Badge tone="lavender">Billed to {billing.billedTo.name}</Badge>} | |
| 248 | 288 | <TermsBadge terms={billing.terms} /> | |
| 249 | 289 | {billing.limit && billing.terms.kind !== "comped" && <TrustBadge trust={billing.limit.trust} />} | |
| 250 | − | {!row.known && <Badge tone="warn">Billing only</Badge>} | |
| 251 | 290 | </div> | |
| 252 | 291 | </div> | |
| 253 | 292 | </div> |
| 550 | 550 | pub terms: Terms, | |
| 551 | 551 | /// The workspaces it pays for. | |
| 552 | 552 | 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>, | |
| 553 | 559 | pub created_at: String, | |
| 554 | 560 | } | |
| 555 | 561 | ||
| 619 | 625 | Custom, | |
| 620 | 626 | } | |
| 621 | 627 | ||
| 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 | + | ||
| 622 | 720 | // --- Staff (sudo.g1t.sh) ------------------------------------------------------ | |
| 623 | 721 | // | |
| 624 | 722 | // Called only by the sudo app, which only g1t staff can reach (behind | |
| 631 | 729 | pub struct AdminAccountsArgs { | |
| 632 | 730 | #[serde(default)] | |
| 633 | 731 | 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>>, | |
| 634 | 736 | } | |
| 635 | 737 | ||
| 636 | 738 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 97 | 97 | name: string; | |
| 98 | 98 | terms: Terms; | |
| 99 | 99 | workspaces: string[]; | |
| 100 | + | /** Where an enterprise's invoices go. */ | |
| 101 | + | billingEmail?: string | null; | |
| 102 | + | /** An enterprise's invoices, newest first. */ | |
| 103 | + | invoices?: EnterpriseInvoice[]; | |
| 100 | 104 | createdAt: string; | |
| 101 | 105 | }; | |
| 102 | 106 | ||
| 113 | 117 | /** One workspace's share of an `AccountSummary`. */ | |
| 114 | 118 | export type WorkspaceFigures = { workspace: string; chargedMicros: number; costMicros: number; paidMicros: number }; | |
| 115 | 119 | ||
| 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 | + | ||
| 116 | 139 | /** A customer's Stripe billing page, for staff to send them. */ | |
| 117 | 140 | export type BillingLink = { | |
| 118 | 141 | /** One-time and short-lived, signed in already. */ | |
| 142 | 165 | credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>; | |
| 143 | 166 | /** The workspace's Stripe billing page, to send to the customer. Logged. */ | |
| 144 | 167 | 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[]>; | |
| 145 | 176 | } | |
| 146 | 177 | ||
| 147 | 178 | /** How much a workspace has earned g1t's trust with money. */ |
| 229 | 229 | attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }), | |
| 230 | 230 | credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }), | |
| 231 | 231 | 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 }), | |
| 232 | 236 | }; | |
| 233 | 237 | } | |
| 234 | 238 |
| 17 | 17 | getrandom = { version = "0.2", features = ["js"] } | |
| 18 | 18 | hex = "0.4" | |
| 19 | 19 | sha2 = "0.10" | |
| 20 | + | hmac = "0.12" |
| 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 | + | ); |
| 41 | 41 | terms_set_by: Option<String>, | |
| 42 | 42 | terms_set_at: Option<String>, | |
| 43 | 43 | created_at: String, | |
| 44 | + | #[serde(default)] | |
| 45 | + | billing_email: Option<String>, | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | impl AccountRow { | |
| 147 | 149 | terms: row.terms(), | |
| 148 | 150 | workspaces, | |
| 149 | 151 | created_at: row.created_at.clone(), | |
| 152 | + | billing_email: row.billing_email.clone(), | |
| 153 | + | invoices: vec![], | |
| 150 | 154 | } | |
| 151 | 155 | } | |
| 152 | 156 | ||
| 179 | 183 | terms: Terms::standard(), | |
| 180 | 184 | workspaces: vec![workspace], | |
| 181 | 185 | created_at: String::new(), | |
| 186 | + | billing_email: None, | |
| 187 | + | invoices: vec![], | |
| 182 | 188 | }, | |
| 183 | 189 | }) | |
| 184 | 190 | } | |
| 188 | 194 | Ok(self.account_of(workspace).await?.terms) | |
| 189 | 195 | } | |
| 190 | 196 | ||
| 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 | + | ||
| 191 | 208 | /// An account by id, or the account of a workspace by its slug. | |
| 192 | 209 | async fn find_account(&self, id: &str) -> Result<Option<BillingAccount>> { | |
| 193 | 210 | let id = id.trim().to_lowercase(); | |
| 194 | 211 | 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; | |
| 202 | 213 | } | |
| 203 | 214 | let slug = id.strip_prefix("ws_").unwrap_or(&id); | |
| 204 | 215 | if slug.is_empty() { | |
| 207 | 218 | Ok(Some(self.account_of(slug).await?)) | |
| 208 | 219 | } | |
| 209 | 220 | ||
| 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<()> { | |
| 211 | 222 | let now = now_ms(); | |
| 212 | 223 | self.db | |
| 213 | 224 | .prepare("INSERT INTO admin_actions (id, account, action, detail, by, created_at) VALUES (?, ?, ?, ?, ?, ?)") | |
| 294 | 305 | // --- Staff ------------------------------------------------------------ | |
| 295 | 306 | ||
| 296 | 307 | 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 | + | } | |
| 297 | 320 | // Every workspace that has used or paid for anything, and every | |
| 298 | 321 | // account with terms of its own. | |
| 299 | 322 | #[derive(Deserialize)] |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | /// Writes down what the processor says about a plan. | |
| 135 | − | async fn record( | |
| 135 | + | pub(crate) async fn record( | |
| 136 | 136 | &self, | |
| 137 | 137 | workspace: &str, | |
| 138 | 138 | feature: Feature, |
| 17 | 17 | //! the methods and their arguments. | |
| 18 | 18 | ||
| 19 | 19 | mod accounts; | |
| 20 | + | mod webhooks; | |
| 20 | 21 | mod features; | |
| 21 | 22 | mod keeper; | |
| 22 | 23 | mod limits; | |
| 963 | 964 | if let Err(error) = billing.close_months().await { | |
| 964 | 965 | worker::console_error!("closing the month failed: {error}"); | |
| 965 | 966 | } | |
| 967 | + | if let Err(error) = billing.invoice_enterprises().await { | |
| 968 | + | worker::console_error!("invoicing enterprises failed: {error}"); | |
| 969 | + | } | |
| 966 | 970 | if let Ok(identity) = env.service("IDENTITY") { | |
| 967 | 971 | if let Err(error) = billing.warn_limits(&identity).await { | |
| 968 | 972 | worker::console_error!("warning owners failed: {error}"); | |
| 1008 | 1012 | "prices" => reply(&billing.prices().await?), | |
| 1009 | 1013 | "billing_portal" => reply(&billing.billing_portal(args(body)?).await?), | |
| 1010 | 1014 | "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?), | |
| 1011 | 1019 | "note_pending" => reply(&billing.note_pending(args(body)?).await?), | |
| 1012 | 1020 | "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?), | |
| 1013 | 1021 | "admin_account" => reply(&billing.admin_account(args(body)?).await?), |
| 132 | 132 | is_live(&self.key) | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 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 | + | ||
| 135 | 149 | async fn call<T: for<'a> Deserialize<'a>>( | |
| 136 | 150 | &self, | |
| 137 | 151 | method: Method, |
| 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 | + | } |