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.
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 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(); | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 70 | // Shared invite links (identity's shared_invites.rs): `invites` is a reserved name, never a workspace's. |
| 71 | if (id === SHARED_INVITES_ACCOUNT) return "/invites?tab=shared"; | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 72 | const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id); |
| 73 | if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`; | |
| 74 | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; | |
| 75 | if (id.startsWith("ws_") && SLUG.test(id.slice(3))) return `/workspaces/${encodeURIComponent(id.slice(3))}`; | |
| 76 | return null; | |
| 77 | } | |
| 78 | ||
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 79 | /** Where sudo's audit log files shared invite links. */ |
| 80 | export const SHARED_INVITES_ACCOUNT = "ws_invites"; | |
| 81 | ||
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 82 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ |
| 83 | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 84 | if (names.has(account)) return names.get(account) as string; | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 85 | // The retired model catalogue, in older entries. |
| 86 | if (account === "models") return "Model catalogue (retired)"; | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 87 | if (account === SHARED_INVITES_ACCOUNT) return "Shared invite links"; |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 88 | if (account.startsWith("incident:")) return "Incident"; |
| 89 | if (account.startsWith("maintenance:")) return "Maintenance"; | |
| 90 | if (account.startsWith("ws_")) return account.slice(3); | |
| 91 | if (ENTERPRISE.test(account)) return "an enterprise"; | |
| 92 | return account; | |
| 93 | } | |
| 94 | ||
| 95 | // --- Audit ---------------------------------------------------------------------- | |
| 96 | ||
| 97 | /** Each kind of change, as staff read it. */ | |
| 98 | export const AUDIT_ACTIONS: Record<string, string> = { | |
| 99 | terms: "Terms changed", | |
| 100 | allowances: "Plan, pools and caps changed", | |
| 101 | payment: "Bank transfer recorded", | |
| 102 | goodwill: "Goodwill credit", | |
| 103 | request: "Request answered", | |
| 104 | create: "Enterprise created", | |
| 105 | attach: "Workspace added", | |
| 106 | detach: "Workspace removed", | |
| 107 | credit: "Credit issued", | |
| sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's | 108 | reset: "Billing reset (testing)", |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 109 | billing_link: "Billing link made", |
| 110 | billing_email: "Invoice email set", | |
| 111 | invoice: "Invoice sent", | |
| 112 | dispute: "Payment disputed", | |
| 113 | sales: "Sales record changed", | |
| 114 | note: "Sales note added", | |
| 115 | stripe: "From Stripe", | |
| 116 | webhook: "Stripe webhook registered", | |
| 117 | close: "Billing closed", | |
| 118 | // From identity: deleted workspaces staff restored or purged. | |
| 119 | workspace_restored: "Workspace restored", | |
| 120 | workspace_purged: "Workspace purged", | |
| Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037) | 121 | // From identity: accounts staff deleted, restored or purged. |
| 122 | account_deleted: "Account deleted", | |
| 123 | account_restored: "Account restored", | |
| 124 | account_purged: "Account purged", | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 125 | // From identity: workspace aliases staff set or removed. |
| 126 | alias_added: "Alias added", | |
| 127 | alias_removed: "Alias removed", | |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 128 | // From identity: shared invite links staff made or revoked, filed under `ws_invites`. |
| 129 | shared_invite_created: "Shared invite link made", | |
| 130 | shared_invite_revoked: "Shared invite link revoked", | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 131 | // From the status page (apps/status), merged in by the Audit log page. |
| 132 | incident_declared: "Incident declared", | |
| 133 | incident_detected: "Incident detected", | |
| 134 | incident_update: "Incident update", | |
| 135 | incident_note: "Incident note", | |
| 136 | incident_resolved: "Incident resolved", | |
| 137 | incident_roles: "Incident roles", | |
| 138 | incident_published: "Incident published", | |
| 139 | incident_dismissed: "Draft dismissed", | |
| 140 | incident_followup: "Incident follow-up", | |
| 141 | postmortem_saved: "Postmortem saved", | |
| 142 | postmortem_published: "Postmortem published", | |
| 143 | postmortem_unpublished: "Postmortem taken down", | |
| 144 | maintenance_scheduled: "Maintenance scheduled", | |
| 145 | maintenance_update: "Maintenance update", | |
| 146 | maintenance_started: "Maintenance started", | |
| 147 | maintenance_completed: "Maintenance completed", | |
| 148 | maintenance_cancelled: "Maintenance cancelled", | |
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 149 | credit_revoked: "Credit revoked", |
| 150 | credit_expired: "Credit expired", | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 151 | // The model catalogue, retired with g1t's own agents: older entries still name these. |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 152 | models_discovered: "Models found or gone", |
| 153 | model_approved: "Model approved", | |
| 154 | model_retired: "Model retired", | |
| 155 | model_restored: "Model restored", | |
| 156 | model_default: "Model default changed", | |
| Costs: a statement that keeps usage sold, running g1t, subscriptions and what was given away (comped, free use, trial, pool) apart, and says who was paid; free use carries its own cost; the run button says it is running | 157 | }; |
| 158 | ||
| 159 | /** | |
| 160 | * The status page's audit lines as the Audit log lists billing's: the | |
| 161 | * account is `incident:<id>` or `maintenance:<id>`, which link to their | |
| 162 | * pages in sudo. | |
| 163 | */ | |
| 164 | export function statusAuditActions(entries: { id: string; at: string; by: string; action: string; target: string; detail: string }[]): AdminAction[] { | |
| 165 | return entries.map((e) => ({ | |
| 166 | id: `status-${e.id}`, | |
| 167 | account: `${e.action.startsWith("maintenance_") ? "maintenance" : "incident"}:${e.target}`, | |
| 168 | action: e.action, | |
| 169 | detail: e.detail, | |
| 170 | by: e.by, | |
| 171 | createdAt: e.at, | |
| 172 | })); | |
| 173 | } | |
| 174 | ||
| 175 | /** Billing's lines and the status page's, newest first, one page's worth. */ | |
| 176 | export function mergeAudit(a: AdminAction[], b: AdminAction[], page = AUDIT_PAGE): AdminAction[] { | |
| 177 | return [...a, ...b].sort((x, y) => y.createdAt.localeCompare(x.createdAt)).slice(0, page); | |
| 178 | } | |
| 179 | ||
| 180 | export function actionLabel(action: string): string { | |
| 181 | return AUDIT_ACTIONS[action] ?? action.replace(/_/g, " ").replace(/^./, (char) => char.toUpperCase()); | |
| 182 | } | |
| 183 | ||
| 184 | export const AUDIT_PAGE = 100; | |
| 185 | ||
| 186 | /** Where the next, older page starts: after the last line, if this page was full. */ | |
| 187 | export function olderBefore(actions: Pick<AdminAction, "createdAt">[], page = AUDIT_PAGE): string | null { | |
| 188 | return actions.length >= page ? (actions.at(-1)?.createdAt ?? null) : null; | |
| 189 | } | |
| 190 | ||
| 191 | /** A staff email filter: lowercased, at most one address's length. */ | |
| 192 | export function parseBy(value: string | null): string | null { | |
| 193 | const by = (value ?? "").trim().toLowerCase(); | |
| 194 | return by && by.length <= 254 && !/\s/.test(by) ? by : null; | |
| 195 | } | |
| 196 | ||
| 197 | export function parseAction(value: string | null): string | null { | |
| 198 | const action = (value ?? "").trim(); | |
| 199 | return /^[a-z_]{1,40}$/.test(action) ? action : null; | |
| 200 | } | |
| 201 | ||
| 202 | /** An RFC 3339 time to page from. */ | |
| 203 | export function parseBefore(value: string | null): string | null { | |
| 204 | if (!value || value.length > 40 || Number.isNaN(new Date(value).getTime())) return null; | |
| 205 | return value; | |
| 206 | } | |
| 207 | ||
| 208 | export function auditHref({ by, action, before }: { by?: string | null; action?: string | null; before?: string | null }): string { | |
| 209 | const params = new URLSearchParams(); | |
| 210 | if (by) params.set("by", by); | |
| 211 | if (action) params.set("action", action); | |
| 212 | if (before) params.set("before", before); | |
| 213 | const query = params.toString(); | |
| 214 | return query ? `/audit?${query}` : "/audit"; | |
| 215 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.