g1t/apps/sudo/app/lib/ledgers.ts
| 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", |
| 88 | allowances: "Plan, pools and caps changed", |
| 89 | payment: "Bank transfer recorded", |
| 90 | goodwill: "Goodwill credit", |
| 91 | request: "Request answered", |
| 92 | create: "Enterprise created", |
| 93 | attach: "Workspace added", |
| 94 | detach: "Workspace removed", |
| 95 | credit: "Credit issued", |
| 96 | billing_link: "Billing link made", |
| 97 | billing_email: "Invoice email set", |
| 98 | invoice: "Invoice sent", |
| 99 | dispute: "Payment disputed", |
| 100 | sales: "Sales record changed", |
| 101 | note: "Sales note added", |
| 102 | stripe: "From Stripe", |
| 103 | webhook: "Stripe webhook registered", |
| 104 | }; |
| 105 | |
| 106 | export function actionLabel(action: string): string { |
| 107 | return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); |
| 108 | } |
| 109 | |
| 110 | export const AUDIT_PAGE = 100; |
| 111 | |
| 112 | /** Where the next, older page starts: after the last line, if this page was full. */ |
| 113 | export function olderBefore(actions: Pick<AdminAction, "createdAt">[], page = AUDIT_PAGE): string | null { |
| 114 | return actions.length >= page ? (actions.at(-1)?.createdAt ?? null) : null; |
| 115 | } |
| 116 | |
| 117 | /** A staff email filter: lowercased, at most one address's length. */ |
| 118 | export function parseBy(value: string | null): string | null { |
| 119 | const by = (value ?? "").trim().toLowerCase(); |
| 120 | return by && by.length <= 254 && !/\s/.test(by) ? by : null; |
| 121 | } |
| 122 | |
| 123 | export function parseAction(value: string | null): string | null { |
| 124 | const action = (value ?? "").trim(); |
| 125 | return /^[a-z_]{1,40}$/.test(action) ? action : null; |
| 126 | } |
| 127 | |
| 128 | /** An RFC 3339 time to page from. */ |
| 129 | export function parseBefore(value: string | null): string | null { |
| 130 | if (!value || value.length > 40 || Number.isNaN(new Date(value).getTime())) return null; |
| 131 | return value; |
| 132 | } |
| 133 | |
| 134 | export function auditHref({ by, action, before }: { by?: string | null; action?: string | null; before?: string | null }): string { |
| 135 | const params = new URLSearchParams(); |
| 136 | if (by) params.set("by", by); |
| 137 | if (action) params.set("action", action); |
| 138 | if (before) params.set("before", before); |
| 139 | const query = params.toString(); |
| 140 | return query ? `/audit?${query}` : "/audit"; |
| 141 | } |