| 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 | const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id); |
| 71 | if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`; |
| 72 | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; |
| 73 | if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`; |
| 74 | return null; |
| 75 | } |
| 76 | |
| 77 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ |
| 78 | export function accountName(account: string, names: Map<string, string> = new Map()): string { |
| 79 | if (names.has(account)) return names.get(account) as string; |
| 80 | if (account.startsWith("incident:")) return "Incident"; |
| 81 | if (account.startsWith("maintenance:")) return "Maintenance"; |
| 82 | if (account.startsWith("ws_")) return account.slice(3); |
| 83 | if (ENTERPRISE.test(account)) return "an enterprise"; |
| 84 | return account; |
| 85 | } |
| 86 | |
| 87 | // --- Audit ---------------------------------------------------------------------- |
| 88 | |
| 89 | /** Each kind of change, as staff read it. */ |
| 90 | export const AUDIT_ACTIONS: Record<string, string> = { |
| 91 | terms: "Terms changed", |
| 92 | allowances: "Plan, pools and caps changed", |
| 93 | payment: "Bank transfer recorded", |
| 94 | goodwill: "Goodwill credit", |
| 95 | request: "Request answered", |
| 96 | create: "Enterprise created", |
| 97 | attach: "Workspace added", |
| 98 | detach: "Workspace removed", |
| 99 | credit: "Credit issued", |
| 100 | reset: "Billing reset (testing)", |
| 101 | billing_link: "Billing link made", |
| 102 | billing_email: "Invoice email set", |
| 103 | invoice: "Invoice sent", |
| 104 | dispute: "Payment disputed", |
| 105 | sales: "Sales record changed", |
| 106 | note: "Sales note added", |
| 107 | stripe: "From Stripe", |
| 108 | webhook: "Stripe webhook registered", |
| 109 | close: "Billing closed", |
| 110 | // From identity: deleted workspaces staff restored or purged. |
| 111 | workspace_restored: "Workspace restored", |
| 112 | workspace_purged: "Workspace purged", |
| 113 | // From the status page (apps/status), merged in by the Audit log page. |
| 114 | incident_declared: "Incident declared", |
| 115 | incident_detected: "Incident detected", |
| 116 | incident_update: "Incident update", |
| 117 | incident_note: "Incident note", |
| 118 | incident_resolved: "Incident resolved", |
| 119 | incident_roles: "Incident roles", |
| 120 | incident_published: "Incident published", |
| 121 | incident_dismissed: "Draft dismissed", |
| 122 | incident_followup: "Incident follow-up", |
| 123 | postmortem_saved: "Postmortem saved", |
| 124 | postmortem_published: "Postmortem published", |
| 125 | postmortem_unpublished: "Postmortem taken down", |
| 126 | maintenance_scheduled: "Maintenance scheduled", |
| 127 | maintenance_update: "Maintenance update", |
| 128 | maintenance_started: "Maintenance started", |
| 129 | maintenance_completed: "Maintenance completed", |
| 130 | maintenance_cancelled: "Maintenance cancelled", |
| 131 | }; |
| 132 | |
| 133 | /** |
| 134 | * The status page's audit lines as the Audit log lists billing's: the |
| 135 | * account is `incident:<id>` or `maintenance:<id>`, which link to their |
| 136 | * pages in sudo. |
| 137 | */ |
| 138 | export function statusAuditActions(entries: { id: string; at: string; by: string; action: string; target: string; detail: string }[]): AdminAction[] { |
| 139 | return entries.map((e) => ({ |
| 140 | id: `status-${e.id}`, |
| 141 | account: `${e.action.startsWith("maintenance_") ? "maintenance" : "incident"}:${e.target}`, |
| 142 | action: e.action, |
| 143 | detail: e.detail, |
| 144 | by: e.by, |
| 145 | createdAt: e.at, |
| 146 | })); |
| 147 | } |
| 148 | |
| 149 | /** Billing's lines and the status page's, newest first, one page's worth. */ |
| 150 | export function mergeAudit(a: AdminAction[], b: AdminAction[], page = AUDIT_PAGE): AdminAction[] { |
| 151 | return [...a, ...b].sort((x, y) => y.createdAt.localeCompare(x.createdAt)).slice(0, page); |
| 152 | } |
| 153 | |
| 154 | export function actionLabel(action: string): string { |
| 155 | return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); |
| 156 | } |
| 157 | |
| 158 | export const AUDIT_PAGE = 100; |
| 159 | |
| 160 | /** Where the next, older page starts: after the last line, if this page was full. */ |
| 161 | export function olderBefore(actions: Pick<AdminAction, "createdAt">[], page = AUDIT_PAGE): string | null { |
| 162 | return actions.length >= page ? (actions.at(-1)?.createdAt ?? null) : null; |
| 163 | } |
| 164 | |
| 165 | /** A staff email filter: lowercased, at most one address's length. */ |
| 166 | export function parseBy(value: string | null): string | null { |
| 167 | const by = (value ?? "").trim().toLowerCase(); |
| 168 | return by && by.length <= 254 && !/\s/.test(by) ? by : null; |
| 169 | } |
| 170 | |
| 171 | export function parseAction(value: string | null): string | null { |
| 172 | const action = (value ?? "").trim(); |
| 173 | return /^[a-z_]{1,40}$/.test(action) ? action : null; |
| 174 | } |
| 175 | |
| 176 | /** An RFC 3339 time to page from. */ |
| 177 | export function parseBefore(value: string | null): string | null { |
| 178 | if (!value || value.length > 40 || Number.isNaN(new Date(value).getTime())) return null; |
| 179 | return value; |
| 180 | } |
| 181 | |
| 182 | export function auditHref({ by, action, before }: { by?: string | null; action?: string | null; before?: string | null }): string { |
| 183 | const params = new URLSearchParams(); |
| 184 | if (by) params.set("by", by); |
| 185 | if (action) params.set("action", action); |
| 186 | if (before) params.set("before", before); |
| 187 | const query = params.toString(); |
| 188 | return query ? `/audit?${query}` : "/audit"; |
| 189 | } |