g1t/apps/sudo/app/lib/ledgers.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| sudo: Invoices, Audit log, and follow-ups due | 1 | /** |
| 2 | * The Invoices and Audit log pages' arithmetic and wording: filters read | |
| 3 | * from the address, totals, links from billing's account ids to sudo's | |
| 4 | * pages, and paging. No Workers imports, so it can be tested under Node. | |
| 5 | */ | |
| 6 | import type { AdminAction, InvoiceSummary } from "@g1t/contracts"; | |
| 7 | ||
| 8 | // --- Invoices ----------------------------------------------------------------- | |
| 9 | ||
| 10 | export const INVOICE_STATUSES = ["open", "overdue", "failed", "paid", "void"] as const; | |
| 11 | export type InvoiceStatusFilter = (typeof INVOICE_STATUSES)[number]; | |
| 12 | ||
| 13 | export function parseInvoiceStatus(value: string | null): InvoiceStatusFilter | null { | |
| 14 | return INVOICE_STATUSES.find((status) => status === value) ?? null; | |
| 15 | } | |
| 16 | ||
| 17 | /** A month as `<input type="month">` sends it: `2026-10`. */ | |
| 18 | export function parseMonth(value: string | null): string | null { | |
| 19 | if (!value || !/^\d{4}-(0[1-9]|1[0-2])$/.test(value)) return null; | |
| 20 | return value; | |
| 21 | } | |
| 22 | ||
| 23 | /** Not paid and not void: what is still owed. */ | |
| 24 | export function isOutstanding(status: string): boolean { | |
| 25 | return status === "open" || status === "overdue" || status === "failed"; | |
| 26 | } | |
| 27 | ||
| 28 | export type InvoiceTotals = { count: number; amountMicros: number; paidMicros: number; outstandingMicros: number; outstanding: number }; | |
| 29 | ||
| 30 | export function invoiceTotals(invoices: Pick<InvoiceSummary, "amountMicros" | "status">[]): InvoiceTotals { | |
| 31 | const totals: InvoiceTotals = { count: invoices.length, amountMicros: 0, paidMicros: 0, outstandingMicros: 0, outstanding: 0 }; | |
| 32 | for (const invoice of invoices) { | |
| 33 | if (invoice.status === "void") continue; | |
| 34 | totals.amountMicros += invoice.amountMicros; | |
| 35 | if (invoice.status === "paid") totals.paidMicros += invoice.amountMicros; | |
| 36 | if (isOutstanding(invoice.status)) { | |
| 37 | totals.outstandingMicros += invoice.amountMicros; | |
| 38 | totals.outstanding += 1; | |
| 39 | } | |
| 40 | } | |
| 41 | return totals; | |
| 42 | } | |
| 43 | ||
| 44 | /** A link to the invoices list with these filters; the defaults are left out. */ | |
| 45 | export function invoicesHref({ status, month }: { status?: string | null; month?: string | null }): string { | |
| 46 | const params = new URLSearchParams(); | |
| 47 | if (status) params.set("status", status); | |
| 48 | if (month) params.set("month", month); | |
| 49 | const query = params.toString(); | |
| 50 | return query ? `/invoices?${query}` : "/invoices"; | |
| 51 | } | |
| 52 | ||
| 53 | /** Only https links to Stripe are followed. */ | |
| 54 | export function safeUrl(url: string | null | undefined): string | null { | |
| 55 | return url && url.startsWith("https://") ? url : null; | |
| 56 | } | |
| 57 | ||
| 58 | // --- Accounts ------------------------------------------------------------------- | |
| 59 | ||
| 60 | const SLUG = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/; | |
| 61 | const ENTERPRISE = /^ent_[a-z0-9_-]{1,80}$/; | |
| 62 | ||
| 63 | /** | |
| 64 | * The sudo page for one of billing's account ids: `ws_<slug>` is the | |
| 65 | * workspace's, `ent_…` the enterprise's. Null for anything else, such as | |
| 66 | * Stripe's own lines. | |
| 67 | */ | |
| 68 | export function accountPath(account: string | null | undefined): string | null { | |
| 69 | const id = (account ?? "").trim().toLowerCase(); | |
| 70 | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; | |
| 71 | if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`; | |
| 72 | return null; | |
| 73 | } | |
| 74 | ||
| 75 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ | |
| 76 | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 77 | if (names.has(account)) return names.get(account) as string; | |
| 78 | if (account.startsWith("ws_")) return account.slice(3); | |
| 79 | if (ENTERPRISE.test(account)) return "an enterprise"; | |
| 80 | return account; | |
| 81 | } | |
| 82 | ||
| 83 | // --- Audit ---------------------------------------------------------------------- | |
| 84 | ||
| 85 | /** Each kind of change, as staff read it. */ | |
| 86 | export const AUDIT_ACTIONS: Record<string, string> = { | |
| 87 | terms: "Terms changed", | |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 88 | allowances: "Plan and pools changed", |
| sudo: Invoices, Audit log, and follow-ups due | 89 | create: "Enterprise created", |
| 90 | attach: "Workspace added", | |
| 91 | detach: "Workspace removed", | |
| 92 | credit: "Credit issued", | |
| 93 | billing_link: "Billing link made", | |
| 94 | billing_email: "Invoice email set", | |
| 95 | invoice: "Invoice sent", | |
| 96 | dispute: "Payment disputed", | |
| 97 | sales: "Sales record changed", | |
| 98 | note: "Sales note added", | |
| 99 | stripe: "From Stripe", | |
| 100 | webhook: "Stripe webhook registered", | |
| 101 | }; | |
| 102 | ||
| 103 | export function actionLabel(action: string): string { | |
| 104 | return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); | |
| 105 | } | |
| 106 | ||
| 107 | export const AUDIT_PAGE = 100; | |
| 108 | ||
| 109 | /** Where the next, older page starts: after the last line, if this page was full. */ | |
| 110 | export function olderBefore(actions: Pick<AdminAction, "createdAt">[], page = AUDIT_PAGE): string | null { | |
| 111 | return actions.length >= page ? (actions.at(-1)?.createdAt ?? null) : null; | |
| 112 | } | |
| 113 | ||
| 114 | /** A staff email filter: lowercased, at most one address's length. */ | |
| 115 | export function parseBy(value: string | null): string | null { | |
| 116 | const by = (value ?? "").trim().toLowerCase(); | |
| 117 | return by && by.length <= 254 && !/\s/.test(by) ? by : null; | |
| 118 | } | |
| 119 | ||
| 120 | export function parseAction(value: string | null): string | null { | |
| 121 | const action = (value ?? "").trim(); | |
| 122 | return /^[a-z_]{1,40}$/.test(action) ? action : null; | |
| 123 | } | |
| 124 | ||
| 125 | /** An RFC 3339 time to page from. */ | |
| 126 | export function parseBefore(value: string | null): string | null { | |
| 127 | if (!value || value.length > 40 || Number.isNaN(new Date(value).getTime())) return null; | |
| 128 | return value; | |
| 129 | } | |
| 130 | ||
| 131 | export function auditHref({ by, action, before }: { by?: string | null; action?: string | null; before?: string | null }): string { | |
| 132 | const params = new URLSearchParams(); | |
| 133 | if (by) params.set("by", by); | |
| 134 | if (action) params.set("action", action); | |
| 135 | if (before) params.set("before", before); | |
| 136 | const query = params.toString(); | |
| 137 | return query ? `/audit?${query}` : "/audit"; | |
| 138 | } |