Compare changes
Choose two branches to see what one has that the other does not, then open a pull request for it.
1 commit
26 files+1626−1230/26 viewed
| 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 | + | } |