flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

Commit

Billing on Stripe's pages, month-end charges, warnings; sudo by workspace

Cards, invoices, receipts and billing details live on Stripe's hosted billing page (the customer portal), opened from Billing; g1t never sees a card number. Staff send a customer the same page from sudo: a one-time link and the page's permanent sign-in, logged. Billing now charges the card on file when a month closes, for what was owed, and autopay near the limit charges what is owed rather than the cost of free usage. Unpaid charges carry into the next month's limit, so a new month is no fresh allowance. Owners are emailed at 50, 80 and 100% of their limit and when a card is declined, through identity's new notify_owners. sudo is organised around workspaces: name, owners, members and created first, then what each is billed and to whom; enterprises have their own pages, and internal billing ids no longer show. Identity has staff-only admin_workspaces and admin_workspace for it. The app's two top bars are 56px instead of 48.

syntaqxcommitted Parentaaac4c0Browse files
40 files+3088−10420/40 viewed
+27−1
158158 again, g1t rebuilds each one from the commit it was serving, by itself.
159159
160160 What counts is this month's usage (UTC), each item at what it cost g1t or
161−what it is charged, whichever is more, less what was paid this month. Even
161+what it is charged, whichever is more, less what was paid this month, plus
162+any charges left unpaid from earlier months: a new month is not a fresh
163+allowance. Even
162164 usage that is free to you, such as the free minutes, counts at its cost:
163165 the limit is about what g1t has spent on a workspace's behalf.
164166
205207 Each change is made by g1t staff in g1t's billing console and recorded with
206208 who made it and why. To ask for one, write to support.
207209
210+## Your card, invoices and billing details
211+
212+These live on **Stripe's billing page**, not on g1t: g1t never sees or
213+stores card numbers. An owner opens it from **Settings → Billing → Card
214+and invoices → Manage billing on Stripe** (or **Add a card on Stripe**),
215+and there adds or replaces the card, downloads invoices and receipts, and
216+sets the billing email, address and tax ID. Saving a card charges
217+nothing. g1t support never takes card details by phone or email; if you
218+need help, we send you a link to that same Stripe page.
219+
220+With a card on file:
221+
222+- **Near the usage limit** (80%), g1t charges it for what the workspace
223+ owes, at least $5, so work does not stop.
224+- **When each month closes**, g1t charges it for what the workspace owed
225+ at the end of the month, and the statement shows the payment as *Usage
226+ for 2026-10, charged to the card on file when the month closed*.
227+- **If it is declined**, work stops until the workspace pays, and the
228+ Billing page and the API say why. Replace the card or add credit to pay.
229+
230+Comped workspaces are never charged, and an enterprise's workspaces are
231+billed through the enterprise. Test-mode cards are never charged
232+automatically.
233+
208234 ## Add credit
209235
210236 Credit is a payment in advance: it pays for usage as it happens, and lowers
+37−15
11 # sudo
22
3−g1t's staff console, at <https://sudo.g1t.sh>. Staff use it to manage how
4−accounts pay: comp a workspace, set custom terms (a discount, a ceiling on
5−unpaid usage, an end date), create Enterprise accounts that pay for several
6−workspaces, move workspaces on and off them, issue credits, and see where
7−every account stands this month with its ledger and audit log.
3+g1t's staff console, at <https://sudo.g1t.sh>. It is organised the way
4+customers know g1t: by **workspace**.
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
11+ **Billing** its terms, who it is billed to (move it onto or off an
12+ enterprise), a credit form, a Stripe billing link, its ledger and its
13+ audit log.
14+- **Enterprises**: customers that pay for several workspaces with one
15+ 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.
17+
18+Billing's internal account ids (`ws_<slug>` for a workspace's own,
19+`ent_…` for an enterprise) are never shown as names; an enterprise's id
20+appears only as small "Billing account id" text. Old `/accounts/…` links
21+redirect to the workspace or enterprise they meant.
22+
23+**Cards stay on Stripe.** sudo never shows a card field. To help a customer
24+update their card or see invoices, staff make a Stripe billing link on the
25+workspace's page (it is recorded) and send it to the owner.
826
9−It holds no data. Everything goes to the billing service's staff methods
10−(`admin_*`, see `BillingAdminApi` in `packages/contracts/src/billing.ts`),
11−and each change is recorded there with the staff member's email.
27+It holds no data. Workspaces, owners and members come from identity's
28+staff methods (`admin_workspaces`, `admin_workspace`; `IdentityAdminApi` in
29+`packages/contracts/src/identity.ts`); everything about money goes to the
30+billing service's (`admin_*`, `BillingAdminApi` in
31+`packages/contracts/src/billing.ts`), where each change is recorded with
32+the staff member's email. Both are reached over service bindings only, and
33+nothing but sudo binds to them.
1234
1335 ## How it is locked
1436
2244 `STAFF_EMAILS` are all set, every request gets a 403 saying sudo is not
2345 configured.
2446 4. **Changes** are POSTs only, and only from sudo's own pages (`Origin`, or
25− `Referer`, must be `https://sudo.g1t.sh`). Terms, enterprise moves and new
26− enterprises show a confirmation step first; a credit needs the workspace's
27− slug typed out.
47+ `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.
2850 5. **The pages ship no JavaScript.** The content security policy forbids
2951 every script and inline style; responses are `no-store`, `noindex` and
3052 cannot be framed. The worker has no `workers.dev` address or preview URLs.
5476 }
5577 ```
5678
57−6. Deploy: `scripts/deploy.sh sudo` (after `billing`, whose `admin_*`
58− methods it calls).
79+6. Deploy: `scripts/deploy.sh sudo` (after `billing` and `identity`, whose
80+ `admin_*` methods it calls).
5981
60−Visit <https://sudo.g1t.sh>: Access asks you to sign in, then the accounts
82+Visit <https://sudo.g1t.sh>: Access asks you to sign in, then the workspaces
6183 list opens. Anyone else gets Access's own refusal; anyone Access lets in who
6284 is not in `STAFF_EMAILS` gets a 403 from the worker.
6385
7395
7496 ```sh
7597 npm run typecheck -w @g1t/sudo
76−npm test -w @g1t/sudo # JWT verification, forms, money
98+npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join
7799 npm run build -w @g1t/sudo
78100 ```
79101
+449−0
1+/**
2+ * The billing sections shared by a workspace's page and an enterprise's:
3+ * confirmations, terms, credit, the Stripe billing link, the ledger and the
4+ * audit log. Plain forms; sudo ships no JavaScript.
5+ */
6+import { CreditCard, Gift, ScrollText, UserRound } from "lucide-react";
7+import type { ReactNode } from "react";
8+import { Link } from "react-router";
9+
10+import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
11+
12+import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
13+import { dollarsField, usd } from "~/lib/money";
14+import type { Review, SectionError } from "~/lib/review";
15+
16+export function Hidden({ values }: { values: Record<string, string> }) {
17+ return (
18+ <>
19+ {Object.entries(values).map(([name, value]) => (
20+ <input key={name} type="hidden" name={name} value={value} />
21+ ))}
22+ </>
23+ );
24+}
25+
26+export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) {
27+ return (
28+ <div className="rounded-lg border border-line bg-surface px-4 py-3">
29+ <p className="text-xs text-muted">{label}</p>
30+ <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
31+ {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
32+ </div>
33+ );
34+}
35+
36+/** Owners' usernames, each with their email beneath (and on hover). */
37+export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) {
38+ if (owners.length === 0) return <span className="text-faint">No owner</span>;
39+ if (compact) {
40+ return (
41+ <span className="flex flex-col gap-0.5">
42+ {owners.map((owner) => (
43+ <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate">
44+ <span className="font-mono text-fg-soft">{owner.username}</span>
45+ {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>}
46+ </span>
47+ ))}
48+ </span>
49+ );
50+ }
51+ return (
52+ <ul className="flex flex-wrap gap-2">
53+ {owners.map((owner) => (
54+ <li key={owner.username} title={owner.email ?? "No email"} className="inline-flex items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1 text-xs">
55+ <Avatar name={owner.username} size={14} square={false} />
56+ <span className="font-mono">{owner.username}</span>
57+ {owner.email && <span className="text-faint">{owner.email}</span>}
58+ </li>
59+ ))}
60+ </ul>
61+ );
62+}
63+
64+// --- Confirmation ------------------------------------------------------------
65+
66+function describeTerms(terms: Terms): [string, string][] {
67+ return [
68+ ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
69+ ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
70+ ["Limit", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
71+ ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
72+ ["Note", terms.note || "—"],
73+ ];
74+}
75+
76+export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
77+ let title: string;
78+ let body: ReactNode;
79+ let danger = false;
80+ let confirm = "Confirm";
81+ if (review.intent === "terms") {
82+ const before = describeTerms(review.before);
83+ const after = describeTerms(review.after);
84+ title = "Confirm the new terms";
85+ danger = review.after.kind === "comped";
86+ body = (
87+ <>
88+ <div className="overflow-x-auto">
89+ <table className="w-full text-sm">
90+ <thead>
91+ <tr className="text-left text-xs text-muted">
92+ <th className="py-1.5 pr-4 font-medium" />
93+ <th className="py-1.5 pr-4 font-medium">Now</th>
94+ <th className="py-1.5 font-medium">After</th>
95+ </tr>
96+ </thead>
97+ <tbody>
98+ {after.map(([label, value], index) => (
99+ <tr key={label} className="border-t border-line align-top">
100+ <td className="py-1.5 pr-4 text-muted">{label}</td>
101+ <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
102+ <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
103+ </tr>
104+ ))}
105+ </tbody>
106+ </table>
107+ </div>
108+ {review.after.kind === "comped" && (
109+ <p className="mt-3 text-sm text-warn">
110+ Comped: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded
111+ at cost.
112+ </p>
113+ )}
114+ </>
115+ );
116+ } else if (review.intent === "attach") {
117+ title = `Bill ${review.workspace} to ${review.targetName}?`;
118+ body = (
119+ <p className="text-sm text-muted">
120+ From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
121+ <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
122+ </p>
123+ );
124+ } else if (review.intent === "detach") {
125+ title = `Move ${review.workspace} off ${review.from}?`;
126+ danger = true;
127+ body = (
128+ <p className="text-sm text-muted">
129+ <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit
130+ its trust gives it. It may stop at once if its own limit is lower than what it owes.
131+ </p>
132+ );
133+ } else {
134+ title = `Make a Stripe billing link for ${review.workspace}?`;
135+ confirm = "Make the link";
136+ body = (
137+ <p className="text-sm text-muted">
138+ Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s
139+ customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it
140+ only to the workspace's owner. Making it is recorded with your email.
141+ </p>
142+ );
143+ }
144+ return (
145+ <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+ <h2 className="font-semibold tracking-tight">{title}</h2>
147+ <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">
149+ <Hidden values={review.fields} />
150+ <input type="hidden" name="intent" value={review.intent} />
151+ <input type="hidden" name="confirm" value="yes" />
152+ <Button type="submit" variant={danger ? "danger" : "lavender"}>
153+ {confirm}
154+ </Button>
155+ <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
156+ Cancel
157+ </Link>
158+ </form>
159+ </section>
160+ );
161+}
162+
163+// --- Terms -------------------------------------------------------------------
164+
165+const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
166+ { value: "standard", title: "Standard", text: "Published prices; the limit comes from trust." },
167+ { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
168+ { value: "custom", title: "Custom", text: "A discount, a custom limit, or both." },
169+];
170+
171+export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
172+ const values = error?.values;
173+ const kind = values?.kind ?? terms.kind;
174+ return (
175+ <Section
176+ id="terms"
177+ title="Terms"
178+ description={
179+ terms.setBy ? (
180+ <>
181+ Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
182+ {terms.note && <> · “{terms.note}”</>}
183+ </>
184+ ) : (
185+ "Standard, as everyone starts."
186+ )
187+ }
188+ >
189+ <form method="post" action={`${pathname}#review`} className="space-y-4">
190+ <input type="hidden" name="intent" value="terms" />
191+ {error && <Notice tone="error">{error.error}</Notice>}
192+ <fieldset>
193+ <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
194+ <div className="grid gap-2 sm:grid-cols-3">
195+ {KINDS.map((option) => (
196+ <label
197+ key={option.value}
198+ className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
199+ >
200+ <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
201+ <span>
202+ <span className="block text-sm font-medium">{option.title}</span>
203+ <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
204+ </span>
205+ </label>
206+ ))}
207+ </div>
208+ </fieldset>
209+ <div className="grid gap-4 sm:grid-cols-3">
210+ <Field label="Discount %" hint="Custom only.">
211+ <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
212+ </Field>
213+ <Field label="Limit $" hint="Unpaid usage allowed. Blank: trust decides.">
214+ <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
215+ </Field>
216+ <Field label="Until" hint="Blank: no end. UTC.">
217+ <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
218+ </Field>
219+ </div>
220+ <Field label="Note" hint="Required. Why, for whoever looks next.">
221+ <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
222+ </Field>
223+ <div className="flex justify-end">
224+ <Button type="submit">Review terms</Button>
225+ </div>
226+ </form>
227+ </Section>
228+ );
229+}
230+
231+// --- Credit -------------------------------------------------------------------
232+
233+export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
234+ const values = error?.values;
235+ const single = workspaces.length === 1 ? workspaces[0] : null;
236+ return (
237+ <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
238+ {workspaces.length === 0 ? (
239+ <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
240+ ) : (
241+ <form method="post" action={`${pathname}#credit`} className="space-y-4">
242+ <input type="hidden" name="intent" value="credit" />
243+ {error && <Notice tone="error">{error.error}</Notice>}
244+ {single ? (
245+ <input type="hidden" name="workspace" value={single} />
246+ ) : (
247+ <Field label="Workspace">
248+ <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
249+ <option value="" disabled>
250+ Choose a workspace
251+ </option>
252+ {workspaces.map((slug) => (
253+ <option key={slug} value={slug}>
254+ {slug}
255+ </option>
256+ ))}
257+ </Select>
258+ </Field>
259+ )}
260+ <Field label="Amount $" hint="Up to $10,000 at a time.">
261+ <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
262+ </Field>
263+ <Field label="Note" hint="Required. Shown on the workspace's statement.">
264+ <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
265+ </Field>
266+ <Field
267+ label="Confirm"
268+ hint={
269+ <>
270+ Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
271+ </>
272+ }
273+ >
274+ <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
275+ </Field>
276+ <div className="flex justify-end">
277+ <Button type="submit" variant="lavender">
278+ <Gift size={14} />
279+ Issue credit
280+ </Button>
281+ </div>
282+ </form>
283+ )}
284+ </Section>
285+ );
286+}
287+
288+// --- Stripe billing link -----------------------------------------------------
289+
290+/**
291+ * Card details, invoices and receipts live on Stripe's own billing page.
292+ * sudo never shows a card field: staff send the customer a link instead.
293+ */
294+export function BillingLinkSection({
295+ link,
296+ pathname,
297+ error,
298+}: {
299+ link: BillingLink | null;
300+ pathname: string;
301+ error: SectionError;
302+}) {
303+ return (
304+ <Section
305+ id="billing-link"
306+ title="Stripe billing page"
307+ description="Where the customer updates their card and sees invoices. g1t never takes card numbers."
308+ >
309+ {error && (
310+ <div className="mb-4">
311+ <Notice tone="error">{error.error}</Notice>
312+ </div>
313+ )}
314+ {link ? (
315+ <div className="space-y-4">
316+ <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice>
317+ <Field label="One-time link" hint={link.expiresNote}>
318+ <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" />
319+ </Field>
320+ {link.loginUrl && (
321+ <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}>
322+ <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" />
323+ </Field>
324+ )}
325+ {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>}
326+ </div>
327+ ) : (
328+ <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
329+ <input type="hidden" name="intent" value="billing-link" />
330+ <p className="text-sm text-muted">
331+ Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner.
332+ </p>
333+ <Button type="submit" variant="quiet" className="shrink-0">
334+ <CreditCard size={14} />
335+ Make a link
336+ </Button>
337+ </form>
338+ )}
339+ </Section>
340+ );
341+}
342+
343+// --- Ledger and audit ---------------------------------------------------------
344+
345+const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
346+
347+export function LedgerSection({
348+ ledger,
349+ showWorkspace = false,
350+ description = "Recent lines of the statement, newest first.",
351+}: {
352+ ledger: LedgerEntry[];
353+ showWorkspace?: boolean;
354+ description?: ReactNode;
355+}) {
356+ return (
357+ <Section title="Ledger" description={description}>
358+ {ledger.length === 0 ? (
359+ <EmptyState title="Nothing yet" />
360+ ) : (
361+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
362+ <table className="w-full min-w-[36rem] text-sm">
363+ <thead>
364+ <tr className="border-b border-line text-left text-xs text-muted">
365+ <th className="px-4 py-2 font-medium sm:pl-5">When</th>
366+ <th className="px-4 py-2 font-medium">What</th>
367+ <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
368+ </tr>
369+ </thead>
370+ <tbody>
371+ {ledger.map((entry) => (
372+ <tr key={entry.id} className="border-b border-line align-top last:border-0">
373+ <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
374+ <When at={entry.createdAt} time />
375+ </td>
376+ <td className="px-4 py-2.5">
377+ <div className="flex flex-wrap items-center gap-1.5">
378+ <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
379+ {showWorkspace && entry.workspace && (
380+ <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline">
381+ {entry.workspace}
382+ </Link>
383+ )}
384+ <span className="break-words">{entry.description}</span>
385+ </div>
386+ <p className="mt-0.5 font-mono text-xs text-faint">
387+ {[
388+ entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
389+ entry.task,
390+ entry.model,
391+ entry.billedTo === "workspace" ? "own provider" : null,
392+ entry.createdBy && `by ${entry.createdBy}`,
393+ ]
394+ .filter(Boolean)
395+ .join(" · ")}
396+ </p>
397+ </td>
398+ <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
399+ {usd(entry.amountMicros, { signed: true })}
400+ </td>
401+ </tr>
402+ ))}
403+ </tbody>
404+ </table>
405+ </div>
406+ )}
407+ </Section>
408+ );
409+}
410+
411+const ACTION: Record<string, string> = {
412+ terms: "Terms changed",
413+ create: "Enterprise created",
414+ attach: "Workspace added",
415+ detach: "Workspace removed",
416+ credit: "Credit issued",
417+ billing_link: "Billing link made",
418+};
419+
420+export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
421+ return (
422+ <Section title="Audit log" description={description}>
423+ {audit.length === 0 ? (
424+ <p className="flex items-center gap-2 text-sm text-muted">
425+ <ScrollText size={14} />
426+ No changes yet.
427+ </p>
428+ ) : (
429+ <ol className="space-y-3">
430+ {audit.map((entry) => (
431+ <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
432+ <p className="flex flex-wrap items-center gap-x-2 text-sm">
433+ <span className="font-medium text-merged">{ACTION[entry.action] ?? entry.action}</span>
434+ <span className="text-xs text-faint">
435+ <When at={entry.createdAt} time />
436+ </span>
437+ </p>
438+ {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
439+ <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
440+ <UserRound size={11} />
441+ {entry.by}
442+ </p>
443+ </li>
444+ ))}
445+ </ol>
446+ )}
447+ </Section>
448+ );
449+}
+5−9
77 import type { ComponentProps, ReactNode } from "react";
88 import { Link, type LinkProps } from "react-router";
99
10−import type { Limit, PayingAccount, Terms, Trust } from "@g1t/contracts";
10+import type { Limit, Terms, Trust } from "@g1t/contracts";
1111
1212 import { usd } from "~/lib/money";
1313
148148 );
149149 }
150150
151−export function KindBadge({ kind }: { kind: PayingAccount["kind"] }) {
152− return kind === "enterprise" ? <Badge tone="lavender">Enterprise</Badge> : <Badge>Workspace</Badge>;
153−}
154−
155151 export function TermsBadge({ terms }: { terms: Terms }) {
156152 if (terms.kind === "comped") return <Badge tone="mint">Comped</Badge>;
157153 if (terms.kind === "custom") {
185181 }
186182
187183 /**
188− * Unpaid usage this month against the ceiling, as a bar coloured by where
189− * it stands. An account with no ceiling (g1t's own) shows the figure only.
184+ * Usage this month that is not paid for yet, against the limit, as a bar
185+ * coloured by where it stands. With no limit (comped) it shows the figure.
190186 */
191187 export function ExposureBar({ limit, wide = false }: { limit: Limit; wide?: boolean }) {
192188 const { exposureMicros, ceilingMicros, state } = limit;
197193 <div className="flex items-baseline justify-between gap-2 text-xs">
198194 <span className="tabular font-medium text-fg-soft">
199195 {usd(exposureMicros)}
200− <span className="font-normal text-faint"> / {ceilingMicros == null ? "no ceiling" : usd(ceilingMicros)}</span>
196+ <span className="font-normal text-faint"> / {ceilingMicros == null ? "no limit" : usd(ceilingMicros)}</span>
201197 </span>
202198 {percent != null && <span className={`tabular ${STATE[state].text}`}>{percent}%</span>}
203199 </div>
204− <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the ceiling`}>
200+ <svg viewBox="0 0 100 4" preserveAspectRatio="none" className="mt-1.5 block h-1.5 w-full" role="img" aria-label={`${STATE[state].label}: ${percent ?? 0}% of the limit`}>
205201 <rect x="0" y="0" width="100" height="4" rx="2" fill="var(--g1t-raised)" />
206202 {ceilingMicros != null && share > 0 && (
207203 <rect x="0" y="0" width={Math.max(2, share * 100)} height="4" rx="2" fill={STATE[state].fill} />
+118−0
1+/**
2+ * The changes staff make to how a workspace or an enterprise pays: terms,
3+ * moving workspaces on and off enterprises, credits, and Stripe billing
4+ * links. What is acted on comes from the billing service and identity, not
5+ * from the form; each change shows a confirmation first, and a credit
6+ * needs the workspace's slug typed out.
7+ */
8+import type { Terms } from "@g1t/contracts";
9+import { data, redirect } from "react-router";
10+
11+import { fields, parseCredit, parseNote, parseSlug, parseTerms, text } from "./forms";
12+import type { ActionData } from "./review";
13+import { admin, identity } from "./services.server";
14+import type { Staff } from "./staff";
15+import type { Enterprise } from "./workspaces";
16+
17+/** What a page acts on. `accountId` is billing's internal id, never shown. */
18+export type Subject =
19+ | { kind: "workspace"; slug: string; accountId: string; terms: Terms; billedTo: Enterprise | null }
20+ | { kind: "enterprise"; accountId: string; name: string; terms: Terms; workspaces: string[] };
21+
22+function failed(section: string, error: string, values?: Record<string, string>) {
23+ return data<ActionData>({ error, section, values }, { status: 422 });
24+}
25+
26+export async function billingAction(request: Request, staff: Staff, subject: Subject, path: string) {
27+ const form = await request.formData();
28+ const intent = text(form, "intent");
29+ const confirmed = text(form, "confirm") === "yes";
30+ const back = (done: string) => redirect(`${path}?done=${done}#top`);
31+ const isEnterprise = subject.kind === "enterprise";
32+
33+ if (intent === "terms") {
34+ const values = fields(form, "kind", "discount", "ceiling", "note", "until");
35+ if (subject.kind === "workspace" && subject.billedTo) {
36+ return failed("terms", `This workspace is charged on ${subject.billedTo.name}'s terms. Change them on the enterprise.`, values);
37+ }
38+ const terms = parseTerms(form, staff.email);
39+ if (!terms.ok) return failed("terms", terms.error, values);
40+ if (!confirmed) return { review: { intent, before: subject.terms, after: terms.value, fields: values } } satisfies ActionData;
41+ const result = await admin.setTerms(subject.accountId, terms.value, staff.email);
42+ if (!result.ok) return failed("terms", result.error.message, values);
43+ return back("terms");
44+ }
45+
46+ if (intent === "attach") {
47+ const values = fields(form, "workspace", "target");
48+ const section = isEnterprise ? "members" : "billed-to";
49+ let slug: string;
50+ let target: Enterprise;
51+ if (subject.kind === "enterprise") {
52+ const parsed = parseSlug(values.workspace);
53+ if (!parsed.ok) return failed(section, parsed.error, values);
54+ slug = parsed.value;
55+ if (subject.workspaces.includes(slug)) return failed(section, `${slug} is already on this enterprise.`, values);
56+ if (!(await identity.workspace(slug))) return failed(section, `There is no workspace called ${slug}.`, values);
57+ target = { id: subject.accountId, name: subject.name };
58+ } else {
59+ slug = subject.slug;
60+ const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target);
61+ if (!enterprise) return failed(section, "Choose an enterprise to move onto.", values);
62+ target = { id: enterprise.account.id, name: enterprise.account.name };
63+ }
64+ if (!confirmed) return { review: { intent, workspace: slug, targetName: target.name, fields: values } } satisfies ActionData;
65+ const result = await admin.attach(slug, target.id, staff.email);
66+ if (!result.ok) return failed(section, result.error.message, values);
67+ return back("attach");
68+ }
69+
70+ if (intent === "detach") {
71+ const values = fields(form, "workspace");
72+ const section = isEnterprise ? "members" : "billed-to";
73+ let slug: string;
74+ let from: string;
75+ if (subject.kind === "enterprise") {
76+ slug = values.workspace;
77+ if (!subject.workspaces.includes(slug)) return failed(section, `${slug} is not on this enterprise.`);
78+ from = subject.name;
79+ } else {
80+ if (!subject.billedTo) return failed(section, `${subject.slug} already pays for itself.`);
81+ slug = subject.slug;
82+ from = subject.billedTo.name;
83+ }
84+ if (!confirmed) return { review: { intent, workspace: slug, from, fields: values } } satisfies ActionData;
85+ const result = await admin.attach(slug, null, staff.email);
86+ if (!result.ok) return failed(section, result.error.message);
87+ return back("detach");
88+ }
89+
90+ if (intent === "credit") {
91+ const values = fields(form, "workspace", "amount", "note", "confirmation");
92+ const workspace = subject.kind === "enterprise" ? values.workspace : subject.slug;
93+ if (subject.kind === "enterprise" && !subject.workspaces.includes(workspace)) {
94+ return failed("credit", "Choose one of this enterprise's workspaces.", values);
95+ }
96+ const amount = parseCredit(values.amount);
97+ if (!amount.ok) return failed("credit", amount.error, values);
98+ const note = parseNote(values.note);
99+ if (!note.ok) return failed("credit", note.error, values);
100+ if (values.confirmation !== workspace) {
101+ return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" });
102+ }
103+ const result = await admin.credit(workspace, amount.value, note.value, staff.email);
104+ if (!result.ok) return failed("credit", result.error.message, values);
105+ return back("credit");
106+ }
107+
108+ if (intent === "billing-link") {
109+ if (subject.kind !== "workspace") return failed("top", "Billing links are made from a workspace's page.");
110+ if (!confirmed) return { review: { intent, workspace: subject.slug, fields: {} } } satisfies ActionData;
111+ const result = await admin.billingLink(subject.slug, staff.email);
112+ if (!result.ok) return failed("billing-link", result.error.message);
113+ // Shown once, in this response only: it is never stored or redirected to.
114+ return { link: result.value, workspace: subject.slug } satisfies ActionData;
115+ }
116+
117+ return failed("top", "Unknown action.");
118+}
+33−0
1+/**
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.
5+ */
6+import type { BillingLink, Terms } from "@g1t/contracts";
7+
8+export type Review =
9+ | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> }
10+ | { intent: "attach"; workspace: string; targetName: string; fields: Record<string, string> }
11+ | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> }
12+ | { intent: "billing-link"; workspace: string; fields: Record<string, string> };
13+
14+export type ActionData =
15+ | { error: string; section: string; values?: Record<string, string> }
16+ | { review: Review }
17+ | { link: BillingLink; workspace: string };
18+
19+export type SectionError = { error: string; values?: Record<string, string> } | null;
20+
21+/** The flash messages a change redirects back with (`?done=`). */
22+export const DONE: Record<string, string> = {
23+ terms: "Terms saved. They apply to charges from now on.",
24+ attach: "Workspace moved onto the enterprise.",
25+ detach: "Workspace moved off the enterprise. It pays for itself again.",
26+ credit: "Credit issued.",
27+ created: "Enterprise created.",
28+};
29+
30+export function doneMessage(url: string): string | null {
31+ const done = new URL(url).searchParams.get("done");
32+ return done && DONE[done] ? DONE[done] : null;
33+}
+4−1
11 import { env } from "cloudflare:workers";
22
3−import { billingAdminClient } from "@g1t/contracts";
3+import { billingAdminClient, identityAdminClient } from "@g1t/contracts";
44
55 /** Staff-only billing, on the billing service. */
66 export const admin = billingAdminClient(env.BILLING);
7+
8+/** Staff-only identity: every workspace, its owners and members. */
9+export const identity = identityAdminClient(env.IDENTITY);
+106−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AccountSummary, AdminWorkspace, Limit, Terms } from "@g1t/contracts";
5+
6+import { STANDARD_TERMS, joinWorkspaces, legacyAccountPath, matchesQuery } from "./workspaces.ts";
7+
8+function limit(workspace: string, account: string, exposureMicros = 0): Limit {
9+ return {
10+ workspace,
11+ account,
12+ accountName: account,
13+ trust: "new",
14+ exposureMicros,
15+ ceilingMicros: 10_000_000,
16+ trustCeilingMicros: 10_000_000,
17+ spendLimitMicros: null,
18+ state: "ok",
19+ message: null,
20+ };
21+}
22+
23+function workspace(slug: string, owner = `${slug}-owner`): AdminWorkspace {
24+ return {
25+ slug,
26+ name: slug.toUpperCase(),
27+ createdAt: "2026-10-01T00:00:00Z",
28+ owners: [{ username: owner, email: `${owner}@example.com` }],
29+ memberCount: 1,
30+ };
31+}
32+
33+const COMPED: Terms = { ...STANDARD_TERMS, kind: "comped", note: "g1t's own" };
34+
35+const OWN: AccountSummary = {
36+ account: { id: "ws_syntaqx", kind: "workspace", name: "syntaqx", terms: COMPED, workspaces: ["syntaqx"], createdAt: "2026-10-05T00:00:00Z" },
37+ limit: limit("syntaqx", "ws_syntaqx", 3_000_000),
38+ chargedMicros: 0,
39+ costMicros: 3_000_000,
40+ paidMicros: 0,
41+ byWorkspace: [{ workspace: "syntaqx", chargedMicros: 0, costMicros: 3_000_000, paidMicros: 0 }],
42+};
43+
44+const ACME: AccountSummary = {
45+ account: { id: "ent_acme", kind: "enterprise", name: "Acme Corp", terms: STANDARD_TERMS, workspaces: ["acme", "acme-labs"], createdAt: "2026-10-02T00:00:00Z" },
46+ limit: limit("acme", "ent_acme", 9_000_000),
47+ chargedMicros: 12_000_000,
48+ costMicros: 6_000_000,
49+ paidMicros: 0,
50+ byWorkspace: [
51+ { workspace: "acme", chargedMicros: 12_000_000, costMicros: 6_000_000, paidMicros: 0 },
52+ { workspace: "acme-labs", chargedMicros: 0, costMicros: 0, paidMicros: 0 },
53+ ],
54+};
55+
56+test("every workspace is listed, with or without billing", () => {
57+ const rows = joinWorkspaces([workspace("syntaqx"), workspace("quiet")], [OWN]);
58+ assert.deepEqual(
59+ rows.map((row) => row.slug),
60+ ["syntaqx", "quiet"],
61+ );
62+ const [syntaqx, quiet] = rows;
63+ assert.equal(syntaqx.billing.terms.kind, "comped");
64+ assert.equal(syntaqx.billing.costMicros, 3_000_000);
65+ assert.equal(syntaqx.billing.billedTo, null);
66+ assert.equal(quiet.billing.terms.kind, "standard");
67+ assert.equal(quiet.billing.limit, null);
68+ assert.equal(quiet.billing.chargedMicros, 0);
69+});
70+
71+test("a workspace on an enterprise shows the enterprise and only its own share", () => {
72+ const rows = joinWorkspaces([workspace("acme"), workspace("acme-labs")], [ACME]);
73+ const labs = rows.find((row) => row.slug === "acme-labs");
74+ assert.deepEqual(labs?.billing.billedTo, { id: "ent_acme", name: "Acme Corp" });
75+ assert.equal(labs?.billing.chargedMicros, 0);
76+ assert.equal(labs?.billing.limit?.exposureMicros, 9_000_000);
77+ assert.equal(rows.find((row) => row.slug === "acme")?.billing.chargedMicros, 12_000_000);
78+});
79+
80+test("a slug only billing knows is still listed, marked unknown", () => {
81+ const rows = joinWorkspaces([], [ACME]);
82+ assert.deepEqual(
83+ rows.map((row) => [row.slug, row.known]),
84+ [
85+ ["acme", false],
86+ ["acme-labs", false],
87+ ],
88+ );
89+});
90+
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"));
98+});
99+
100+test("old account links go to the workspace or the enterprise", () => {
101+ assert.equal(legacyAccountPath("ws_syntaqx"), "/workspaces/syntaqx");
102+ assert.equal(legacyAccountPath("syntaqx"), "/workspaces/syntaqx");
103+ assert.equal(legacyAccountPath("ent_01abc"), "/enterprises/ent_01abc");
104+ assert.equal(legacyAccountPath("ws_"), null);
105+ assert.equal(legacyAccountPath("../etc"), null);
106+});
+148−0
1+/**
2+ * sudo is organised around workspaces, as customers know them. Identity
3+ * says which workspaces exist and who owns them; billing says who pays for
4+ * each and where it stands. This joins the two, by slug. No Workers
5+ * imports, so it can be tested under Node.
6+ *
7+ * Billing's account ids (`ws_<slug>`, `ent_…`) are internal: they are used
8+ * to look things up and never shown as a name.
9+ */
10+import type { AccountSummary, AdminOwner, AdminWorkspace, Limit, Terms } from "@g1t/contracts";
11+
12+/** Terms every workspace starts on. */
13+export const STANDARD_TERMS: Terms = {
14+ kind: "standard",
15+ discountPercent: 0,
16+ ceilingMicros: null,
17+ note: "",
18+ until: null,
19+ setBy: null,
20+ setAt: null,
21+};
22+
23+export type Enterprise = { id: string; name: string };
24+
25+/** Where a workspace stands with billing. */
26+export type WorkspaceBilling = {
27+ /** The enterprise that pays for it, or null when it pays for itself. */
28+ billedTo: Enterprise | null;
29+ /** The terms it is charged on: its own, or its enterprise's. */
30+ terms: Terms;
31+ /**
32+ * Its limit this month: its own, or for a workspace on an enterprise the
33+ * enterprise's, which all its workspaces share. Null with no billing
34+ * activity yet.
35+ */
36+ limit: Limit | null;
37+ /** This workspace's own figures this month (charged, cost) and ever (paid). */
38+ chargedMicros: number;
39+ costMicros: number;
40+ paidMicros: number;
41+};
42+
43+export type WorkspaceRow = {
44+ slug: string;
45+ name: string;
46+ /** Null for a workspace billing knows and identity returned nothing for. */
47+ createdAt: string | null;
48+ owners: AdminOwner[];
49+ memberCount: number | null;
50+ /** False when only billing knows the slug. */
51+ known: boolean;
52+ billing: WorkspaceBilling;
53+};
54+
55+/** The billing side of every workspace billing knows, by slug. */
56+export function billingBySlug(accounts: AccountSummary[]): Map<string, WorkspaceBilling> {
57+ const bySlug = new Map<string, WorkspaceBilling>();
58+ for (const summary of accounts) {
59+ const { account } = summary;
60+ const shares = new Map((summary.byWorkspace ?? []).map((share) => [share.workspace, share]));
61+ if (account.kind === "enterprise") {
62+ for (const slug of account.workspaces) {
63+ const share = shares.get(slug);
64+ bySlug.set(slug, {
65+ billedTo: { id: account.id, name: account.name },
66+ terms: account.terms,
67+ limit: summary.limit,
68+ chargedMicros: share?.chargedMicros ?? 0,
69+ costMicros: share?.costMicros ?? 0,
70+ paidMicros: share?.paidMicros ?? 0,
71+ });
72+ }
73+ } else {
74+ const slug = account.workspaces[0] ?? summary.limit.workspace;
75+ // An enterprise's claim on a workspace wins over a stale own account.
76+ if (bySlug.get(slug)?.billedTo) continue;
77+ bySlug.set(slug, {
78+ billedTo: null,
79+ terms: account.terms,
80+ limit: summary.limit,
81+ chargedMicros: summary.chargedMicros,
82+ costMicros: summary.costMicros,
83+ paidMicros: summary.paidMicros,
84+ });
85+ }
86+ }
87+ return bySlug;
88+}
89+
90+/** A workspace with no billing activity: standard terms, nothing charged. */
91+export function noBilling(): WorkspaceBilling {
92+ return { billedTo: null, terms: STANDARD_TERMS, limit: null, chargedMicros: 0, costMicros: 0, paidMicros: 0 };
93+}
94+
95+/**
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.
99+ */
100+export function joinWorkspaces(workspaces: AdminWorkspace[], accounts: AccountSummary[]): WorkspaceRow[] {
101+ const billing = billingBySlug(accounts);
102+ const rows: WorkspaceRow[] = workspaces.map((workspace) => ({
103+ slug: workspace.slug,
104+ name: workspace.name,
105+ createdAt: workspace.createdAt,
106+ owners: workspace.owners,
107+ memberCount: workspace.memberCount,
108+ known: true,
109+ billing: billing.get(workspace.slug) ?? noBilling(),
110+ }));
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+}
118+
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));
130+}
131+
132+/**
133+ * Where an old `/accounts/<id>` link now lives: a workspace's own account
134+ * (`ws_<slug>`, or a bare slug) at its workspace, an enterprise at its own
135+ * page. Null for anything else.
136+ */
137+export function legacyAccountPath(id: string): string | null {
138+ const value = id.trim().toLowerCase();
139+ if (/^ent_[a-z0-9_-]{1,80}$/.test(value)) return `/enterprises/${encodeURIComponent(value)}`;
140+ const slug = value.startsWith("ws_") ? value.slice(3) : value;
141+ if (/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(slug)) return `/workspaces/${encodeURIComponent(slug)}`;
142+ return null;
143+}
144+
145+/** A row's owners' usernames, as one line. */
146+export function ownerNames(owners: AdminOwner[]): string {
147+ return owners.map((owner) => owner.username).join(", ");
148+}
+18−18
1−import { Building2, ShieldCheck, Users } from "lucide-react";
2−import { isRouteErrorResponse, Link, Links, Meta, NavLink, Outlet, useRouteLoaderData } from "react-router";
1+import { Building2, Boxes, ShieldCheck } from "lucide-react";
2+import { isRouteErrorResponse, Link, Links, Meta, Outlet, useLocation, useRouteLoaderData } from "react-router";
33
44 import type { Route } from "./+types/root";
55 import "./app.css";
2626 return { email: requireStaff(context).email };
2727 }
2828
29−function NavItem({ to, end, children }: { to: string; end?: boolean; children: React.ReactNode }) {
29+/** A top-level section; it stays lit on the pages beneath it. */
30+function NavItem({ to, active, children }: { to: string; active: (path: string) => boolean; children: React.ReactNode }) {
31+ const { pathname } = useLocation();
32+ const isActive = active(pathname);
3033 return (
31− <NavLink
34+ <Link
3235 to={to}
33− end={end}
34− className={({ isActive }) =>
35− `inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
36− isActive ? "text-fg" : "text-muted"
37− }`
38− }
36+ aria-current={isActive ? "page" : undefined}
37+ className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
38+ isActive ? "bg-raised/60 text-fg" : "text-muted"
39+ }`}
3940 >
4041 {children}
41− </NavLink>
42+ </Link>
4243 );
4344 }
4445
6061 <Logo />
6162 </Link>
6263 <nav className="flex items-center gap-0.5">
63− <NavItem to="/" end>
64− <Users size={14} className="hidden sm:block" />
65− Accounts
64+ <NavItem to="/" active={(path) => path === "/" || path.startsWith("/workspaces")}>
65+ <Boxes size={14} className="hidden sm:block" />
66+ Workspaces
6667 </NavItem>
67− <NavItem to="/enterprises/new">
68+ <NavItem to="/enterprises" active={(path) => path.startsWith("/enterprises")}>
6869 <Building2 size={14} className="hidden sm:block" />
69− <span className="sm:hidden">New ent.</span>
70− <span className="hidden sm:inline">New enterprise</span>
70+ Enterprises
7171 </NavItem>
7272 </nav>
7373 {root?.email && (
114114 <p className="mt-3 break-words text-muted">{details}</p>
115115 <div className="mt-8">
116116 <ButtonLink to="/" variant="quiet">
117− Back to accounts
117+ Back to workspaces
118118 </ButtonLink>
119119 </div>
120120 </main>
+7−2
11 import { type RouteConfig, index, route } from "@react-router/dev/routes";
22
33 export default [
4− index("routes/accounts.tsx"),
5− route("accounts/:id", "routes/account.tsx"),
4+ index("routes/workspaces.tsx"),
5+ route("workspaces", "routes/workspaces-redirect.tsx"),
6+ route("workspaces/:slug", "routes/workspace.tsx"),
7+ route("enterprises", "routes/enterprises.tsx"),
68 route("enterprises/new", "routes/new-enterprise.tsx"),
9+ route("enterprises/:id", "routes/enterprise.tsx"),
10+ // Before sudo was organised around workspaces, everything was an account.
11+ route("accounts/*", "routes/legacy-accounts.tsx"),
712 ] satisfies RouteConfig;
+0−713
1−import { ArrowLeft, Building2, Gift, LogOut, Plus, ScrollText, Trash2, UserRound } from "lucide-react";
2−import type { ReactNode } from "react";
3−import { data, Link, redirect, useLocation } from "react-router";
4−
5−import { type AccountDetail, type LedgerEntry, type Limit, type Terms, httpStatus } from "@g1t/contracts";
6−
7−import type { Route } from "./+types/account";
8−import {
9− Avatar,
10− Badge,
11− Button,
12− EmptyState,
13− ExposureBar,
14− Field,
15− Input,
16− KindBadge,
17− Notice,
18− Section,
19− Select,
20− StateBadge,
21− TermsBadge,
22− Textarea,
23− TrustBadge,
24− When,
25−} from "~/components/ui";
26−import { fields, isAccountId, parseCredit, parseNote, parseSlug, parseTerms, text } from "~/lib/forms";
27−import { dollarsField, usd } from "~/lib/money";
28−import { admin } from "~/lib/services.server";
29−import { requireStaff } from "~/lib/staff";
30−
31−export const meta: Route.MetaFunction = ({ loaderData }) => [
32− { title: `${loaderData?.detail.summary.account.name ?? "Account"} · sudo` },
33− { name: "robots", content: "noindex, nofollow" },
34−];
35−
36−const DONE: Record<string, string> = {
37− terms: "Terms saved. They apply to charges from now on.",
38− attach: "Workspace moved onto the enterprise.",
39− detach: "Workspace moved back onto its own account.",
40− credit: "Credit issued.",
41− created: "Enterprise created.",
42−};
43−
44−async function load(id: string): Promise<AccountDetail> {
45− if (!isAccountId(id)) throw data("That is not an account id or a workspace slug.", { status: 404 });
46− const result = await admin.account(id);
47− if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
48− return result.value;
49−}
50−
51−export async function loader({ request, params, context }: Route.LoaderArgs) {
52− requireStaff(context);
53− const detail = await load(params.id);
54− const { account } = detail.summary;
55− // A workspace's page offers the enterprises it could move onto.
56− const enterprises =
57− account.kind === "workspace"
58− ? (await admin.accounts())
59− .filter((row) => row.account.kind === "enterprise")
60− .map((row) => ({ id: row.account.id, name: row.account.name }))
61− : [];
62− const done = new URL(request.url).searchParams.get("done");
63− return { detail, enterprises, done: done && DONE[done] ? DONE[done] : null };
64−}
65−
66−type Review =
67− | { intent: "terms"; before: Terms; after: Terms; fields: Record<string, string> }
68− | { intent: "attach"; workspace: string; target: string; targetName: string; fields: Record<string, string> }
69− | { intent: "detach"; workspace: string; from: string; fields: Record<string, string> };
70−
71−type ActionData = { error: string; section: string; values?: Record<string, string> } | { review: Review };
72−
73−function failed(section: string, error: string, values?: Record<string, string>) {
74− return data<ActionData>({ error, section, values }, { status: 422 });
75−}
76−
77−/** The workspace an account's page acts for when it is a workspace's own. */
78−function ownWorkspace(detail: AccountDetail): string {
79− return detail.summary.limit.workspace;
80−}
81−
82−export async function action({ request, params, context }: Route.ActionArgs) {
83− const staff = requireStaff(context);
84− // What is acted on comes from the billing service, not from the form.
85− const detail = await load(params.id);
86− const { account } = detail.summary;
87− const form = await request.formData();
88− const intent = text(form, "intent");
89− const confirmed = text(form, "confirm") === "yes";
90− const back = (done: string) => redirect(`/accounts/${encodeURIComponent(params.id)}?done=${done}#top`);
91−
92− if (intent === "terms") {
93− const values = fields(form, "kind", "discount", "ceiling", "note", "until");
94− const terms = parseTerms(form, staff.email);
95− if (!terms.ok) return failed("terms", terms.error, values);
96− if (!confirmed) return { review: { intent, before: account.terms, after: terms.value, fields: values } } satisfies ActionData;
97− const result = await admin.setTerms(account.id, terms.value, staff.email);
98− if (!result.ok) return failed("terms", result.error.message, values);
99− return back("terms");
100− }
101−
102− if (intent === "attach") {
103− const values = fields(form, "workspace", "target");
104− const slug = parseSlug(account.kind === "enterprise" ? values.workspace : ownWorkspace(detail));
105− if (!slug.ok) return failed("members", slug.error, values);
106− let target = account.id;
107− let targetName = account.name;
108− if (account.kind === "workspace") {
109− const enterprise = (await admin.accounts()).find((row) => row.account.kind === "enterprise" && row.account.id === values.target);
110− if (!enterprise) return failed("enterprise", "Choose an enterprise to move onto.", values);
111− target = enterprise.account.id;
112− targetName = enterprise.account.name;
113− } else if (account.workspaces.includes(slug.value)) {
114− return failed("members", `${slug.value} is already on this enterprise.`, values);
115− }
116− if (!confirmed) {
117− return { review: { intent, workspace: slug.value, target, targetName, fields: values } } satisfies ActionData;
118− }
119− const result = await admin.attach(slug.value, target, staff.email);
120− if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message, values);
121− return back("attach");
122− }
123−
124− if (intent === "detach") {
125− const values = fields(form, "workspace");
126− const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail);
127− const onIt = account.kind === "enterprise" ? account.workspaces.includes(workspace) : detail.summary.limit.account !== account.id;
128− if (!onIt) return failed(account.kind === "enterprise" ? "members" : "enterprise", `${workspace} is not on an enterprise here.`);
129− const from = account.kind === "enterprise" ? account.name : detail.summary.limit.accountName;
130− if (!confirmed) return { review: { intent, workspace, from, fields: values } } satisfies ActionData;
131− const result = await admin.attach(workspace, null, staff.email);
132− if (!result.ok) return failed(account.kind === "enterprise" ? "members" : "enterprise", result.error.message);
133− return back("detach");
134− }
135−
136− if (intent === "credit") {
137− const values = fields(form, "workspace", "amount", "note", "confirmation");
138− const workspace = account.kind === "enterprise" ? values.workspace : ownWorkspace(detail);
139− if (account.kind === "enterprise" && !account.workspaces.includes(workspace)) {
140− return failed("credit", "Choose one of this account's workspaces.", values);
141− }
142− const amount = parseCredit(values.amount);
143− if (!amount.ok) return failed("credit", amount.error, values);
144− const note = parseNote(values.note);
145− if (!note.ok) return failed("credit", note.error, values);
146− if (values.confirmation !== workspace) {
147− return failed("credit", `Type the workspace's slug, ${workspace}, exactly, to issue the credit.`, { ...values, confirmation: "" });
148− }
149− const result = await admin.credit(workspace, amount.value, note.value, staff.email);
150− if (!result.ok) return failed("credit", result.error.message, values);
151− return back("credit");
152− }
153−
154− return failed("top", "Unknown action.");
155−}
156−
157−export default function Account({ loaderData, actionData }: Route.ComponentProps) {
158− const { detail, enterprises, done } = loaderData;
159− const { summary } = detail;
160− const { account, limit } = summary;
161− const { pathname } = useLocation();
162− const result = actionData as ActionData | undefined;
163− const review = result && "review" in result ? result.review : null;
164− const error = (section: string) => (result && "error" in result && result.section === section ? result : null);
165− const isEnterprise = account.kind === "enterprise";
166− const billedElsewhere = !isEnterprise && limit.account !== account.id;
167−
168− return (
169− <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
170− <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
171− <ArrowLeft size={14} />
172− Accounts
173− </Link>
174−
175− {/* Header */}
176− <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
177− <div className="flex min-w-0 items-start gap-3">
178− <Avatar name={account.name} size={40} />
179− <div className="min-w-0">
180− <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
181− <p className="mt-0.5 font-mono text-xs break-all text-faint">{account.id}</p>
182− <div className="mt-2 flex flex-wrap gap-1.5">
183− <KindBadge kind={account.kind} />
184− <TermsBadge terms={account.terms} />
185− <TrustBadge trust={limit.trust} />
186− <StateBadge state={limit.state} />
187− {billedElsewhere && <Badge tone="lavender">Billed through {limit.accountName}</Badge>}
188− </div>
189− </div>
190− </div>
191− <p className="text-xs text-faint">
192− Account since <When at={account.createdAt} />
193− </p>
194− </div>
195−
196− <div className="mt-6 space-y-3">
197− {done && <Notice tone="ok">{done}</Notice>}
198− {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
199− {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
200− {review && <ReviewPanel review={review} pathname={pathname} />}
201− </div>
202−
203− {/* This month */}
204− <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
205− <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
206− <p className="mb-2 text-xs text-muted">Unpaid exposure this month</p>
207− <ExposureBar limit={limit} wide />
208− <p className="mt-2 text-xs text-faint">
209− Trust ceiling {usd(limit.trustCeilingMicros)} · owner's spend limit {usd(limit.spendLimitMicros)}
210− {account.terms.ceilingMicros != null && <> · custom ceiling {usd(account.terms.ceilingMicros)}</>}
211− </p>
212− </div>
213− <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
214− <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
215− </div>
216−
217− <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
218− <div className="space-y-6">
219− <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
220−
221− {isEnterprise ? (
222− <MembersSection detail={detail} pathname={pathname} error={error("members")} />
223− ) : (
224− <EnterpriseSection
225− billedElsewhere={billedElsewhere}
226− limit={limit}
227− enterprises={enterprises}
228− pathname={pathname}
229− error={error("enterprise")}
230− />
231− )}
232−
233− <LedgerSection ledger={detail.ledger} />
234− </div>
235−
236− <div className="space-y-6">
237− <CreditForm
238− workspaces={isEnterprise ? account.workspaces : [limit.workspace]}
239− pathname={pathname}
240− error={error("credit")}
241− />
242− <AuditSection audit={detail.audit} />
243− </div>
244− </div>
245− </main>
246− );
247−}
248−
249−function Figure({ label, value, hint }: { label: string; value: string; hint?: string }) {
250− return (
251− <div className="rounded-lg border border-line bg-surface px-4 py-3">
252− <p className="text-xs text-muted">{label}</p>
253− <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
254− {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
255− </div>
256− );
257−}
258−
259−function Hidden({ values }: { values: Record<string, string> }) {
260− return (
261− <>
262− {Object.entries(values).map(([name, value]) => (
263− <input key={name} type="hidden" name={name} value={value} />
264− ))}
265− </>
266− );
267−}
268−
269−type SectionError = { error: string; values?: Record<string, string> } | null;
270−
271−// --- Confirmation ------------------------------------------------------------
272−
273−function describeTerms(terms: Terms): [string, string][] {
274− return [
275− ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
276− ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
277− ["Ceiling", terms.ceilingMicros == null ? "By trust" : usd(terms.ceilingMicros)],
278− ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
279− ["Note", terms.note || "—"],
280− ];
281−}
282−
283−function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
284− let title: string;
285− let body: ReactNode;
286− let danger = false;
287− if (review.intent === "terms") {
288− const before = describeTerms(review.before);
289− const after = describeTerms(review.after);
290− title = "Confirm the new terms";
291− danger = review.after.kind === "comped";
292− body = (
293− <>
294− <div className="overflow-x-auto">
295− <table className="w-full text-sm">
296− <thead>
297− <tr className="text-left text-xs text-muted">
298− <th className="py-1.5 pr-4 font-medium" />
299− <th className="py-1.5 pr-4 font-medium">Now</th>
300− <th className="py-1.5 font-medium">After</th>
301− </tr>
302− </thead>
303− <tbody>
304− {after.map(([label, value], index) => (
305− <tr key={label} className="border-t border-line align-top">
306− <td className="py-1.5 pr-4 text-muted">{label}</td>
307− <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
308− <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
309− </tr>
310− ))}
311− </tbody>
312− </table>
313− </div>
314− {review.after.kind === "comped" && (
315− <p className="mt-3 text-sm text-warn">Comped: nothing this account uses will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded at cost.</p>
316− )}
317− </>
318− );
319− } else if (review.intent === "attach") {
320− title = `Move ${review.workspace} onto ${review.targetName}?`;
321− body = (
322− <p className="text-sm text-muted">
323− From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
324− <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
325− </p>
326− );
327− } else {
328− title = `Move ${review.workspace} off ${review.from}?`;
329− danger = true;
330− body = (
331− <p className="text-sm text-muted">
332− <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, under its own terms and the
333− ceiling its trust gives it. It may stop at once if its own ceiling is lower than its exposure.
334− </p>
335− );
336− }
337− return (
338− <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"}`}>
339− <h2 className="font-semibold tracking-tight">{title}</h2>
340− <div className="mt-3">{body}</div>
341− <form method="post" action={`${pathname}#top`} className="mt-4 flex flex-wrap items-center gap-2">
342− <Hidden values={review.fields} />
343− <input type="hidden" name="intent" value={review.intent} />
344− <input type="hidden" name="confirm" value="yes" />
345− <Button type="submit" variant={danger ? "danger" : "lavender"}>
346− Confirm
347− </Button>
348− <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
349− Cancel
350− </Link>
351− </form>
352− </section>
353− );
354−}
355−
356−// --- Terms -------------------------------------------------------------------
357−
358−const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
359− { value: "standard", title: "Standard", text: "Published prices; the ceiling comes from trust." },
360− { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
361− { value: "custom", title: "Custom", text: "A discount, a custom ceiling, or both." },
362−];
363−
364−function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
365− const values = error?.values;
366− const kind = values?.kind ?? terms.kind;
367− return (
368− <Section
369− id="terms"
370− title="Terms"
371− description={
372− terms.setBy ? (
373− <>
374− Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
375− {terms.note && <> · “{terms.note}”</>}
376− </>
377− ) : (
378− "Standard terms, as every account starts."
379− )
380− }
381− >
382− <form method="post" action={`${pathname}#review`} className="space-y-4">
383− <input type="hidden" name="intent" value="terms" />
384− {error && <Notice tone="error">{error.error}</Notice>}
385− <fieldset>
386− <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
387− <div className="grid gap-2 sm:grid-cols-3">
388− {KINDS.map((option) => (
389− <label
390− key={option.value}
391− className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
392− >
393− <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
394− <span>
395− <span className="block text-sm font-medium">{option.title}</span>
396− <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
397− </span>
398− </label>
399− ))}
400− </div>
401− </fieldset>
402− <div className="grid gap-4 sm:grid-cols-3">
403− <Field label="Discount %" hint="Custom only.">
404− <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
405− </Field>
406− <Field label="Ceiling $" hint="Blank: trust decides.">
407− <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
408− </Field>
409− <Field label="Until" hint="Blank: no end. UTC.">
410− <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
411− </Field>
412− </div>
413− <Field label="Note" hint="Required. Why, for whoever looks next.">
414− <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
415− </Field>
416− <div className="flex justify-end">
417− <Button type="submit">Review terms</Button>
418− </div>
419− </form>
420− </Section>
421− );
422−}
423−
424−// --- Enterprise membership ----------------------------------------------------
425−
426−function MembersSection({ detail, pathname, error }: { detail: AccountDetail; pathname: string; error: SectionError }) {
427− const members: Limit[] = detail.workspaces;
428− const listed = new Set(members.map((member) => member.workspace));
429− // Any workspace the account names but no limit came back for.
430− const missing = detail.summary.account.workspaces.filter((slug) => !listed.has(slug));
431− return (
432− <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
433− {error && (
434− <div className="mb-4">
435− <Notice tone="error">{error.error}</Notice>
436− </div>
437− )}
438− {members.length + missing.length === 0 ? (
439− <EmptyState title="No workspaces yet">Add one below to bill it through this enterprise.</EmptyState>
440− ) : (
441− <ul className="divide-y divide-line rounded-md border border-line">
442− {members.map((member) => (
443− <li key={member.workspace} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
444− <div className="flex min-w-0 grow items-center gap-2.5">
445− <Avatar name={member.workspace} size={22} />
446− <div className="min-w-0">
447− <Link to={`/accounts/${encodeURIComponent(member.workspace)}`} className="font-mono text-sm hover:underline hover:underline-offset-4">
448− {member.workspace}
449− </Link>
450− <div className="mt-1 flex flex-wrap gap-1.5">
451− <TrustBadge trust={member.trust} />
452− <StateBadge state={member.state} />
453− </div>
454− {member.message && <p className="mt-1 text-xs text-muted">{member.message}</p>}
455− </div>
456− </div>
457− <div className="flex items-center gap-3 sm:w-64">
458− <ExposureBar limit={member} wide />
459− <DetachButton workspace={member.workspace} pathname={pathname} />
460− </div>
461− </li>
462− ))}
463− {missing.map((slug) => (
464− <li key={slug} className="flex items-center gap-3 p-3">
465− <span className="grow font-mono text-sm">{slug}</span>
466− <DetachButton workspace={slug} pathname={pathname} />
467− </li>
468− ))}
469− </ul>
470− )}
471− <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
472− <input type="hidden" name="intent" value="attach" />
473− <Field label="Add a workspace" className="grow">
474− <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
475− </Field>
476− <Button type="submit" variant="quiet">
477− <Plus size={14} />
478− Add
479− </Button>
480− </form>
481− </Section>
482− );
483−}
484−
485−function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
486− return (
487− <form method="post" action={`${pathname}#review`} className="shrink-0">
488− <input type="hidden" name="intent" value="detach" />
489− <input type="hidden" name="workspace" value={workspace} />
490− <button
491− type="submit"
492− aria-label={`Remove ${workspace}`}
493− title={`Remove ${workspace}`}
494− className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
495− >
496− <Trash2 size={14} />
497− </button>
498− </form>
499− );
500−}
501−
502−function EnterpriseSection({
503− billedElsewhere,
504− limit,
505− enterprises,
506− pathname,
507− error,
508−}: {
509− billedElsewhere: boolean;
510− limit: Limit;
511− enterprises: { id: string; name: string }[];
512− pathname: string;
513− error: SectionError;
514−}) {
515− return (
516− <Section id="enterprise" title="Enterprise" description="Whether another account pays for this workspace.">
517− {error && (
518− <div className="mb-4">
519− <Notice tone="error">{error.error}</Notice>
520− </div>
521− )}
522− {billedElsewhere ? (
523− <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
524− <p className="text-sm text-muted">
525− Billed through{" "}
526− <Link to={`/accounts/${encodeURIComponent(limit.account)}`} className="text-fg hover:underline hover:underline-offset-4">
527− {limit.accountName}
528− </Link>
529− , under its limit and terms.
530− </p>
531− <form method="post" action={`${pathname}#review`}>
532− <input type="hidden" name="intent" value="detach" />
533− <Button type="submit" variant="danger">
534− <LogOut size={14} />
535− Move off
536− </Button>
537− </form>
538− </div>
539− ) : enterprises.length === 0 ? (
540− <p className="text-sm text-muted">
541− Pays for itself. There are no enterprises to move it onto yet;{" "}
542− <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
543− create one
544− </Link>
545− .
546− </p>
547− ) : (
548− <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
549− <input type="hidden" name="intent" value="attach" />
550− <Field label="Pays for itself. Move onto" className="grow">
551− <Select name="target" required defaultValue={error?.values?.target ?? ""}>
552− <option value="" disabled>
553− Choose an enterprise
554− </option>
555− {enterprises.map((enterprise) => (
556− <option key={enterprise.id} value={enterprise.id}>
557− {enterprise.name} ({enterprise.id})
558− </option>
559− ))}
560− </Select>
561− </Field>
562− <Button type="submit" variant="quiet">
563− <Building2 size={14} />
564− Move
565− </Button>
566− </form>
567− )}
568− </Section>
569− );
570−}
571−
572−// --- Credit -------------------------------------------------------------------
573−
574−function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
575− const values = error?.values;
576− const single = workspaces.length === 1 ? workspaces[0] : null;
577− return (
578− <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
579− {workspaces.length === 0 ? (
580− <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
581− ) : (
582− <form method="post" action={`${pathname}#credit`} className="space-y-4">
583− <input type="hidden" name="intent" value="credit" />
584− {error && <Notice tone="error">{error.error}</Notice>}
585− {single ? (
586− <input type="hidden" name="workspace" value={single} />
587− ) : (
588− <Field label="Workspace">
589− <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
590− <option value="" disabled>
591− Choose a workspace
592− </option>
593− {workspaces.map((slug) => (
594− <option key={slug} value={slug}>
595− {slug}
596− </option>
597− ))}
598− </Select>
599− </Field>
600− )}
601− <Field label="Amount $" hint="Up to $10,000 at a time.">
602− <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
603− </Field>
604− <Field label="Note" hint="Required. Shown on the workspace's statement.">
605− <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
606− </Field>
607− <Field
608− label="Confirm"
609− hint={
610− <>
611− Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
612− </>
613− }
614− >
615− <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
616− </Field>
617− <div className="flex justify-end">
618− <Button type="submit" variant="lavender">
619− <Gift size={14} />
620− Issue credit
621− </Button>
622− </div>
623− </form>
624− )}
625− </Section>
626− );
627−}
628−
629−// --- Ledger and audit ---------------------------------------------------------
630−
631−const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
632−
633−function LedgerSection({ ledger }: { ledger: LedgerEntry[] }) {
634− return (
635− <Section title="Ledger" description="Recent lines of the statement, newest first.">
636− {ledger.length === 0 ? (
637− <EmptyState title="Nothing yet" />
638− ) : (
639− <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
640− <table className="w-full min-w-[36rem] text-sm">
641− <thead>
642− <tr className="border-b border-line text-left text-xs text-muted">
643− <th className="px-4 py-2 font-medium sm:pl-5">When</th>
644− <th className="px-4 py-2 font-medium">What</th>
645− <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
646− </tr>
647− </thead>
648− <tbody>
649− {ledger.map((entry) => (
650− <tr key={entry.id} className="border-b border-line align-top last:border-0">
651− <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
652− <When at={entry.createdAt} time />
653− </td>
654− <td className="px-4 py-2.5">
655− <div className="flex flex-wrap items-center gap-1.5">
656− <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
657− <span className="break-words">{entry.description}</span>
658− </div>
659− <p className="mt-0.5 font-mono text-xs text-faint">
660− {[
661− entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
662− entry.task,
663− entry.model,
664− entry.billedTo === "workspace" ? "own provider" : null,
665− entry.createdBy && `by ${entry.createdBy}`,
666− ]
667− .filter(Boolean)
668− .join(" · ")}
669− </p>
670− </td>
671− <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
672− {usd(entry.amountMicros, { signed: true })}
673− </td>
674− </tr>
675− ))}
676− </tbody>
677− </table>
678− </div>
679− )}
680− </Section>
681− );
682−}
683−
684−function AuditSection({ audit }: { audit: AccountDetail["audit"] }) {
685− return (
686− <Section title="Audit log" description="Every change made in sudo, and by whom.">
687− {audit.length === 0 ? (
688− <p className="flex items-center gap-2 text-sm text-muted">
689− <ScrollText size={14} />
690− No changes yet.
691− </p>
692− ) : (
693− <ol className="space-y-3">
694− {audit.map((entry) => (
695− <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
696− <p className="flex flex-wrap items-center gap-x-2 text-sm">
697− <span className="font-mono font-medium text-merged">{entry.action}</span>
698− <span className="text-xs text-faint">
699− <When at={entry.createdAt} time />
700− </span>
701− </p>
702− {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
703− <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
704− <UserRound size={11} />
705− {entry.by}
706− </p>
707− </li>
708− ))}
709− </ol>
710− )}
711− </Section>
712− );
713−}
+0−252
1−import { Building2, ChevronRight, Search } from "lucide-react";
2−import { Link } from "react-router";
3−
4−import type { AccountSummary } from "@g1t/contracts";
5−
6−import type { Route } from "./+types/accounts";
7−import {
8− Avatar,
9− Button,
10− ButtonLink,
11− EmptyState,
12− ExposureBar,
13− KindBadge,
14− Stat,
15− TermsBadge,
16− TrustBadge,
17−} from "~/components/ui";
18−import { usd } from "~/lib/money";
19−import { admin } from "~/lib/services.server";
20−import { requireStaff } from "~/lib/staff";
21−
22−export const meta: Route.MetaFunction = () => [{ title: "Accounts · sudo" }, { name: "robots", content: "noindex, nofollow" }];
23−
24−const FILTERS = {
25− attention: { label: "Stopped or warning", test: (row: AccountSummary) => row.limit.state !== "ok" },
26− terms: { label: "Comped or custom", test: (row: AccountSummary) => row.account.terms.kind !== "standard" },
27− enterprise: { label: "Enterprises", test: (row: AccountSummary) => row.account.kind === "enterprise" },
28−} as const;
29−
30−type Filter = keyof typeof FILTERS;
31−
32−const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const;
33−
34−export async function loader({ request, context }: Route.LoaderArgs) {
35− requireStaff(context);
36− const url = new URL(request.url);
37− const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100);
38− const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS);
39−
40− const all = await admin.accounts(q || undefined);
41− const rows = all
42− .filter((row) => show.every((filter) => FILTERS[filter].test(row)))
43− .sort(
44− (a, b) =>
45− SEVERITY[a.limit.state] - SEVERITY[b.limit.state] ||
46− b.limit.exposureMicros - a.limit.exposureMicros ||
47− a.account.name.localeCompare(b.account.name),
48− );
49−
50− const sum = (pick: (row: AccountSummary) => number) => all.reduce((total, row) => total + pick(row), 0);
51− return {
52− q,
53− show,
54− rows,
55− total: all.length,
56− totals: {
57− charged: sum((row) => row.chargedMicros),
58− cost: sum((row) => row.costMicros),
59− paid: sum((row) => row.paidMicros),
60− exposure: sum((row) => row.limit.exposureMicros),
61− stopped: all.filter((row) => row.limit.state === "stopped").length,
62− warning: all.filter((row) => row.limit.state === "warning").length,
63− },
64− };
65−}
66−
67−function accountHref(row: AccountSummary) {
68− return `/accounts/${encodeURIComponent(row.account.id)}`;
69−}
70−
71−export default function Accounts({ loaderData }: Route.ComponentProps) {
72− const { q, show, rows, total, totals } = loaderData;
73− const margin = totals.charged - totals.cost;
74− const filtered = q !== "" || show.length > 0;
75−
76− return (
77− <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
78− <div className="flex flex-wrap items-end justify-between gap-4">
79− <div>
80− <h1 className="text-2xl font-semibold tracking-tight">Accounts</h1>
81− <p className="mt-1 text-sm text-muted">Every account that pays, and where it stands this month.</p>
82− </div>
83− <ButtonLink to="/enterprises/new" variant="lavender">
84− <Building2 size={15} />
85− New enterprise
86− </ButtonLink>
87− </div>
88−
89− <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
90− <Stat label="Charged this month" value={usd(totals.charged)} hint={`${total} account${total === 1 ? "" : "s"}`} />
91− <Stat label="Cost to g1t" value={usd(totals.cost)} hint={`Margin ${usd(margin)}`} tone={margin < 0 ? "danger" : undefined} />
92− <Stat label="Unpaid exposure" value={usd(totals.exposure)} hint={`Paid ever ${usd(totals.paid)}`} />
93− <Stat
94− label="Needs attention"
95− value={`${totals.stopped} stopped`}
96− hint={`${totals.warning} near the ceiling`}
97− tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"}
98− />
99− </div>
100−
101− <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center">
102− <div className="relative grow">
103− <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
104− <input
105− type="search"
106− name="q"
107− defaultValue={q}
108− placeholder="Search by workspace, account id or enterprise"
109− aria-label="Search accounts"
110− autoComplete="off"
111− data-1p-ignore
112− className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60"
113− />
114− </div>
115− <fieldset className="flex flex-wrap items-center gap-2">
116− <legend className="sr-only">Show only</legend>
117− {(Object.keys(FILTERS) as Filter[]).map((key) => (
118− <label
119− key={key}
120− className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-line px-3 py-1.5 text-xs text-muted transition-colors select-none hover:border-line-strong has-checked:border-merged/50 has-checked:bg-merged/10 has-checked:text-merged"
121− >
122− <input type="checkbox" name="show" value={key} defaultChecked={show.includes(key)} className="size-3.5" />
123− {FILTERS[key].label}
124− </label>
125− ))}
126− <Button type="submit" variant="quiet" className="py-1.5">
127− Apply
128− </Button>
129− {filtered && (
130− <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
131− Clear
132− </Link>
133− )}
134− </fieldset>
135− </form>
136−
137− <p className="mt-4 text-xs text-faint">
138− {rows.length === total ? `${total} accounts` : `${rows.length} of ${total} accounts`}
139− {q && (
140− <>
141− {" "}
142− matching <span className="font-mono text-muted">{q}</span>
143− </>
144− )}
145− . Stopped and warning first, then by exposure.
146− </p>
147−
148− {rows.length === 0 ? (
149− <div className="mt-3">
150− <EmptyState title={filtered ? "No accounts match" : "No accounts yet"}>
151− {filtered ? "Try another search, or clear the filters." : "Accounts appear once a workspace exists."}
152− </EmptyState>
153− </div>
154− ) : (
155− <>
156− {/* Phones: one card per account. */}
157− <ul className="mt-3 space-y-2 md:hidden">
158− {rows.map((row) => (
159− <li key={row.account.id}>
160− <Link to={accountHref(row)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
161− <div className="flex items-start justify-between gap-3">
162− <AccountName row={row} />
163− <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
164− </div>
165− <div className="mt-3">
166− <ExposureBar limit={row.limit} wide />
167− </div>
168− <dl className="tabular mt-3 grid grid-cols-3 gap-2 text-xs">
169− <Figure label="Charged" value={usd(row.chargedMicros)} />
170− <Figure label="Cost" value={usd(row.costMicros)} />
171− <Figure label="Paid ever" value={usd(row.paidMicros)} />
172− </dl>
173− </Link>
174− </li>
175− ))}
176− </ul>
177−
178− {/* Wider screens: a table. */}
179− <div className="mt-3 hidden overflow-x-auto rounded-lg border border-line md:block">
180− <table className="w-full text-sm">
181− <thead>
182− <tr className="border-b border-line bg-surface text-left text-xs text-muted">
183− <th className="px-4 py-2.5 font-medium">Account</th>
184− <th className="px-4 py-2.5 font-medium">Trust</th>
185− <th className="px-4 py-2.5 font-medium">Exposure / ceiling</th>
186− <th className="px-4 py-2.5 text-right font-medium">Charged</th>
187− <th className="px-4 py-2.5 text-right font-medium">Cost to g1t</th>
188− <th className="px-4 py-2.5 text-right font-medium">Paid ever</th>
189− </tr>
190− </thead>
191− <tbody>
192− {rows.map((row) => (
193− <tr key={row.account.id} className="border-b border-line last:border-0 hover:bg-surface/60">
194− <td className="px-4 py-3">
195− <Link to={accountHref(row)} className="group block">
196− <AccountName row={row} />
197− </Link>
198− </td>
199− <td className="px-4 py-3">
200− <TrustBadge trust={row.limit.trust} />
201− </td>
202− <td className="px-4 py-3">
203− <ExposureBar limit={row.limit} />
204− </td>
205− <td className="tabular px-4 py-3 text-right">{usd(row.chargedMicros)}</td>
206− <td className="tabular px-4 py-3 text-right text-muted">{usd(row.costMicros)}</td>
207− <td className="tabular px-4 py-3 text-right text-muted">{usd(row.paidMicros)}</td>
208− </tr>
209− ))}
210− </tbody>
211− </table>
212− </div>
213− </>
214− )}
215− </main>
216− );
217−}
218−
219−function AccountName({ row }: { row: AccountSummary }) {
220− const { account } = row;
221− const members = account.workspaces.length;
222− return (
223− <div className="flex min-w-0 items-start gap-2.5">
224− <span className="mt-0.5">
225− <Avatar name={account.name} size={22} />
226− </span>
227− <div className="min-w-0">
228− <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{account.name}</p>
229− <p className="truncate font-mono text-xs text-faint">
230− {account.id}
231− {account.kind === "enterprise" && ` · ${members} workspace${members === 1 ? "" : "s"}`}
232− </p>
233− <div className="mt-1.5 flex flex-wrap gap-1.5">
234− <KindBadge kind={account.kind} />
235− <TermsBadge terms={account.terms} />
236− <span className="md:hidden">
237− <TrustBadge trust={row.limit.trust} />
238− </span>
239− </div>
240− </div>
241− </div>
242− );
243−}
244−
245−function Figure({ label, value }: { label: string; value: string }) {
246− return (
247− <div>
248− <dt className="text-faint">{label}</dt>
249− <dd className="mt-0.5 text-fg-soft">{value}</dd>
250− </div>
251− );
252−}
+235−0
1+import { ArrowLeft, Plus, Trash2 } from "lucide-react";
2+import { data, Link, redirect, useLocation } from "react-router";
3+
4+import { type AdminOwner, type Limit, httpStatus } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/enterprise";
7+import { AuditSection, CreditForm, Figure, LedgerSection, ReviewPanel, TermsForm } from "~/components/billing";
8+import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Input, Notice, Section, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
9+import { type Subject, billingAction } from "~/lib/billing-actions.server";
10+import { usd } from "~/lib/money";
11+import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
12+import { admin, identity } from "~/lib/services.server";
13+import { requireStaff } from "~/lib/staff";
14+import { legacyAccountPath } from "~/lib/workspaces";
15+
16+export const meta: Route.MetaFunction = ({ loaderData }) => [
17+ { title: `${loaderData?.detail.summary.account.name ?? "Enterprise"} · sudo` },
18+ { name: "robots", content: "noindex, nofollow" },
19+];
20+
21+const ENTERPRISE_ID = /^ent_[a-z0-9_-]{1,80}$/;
22+
23+async function load(raw: string) {
24+ const id = raw.trim().toLowerCase();
25+ if (!ENTERPRISE_ID.test(id)) {
26+ // A workspace's id or slug: its own page.
27+ const path = legacyAccountPath(id);
28+ if (path) throw redirect(path);
29+ throw data("That is not an enterprise.", { status: 404 });
30+ }
31+ const result = await admin.account(id);
32+ if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
33+ const detail = result.value;
34+ const { account } = detail.summary;
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 };
37+ return { detail, subject };
38+}
39+
40+export async function loader({ request, params, context }: Route.LoaderArgs) {
41+ requireStaff(context);
42+ const { detail } = await load(params.id);
43+ const slugs = detail.summary.account.workspaces;
44+ const listed = await identity.workspaces();
45+ const people = new Map(listed.map((workspace) => [workspace.slug, { name: workspace.name, owners: workspace.owners }]));
46+ // Members older than identity's list: look them up one by one.
47+ const missing = slugs.filter((slug) => !people.has(slug)).slice(0, 25);
48+ for (const found of await Promise.all(missing.map((slug) => identity.workspace(slug)))) {
49+ if (found) {
50+ people.set(found.slug, {
51+ name: found.name,
52+ owners: found.members.filter((member) => member.role === "owner").map(({ username, email }) => ({ username, email })),
53+ });
54+ }
55+ }
56+ const limits = new Map(detail.workspaces.map((limit) => [limit.workspace, limit]));
57+ const shares = new Map((detail.summary.byWorkspace ?? []).map((share) => [share.workspace, share]));
58+ const members = slugs.map((slug) => ({
59+ slug,
60+ name: people.get(slug)?.name ?? null,
61+ owners: people.get(slug)?.owners ?? ([] as AdminOwner[]),
62+ limit: limits.get(slug) ?? null,
63+ chargedMicros: shares.get(slug)?.chargedMicros ?? 0,
64+ }));
65+ return { detail, members, done: doneMessage(request.url) };
66+}
67+
68+export async function action({ request, params, context }: Route.ActionArgs) {
69+ const staff = requireStaff(context);
70+ // What is acted on comes from billing, not from the form.
71+ const { subject } = await load(params.id);
72+ return billingAction(request, staff, subject, `/enterprises/${encodeURIComponent(subject.accountId)}`);
73+}
74+
75+type Member = Route.ComponentProps["loaderData"]["members"][number];
76+
77+export default function Enterprise({ loaderData, actionData }: Route.ComponentProps) {
78+ const { detail, members, done } = loaderData;
79+ const { summary } = detail;
80+ const { account, limit } = summary;
81+ const { pathname } = useLocation();
82+ const result = actionData as ActionData | undefined;
83+ const review = result && "review" in result ? result.review : null;
84+ const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
85+
86+ return (
87+ <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
88+ <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
89+ <ArrowLeft size={14} />
90+ Enterprises
91+ </Link>
92+
93+ <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
94+ <div className="flex min-w-0 items-start gap-3">
95+ <Avatar name={account.name} size={40} />
96+ <div className="min-w-0">
97+ <h1 className="truncate text-2xl font-semibold tracking-tight">{account.name}</h1>
98+ <p className="mt-0.5 text-xs text-faint">
99+ Enterprise since <When at={account.createdAt} /> · pays for {members.length} workspace{members.length === 1 ? "" : "s"}
100+ </p>
101+ <div className="mt-2 flex flex-wrap gap-1.5">
102+ <Badge tone="lavender">Enterprise</Badge>
103+ <TermsBadge terms={account.terms} />
104+ {account.terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
105+ <StateBadge state={limit.state} />
106+ </div>
107+ </div>
108+ </div>
109+ <p className="text-xs text-faint">
110+ Billing account id <span className="font-mono">{account.id}</span>
111+ </p>
112+ </div>
113+
114+ <div className="mt-6 space-y-3">
115+ {done && <Notice tone="ok">{done}</Notice>}
116+ {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
117+ {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
118+ {review && <ReviewPanel review={review} pathname={pathname} />}
119+ </div>
120+
121+ <div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
122+ <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
123+ <p className="mb-2 text-xs text-muted">Usage this month against the limit, all workspaces together</p>
124+ <ExposureBar limit={limit} wide />
125+ <p className="mt-2 text-xs text-faint">
126+ Limit from trust {usd(limit.trustCeilingMicros)}
127+ {account.terms.ceilingMicros != null && <> · custom limit {usd(account.terms.ceilingMicros)}</>}
128+ </p>
129+ </div>
130+ <Figure label="Charged this month" value={usd(summary.chargedMicros)} hint={`Cost to g1t ${usd(summary.costMicros)}`} />
131+ <Figure label="Paid ever" value={usd(summary.paidMicros)} hint={`Margin this month ${usd(summary.chargedMicros - summary.costMicros)}`} />
132+ </div>
133+
134+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
135+ <div className="space-y-6">
136+ <MembersSection members={members} pathname={pathname} error={error("members")} />
137+ <TermsForm terms={account.terms} pathname={pathname} error={error("terms")} />
138+ <LedgerSection ledger={detail.ledger} showWorkspace description="Recent lines for every workspace it pays for, newest first." />
139+ </div>
140+ <div className="space-y-6">
141+ <CreditForm workspaces={account.workspaces} pathname={pathname} error={error("credit")} />
142+ <AuditSection audit={detail.audit} />
143+ </div>
144+ </div>
145+ </main>
146+ );
147+}
148+
149+function MembersSection({ members, pathname, error }: { members: Member[]; pathname: string; error: SectionError }) {
150+ return (
151+ <Section id="members" title="Workspaces" description="Billed together: one bill, one limit, one set of terms.">
152+ {error && (
153+ <div className="mb-4">
154+ <Notice tone="error">{error.error}</Notice>
155+ </div>
156+ )}
157+ {members.length === 0 ? (
158+ <EmptyState title="No workspaces yet">Add one below to bill it to this enterprise.</EmptyState>
159+ ) : (
160+ <ul className="divide-y divide-line rounded-md border border-line">
161+ {members.map((member) => (
162+ <li key={member.slug} className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center">
163+ <div className="flex min-w-0 grow items-start gap-2.5">
164+ <Avatar name={member.slug} size={22} />
165+ <div className="min-w-0">
166+ <Link to={`/workspaces/${encodeURIComponent(member.slug)}`} className="font-medium hover:underline hover:underline-offset-4">
167+ {member.name ?? member.slug}
168+ </Link>
169+ <p className="truncate text-xs text-faint">
170+ <span className="font-mono">{member.slug}</span>
171+ {member.owners.length > 0 && (
172+ <>
173+ {" · "}
174+ {member.owners.map((owner, index) => (
175+ <span key={owner.username} title={owner.email ?? "No email"}>
176+ {index > 0 && ", "}
177+ {owner.username}
178+ </span>
179+ ))}
180+ </>
181+ )}
182+ </p>
183+ {member.limit?.message && <p className="mt-1 text-xs text-muted">{member.limit.message}</p>}
184+ </div>
185+ </div>
186+ <div className="flex items-center justify-between gap-3 sm:w-56 sm:justify-end">
187+ <p className="tabular text-right text-xs text-muted">
188+ Charged <span className="text-fg-soft">{usd(member.chargedMicros)}</span>
189+ {member.limit && <MemberState limit={member.limit} />}
190+ </p>
191+ <DetachButton workspace={member.slug} pathname={pathname} />
192+ </div>
193+ </li>
194+ ))}
195+ </ul>
196+ )}
197+ <form method="post" action={`${pathname}#review`} className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
198+ <input type="hidden" name="intent" value="attach" />
199+ <Field label="Add a workspace" className="grow">
200+ <Input name="workspace" required placeholder="workspace-slug" defaultValue={error?.values?.workspace ?? ""} className="font-mono" />
201+ </Field>
202+ <Button type="submit" variant="quiet">
203+ <Plus size={14} />
204+ Add
205+ </Button>
206+ </form>
207+ </Section>
208+ );
209+}
210+
211+function MemberState({ limit }: { limit: Limit }) {
212+ if (limit.state === "ok") return null;
213+ return (
214+ <span className="mt-1 block">
215+ <StateBadge state={limit.state} />
216+ </span>
217+ );
218+}
219+
220+function DetachButton({ workspace, pathname }: { workspace: string; pathname: string }) {
221+ return (
222+ <form method="post" action={`${pathname}#review`} className="shrink-0">
223+ <input type="hidden" name="intent" value="detach" />
224+ <input type="hidden" name="workspace" value={workspace} />
225+ <button
226+ type="submit"
227+ aria-label={`Remove ${workspace}`}
228+ title={`Remove ${workspace}`}
229+ className="rounded-md border border-line p-2 text-muted transition-colors hover:border-danger/50 hover:text-danger"
230+ >
231+ <Trash2 size={14} />
232+ </button>
233+ </form>
234+ );
235+}
+115−0
1+import { Building2, ChevronRight } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { AdminOwner } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/enterprises";
7+import { Avatar, ButtonLink, EmptyState, ExposureBar, Stat, StateBadge, TermsBadge, When } from "~/components/ui";
8+import { usd } from "~/lib/money";
9+import { admin, identity } from "~/lib/services.server";
10+import { requireStaff } from "~/lib/staff";
11+
12+export const meta: Route.MetaFunction = () => [{ title: "Enterprises · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13+
14+export async function loader({ context }: Route.LoaderArgs) {
15+ requireStaff(context);
16+ const [accounts, workspaces] = await Promise.all([admin.accounts(), identity.workspaces()]);
17+ const owners = new Map<string, AdminOwner[]>(workspaces.map((workspace) => [workspace.slug, workspace.owners]));
18+ const enterprises = accounts
19+ .filter((row) => row.account.kind === "enterprise")
20+ .sort((a, b) => a.account.name.localeCompare(b.account.name))
21+ .map((row) => ({
22+ ...row,
23+ members: row.account.workspaces.map((slug) => ({ slug, owners: owners.get(slug) ?? [] })),
24+ }));
25+ const sum = (pick: (row: (typeof enterprises)[number]) => number) => enterprises.reduce((total, row) => total + pick(row), 0);
26+ return {
27+ enterprises,
28+ totals: {
29+ charged: sum((row) => row.chargedMicros),
30+ cost: sum((row) => row.costMicros),
31+ workspaces: sum((row) => row.account.workspaces.length),
32+ },
33+ };
34+}
35+
36+export default function Enterprises({ loaderData }: Route.ComponentProps) {
37+ const { enterprises, totals } = loaderData;
38+ return (
39+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
40+ <div className="flex flex-wrap items-end justify-between gap-4">
41+ <div>
42+ <h1 className="text-2xl font-semibold tracking-tight">Enterprises</h1>
43+ <p className="mt-1 text-sm text-muted">Each pays for several workspaces: one bill, one limit, one set of terms.</p>
44+ </div>
45+ <ButtonLink to="/enterprises/new" variant="lavender">
46+ <Building2 size={15} />
47+ New enterprise
48+ </ButtonLink>
49+ </div>
50+
51+ <div className="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-3">
52+ <Stat label="Enterprises" value={String(enterprises.length)} hint={`Paying for ${totals.workspaces} workspace${totals.workspaces === 1 ? "" : "s"}`} />
53+ <Stat label="Charged this month" value={usd(totals.charged)} />
54+ <Stat label="Cost to g1t" value={usd(totals.cost)} hint={`Margin ${usd(totals.charged - totals.cost)}`} tone={totals.charged < totals.cost ? "danger" : undefined} />
55+ </div>
56+
57+ {enterprises.length === 0 ? (
58+ <div className="mt-6">
59+ <EmptyState title="No enterprises yet">Create one to bill several workspaces together.</EmptyState>
60+ </div>
61+ ) : (
62+ <ul className="mt-6 space-y-3">
63+ {enterprises.map((row) => (
64+ <li key={row.account.id}>
65+ <Link
66+ to={`/enterprises/${encodeURIComponent(row.account.id)}`}
67+ className="group grid gap-4 rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong md:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)] md:items-start"
68+ >
69+ <div className="flex min-w-0 items-start gap-2.5">
70+ <Avatar name={row.account.name} size={28} />
71+ <div className="min-w-0">
72+ <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.account.name}</p>
73+ <p className="truncate text-xs text-faint">
74+ Since <When at={row.account.createdAt} />
75+ <span className="font-mono"> · {row.account.id}</span>
76+ </p>
77+ <div className="mt-1.5 flex flex-wrap gap-1.5">
78+ <TermsBadge terms={row.account.terms} />
79+ <StateBadge state={row.limit.state} />
80+ </div>
81+ </div>
82+ </div>
83+ <div className="min-w-0">
84+ <p className="text-xs text-muted">
85+ {row.members.length} workspace{row.members.length === 1 ? "" : "s"}
86+ </p>
87+ <ul className="mt-1 space-y-0.5 text-sm">
88+ {row.members.slice(0, 5).map((member) => (
89+ <li key={member.slug} className="truncate">
90+ <span className="font-mono">{member.slug}</span>
91+ {member.owners.length > 0 && (
92+ <span className="text-xs text-faint"> · {member.owners.map((owner) => owner.username).join(", ")}</span>
93+ )}
94+ </li>
95+ ))}
96+ {row.members.length > 5 && <li className="text-xs text-faint">and {row.members.length - 5} more</li>}
97+ </ul>
98+ </div>
99+ <div className="flex items-start justify-between gap-3">
100+ <div className="min-w-0 grow">
101+ <ExposureBar limit={row.limit} wide />
102+ <p className="tabular mt-2 text-xs text-faint">
103+ Charged {usd(row.chargedMicros)} · cost {usd(row.costMicros)}
104+ </p>
105+ </div>
106+ <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
107+ </div>
108+ </Link>
109+ </li>
110+ ))}
111+ </ul>
112+ )}
113+ </main>
114+ );
115+}
+22−0
1+import { data, redirect } from "react-router";
2+
3+import type { Route } from "./+types/legacy-accounts";
4+import { requireStaff } from "~/lib/staff";
5+import { legacyAccountPath } from "~/lib/workspaces";
6+
7+/**
8+ * Old links: `/accounts` was the list, `/accounts/ws_<slug>` a workspace's
9+ * own account and `/accounts/ent_…` an enterprise.
10+ */
11+export async function loader({ request, params, context }: Route.LoaderArgs) {
12+ requireStaff(context);
13+ const id = (params["*"] ?? "").replace(/\/+$/, "");
14+ if (!id) return redirect(`/${new URL(request.url).search}`, 301);
15+ const path = legacyAccountPath(id);
16+ if (!path) throw data("That is not a workspace or an enterprise.", { status: 404 });
17+ return redirect(path, 301);
18+}
19+
20+export default function LegacyAccounts() {
21+ return null;
22+}
+9−6
44 import type { Route } from "./+types/new-enterprise";
55 import { Avatar, Button, Field, Input, Notice, Section, Textarea } from "~/components/ui";
66 import { fields, parseSlugList, text } from "~/lib/forms";
7−import { admin } from "~/lib/services.server";
7+import { admin, identity } from "~/lib/services.server";
88 import { requireStaff } from "~/lib/staff";
99
1010 export const meta: Route.MetaFunction = () => [{ title: "New enterprise · sudo" }, { name: "robots", content: "noindex, nofollow" }];
3131 if (!workspaces.ok) return fail(workspaces.error);
3232 if (workspaces.value.length === 0) return fail("Name at least one workspace for it to pay for.");
3333 if (workspaces.value.length > 100) return fail("At most 100 workspaces at once.");
34+ const found = await Promise.all(workspaces.value.map((slug) => identity.workspace(slug)));
35+ const unknown = workspaces.value.filter((_, index) => !found[index]);
36+ if (unknown.length > 0) return fail(`No workspace called ${unknown.join(", ")}.`);
3437
3538 // Moving workspaces onto it changes who pays for them: confirm first.
3639 if (text(form, "confirm") !== "yes") {
3841 }
3942 const result = await admin.createEnterprise(name, workspaces.value, staff.email);
4043 if (!result.ok) return fail(result.error.message);
41− return redirect(`/accounts/${encodeURIComponent(result.value.id)}?done=created#top`);
44+ return redirect(`/enterprises/${encodeURIComponent(result.value.id)}?done=created#top`);
4245 }
4346
4447 export default function NewEnterprise({ actionData }: Route.ComponentProps) {
4952
5053 return (
5154 <main className="mx-auto max-w-2xl px-4 py-8 sm:py-10">
52− <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
55+ <Link to="/enterprises" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
5356 <ArrowLeft size={14} />
54− Accounts
57+ Enterprises
5558 </Link>
5659 <h1 className="mt-4 text-2xl font-semibold tracking-tight">New enterprise</h1>
5760 <p className="mt-1 text-sm text-muted">
58− One account that pays for several workspaces, as GitHub Enterprise does: one bill, one limit, one set of terms. Set its terms
61+ One customer that pays for several workspaces, as GitHub Enterprise does: one bill, one limit, one set of terms. Set its terms
5962 once it exists.
6063 </p>
6164
6366 <section id="review" className="mt-6 scroll-mt-20 rounded-lg border border-merged/40 bg-merged/5 p-4 sm:p-5">
6467 <h2 className="font-semibold tracking-tight">Create {review.name}?</h2>
6568 <p className="mt-1 text-sm text-muted">
66− These workspaces will be billed through it from now on, under its limit and terms instead of their own:
69+ These workspaces will be billed to it from now on, under its limit and terms instead of their own:
6770 </p>
6871 <ul className="mt-3 flex flex-wrap gap-2">
6972 {review.workspaces.map((slug) => (
+309−0
1+import { ArrowLeft, Building2, LogOut } from "lucide-react";
2+import { data, Link, useLocation } from "react-router";
3+
4+import { type AccountSummary, type AdminAction, httpStatus } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/workspace";
7+import {
8+ AuditSection,
9+ BillingLinkSection,
10+ CreditForm,
11+ Figure,
12+ LedgerSection,
13+ Owners,
14+ ReviewPanel,
15+ TermsForm,
16+} from "~/components/billing";
17+import { Avatar, Badge, Button, EmptyState, ExposureBar, Field, Notice, Section, Select, StateBadge, TermsBadge, TrustBadge, When } from "~/components/ui";
18+import { type Subject, billingAction } from "~/lib/billing-actions.server";
19+import { parseSlug } from "~/lib/forms";
20+import { usd } from "~/lib/money";
21+import { type ActionData, type SectionError, doneMessage } from "~/lib/review";
22+import { admin, identity } from "~/lib/services.server";
23+import { requireStaff } from "~/lib/staff";
24+import type { Enterprise } from "~/lib/workspaces";
25+
26+export const meta: Route.MetaFunction = ({ loaderData }) => [
27+ { title: `${loaderData?.name ?? "Workspace"} · sudo` },
28+ { name: "robots", content: "noindex, nofollow" },
29+];
30+
31+/** Whether an audit line is about `slug`, in an enterprise's log. */
32+function mentions(action: AdminAction, slug: string): boolean {
33+ // A slug is letters, digits and hyphens, none special in a pattern.
34+ return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
35+}
36+
37+async function load(raw: string) {
38+ const parsed = parseSlug(raw);
39+ if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
40+ const slug = parsed.value;
41+ const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
42+ if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
43+ const detail = result.value;
44+ const { account } = detail.summary;
45+ const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
46+ const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
47+ if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
48+ const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
49+ return { slug, person, detail, billedTo, subject };
50+}
51+
52+export async function loader({ request, params, context }: Route.LoaderArgs) {
53+ requireStaff(context);
54+ const { slug, person, detail, billedTo, subject } = await load(params.slug);
55+ const { summary } = detail;
56+ // On an enterprise, the limit is the enterprise's and the figures are
57+ // this workspace's share of its bill.
58+ const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
59+ const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
60+ const figures = billedTo
61+ ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
62+ : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
63+ const enterprises = billedTo
64+ ? []
65+ : (await admin.accounts())
66+ .filter((row: AccountSummary) => row.account.kind === "enterprise")
67+ .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length }));
68+ return {
69+ slug,
70+ name: person?.name ?? slug,
71+ person,
72+ billedTo,
73+ terms: subject.terms,
74+ limit,
75+ figures,
76+ enterprises,
77+ ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
78+ audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
79+ done: doneMessage(request.url),
80+ };
81+}
82+
83+export async function action({ request, params, context }: Route.ActionArgs) {
84+ const staff = requireStaff(context);
85+ // What is acted on comes from billing and identity, not from the form.
86+ const { slug, subject } = await load(params.slug);
87+ return billingAction(request, staff, subject, `/workspaces/${encodeURIComponent(slug)}`);
88+}
89+
90+export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
91+ const { slug, name, person, billedTo, terms, limit, figures, enterprises, ledger, audit, done } = loaderData;
92+ const { pathname } = useLocation();
93+ const result = actionData as ActionData | undefined;
94+ const review = result && "review" in result ? result.review : null;
95+ const link = result && "link" in result ? result.link : null;
96+ const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
97+ const owners = person?.members.filter((member) => member.role === "owner") ?? [];
98+
99+ return (
100+ <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
101+ <Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
102+ <ArrowLeft size={14} />
103+ Workspaces
104+ </Link>
105+
106+ {/* Header */}
107+ <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
108+ <div className="flex min-w-0 items-start gap-3">
109+ <Avatar name={slug} size={40} />
110+ <div className="min-w-0">
111+ <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
112+ <p className="mt-0.5 text-xs text-faint">
113+ <span className="font-mono">{slug}</span>
114+ {person && (
115+ <>
116+ {" · "}created <When at={person.createdAt} />
117+ </>
118+ )}
119+ </p>
120+ {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
121+ <div className="mt-2 flex flex-wrap gap-1.5">
122+ {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
123+ <TermsBadge terms={terms} />
124+ {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
125+ <StateBadge state={limit.state} />
126+ </div>
127+ </div>
128+ </div>
129+ {owners.length > 0 && (
130+ <div className="text-xs">
131+ <p className="mb-1.5 text-faint">Owners</p>
132+ <Owners owners={owners} />
133+ </div>
134+ )}
135+ </div>
136+
137+ <div className="mt-6 space-y-3">
138+ {done && <Notice tone="ok">{done}</Notice>}
139+ {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
140+ {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
141+ {review && <ReviewPanel review={review} pathname={pathname} />}
142+ </div>
143+
144+ {/* People */}
145+ <Section title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
146+ {!person ? (
147+ <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
148+ ) : person.members.length === 0 ? (
149+ <EmptyState title="No members" />
150+ ) : (
151+ <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
152+ <table className="w-full min-w-120 text-sm">
153+ <thead>
154+ <tr className="border-b border-line text-left text-xs text-muted">
155+ <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
156+ <th className="px-4 py-2 font-medium">Email</th>
157+ <th className="px-4 py-2 font-medium">Role</th>
158+ <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
159+ </tr>
160+ </thead>
161+ <tbody>
162+ {person.members.map((member) => (
163+ <tr key={member.username} className="border-b border-line last:border-0">
164+ <td className="px-4 py-2.5 sm:pl-5">
165+ <span className="inline-flex items-center gap-2 font-mono">
166+ <Avatar name={member.username} size={18} square={false} />
167+ {member.username}
168+ </span>
169+ </td>
170+ <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
171+ <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
172+ <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
173+ <When at={member.joined} />
174+ </td>
175+ </tr>
176+ ))}
177+ </tbody>
178+ </table>
179+ </div>
180+ )}
181+ </Section>
182+
183+ {/* Billing */}
184+ <h2 className="mt-10 text-lg font-semibold tracking-tight">Billing</h2>
185+ <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
186+ <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
187+ <p className="mb-2 text-xs text-muted">
188+ Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
189+ </p>
190+ <ExposureBar limit={limit} wide />
191+ <p className="mt-2 text-xs text-faint">
192+ {billedTo ? <>Limit via {billedTo.name}</> : <>Limit from trust {usd(limit.trustCeilingMicros)}</>} · owner's spend limit{" "}
193+ {usd(limit.spendLimitMicros)}
194+ {terms.ceilingMicros != null && <> · custom limit {usd(terms.ceilingMicros)}</>}
195+ </p>
196+ </div>
197+ <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
198+ <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
199+ </div>
200+
201+ <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
202+ <div className="space-y-6">
203+ <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
204+ {billedTo ? (
205+ <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
206+ <div className="flex flex-wrap items-center justify-between gap-3">
207+ <TermsBadge terms={terms} />
208+ <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
209+ Change them on {billedTo.name}
210+ </Link>
211+ </div>
212+ </Section>
213+ ) : (
214+ <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
215+ )}
216+ <LedgerSection
217+ ledger={ledger}
218+ description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
219+ />
220+ </div>
221+
222+ <div className="space-y-6">
223+ <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
224+ <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
225+ <AuditSection
226+ audit={audit}
227+ description={
228+ billedTo ? (
229+ <>
230+ Changes about this workspace in {billedTo.name}'s log.{" "}
231+ <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
232+ Full log
233+ </Link>
234+ </>
235+ ) : undefined
236+ }
237+ />
238+ </div>
239+ </div>
240+ </main>
241+ );
242+}
243+
244+function BilledToSection({
245+ billedTo,
246+ enterprises,
247+ pathname,
248+ error,
249+}: {
250+ billedTo: Enterprise | null;
251+ enterprises: { id: string; name: string; members: number }[];
252+ pathname: string;
253+ error: SectionError;
254+}) {
255+ return (
256+ <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
257+ {error && (
258+ <div className="mb-4">
259+ <Notice tone="error">{error.error}</Notice>
260+ </div>
261+ )}
262+ {billedTo ? (
263+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
264+ <p className="text-sm text-muted">
265+ <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
266+ {billedTo.name}
267+ </Link>{" "}
268+ pays for it, under its limit and terms.
269+ </p>
270+ <form method="post" action={`${pathname}#review`}>
271+ <input type="hidden" name="intent" value="detach" />
272+ <Button type="submit" variant="danger">
273+ <LogOut size={14} />
274+ Move off
275+ </Button>
276+ </form>
277+ </div>
278+ ) : enterprises.length === 0 ? (
279+ <p className="text-sm text-muted">
280+ Itself. There are no enterprises to move it onto yet;{" "}
281+ <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
282+ create one
283+ </Link>
284+ .
285+ </p>
286+ ) : (
287+ <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
288+ <input type="hidden" name="intent" value="attach" />
289+ <Field label="Itself. Move onto" className="grow">
290+ <Select name="target" required defaultValue={error?.values?.target ?? ""}>
291+ <option value="" disabled>
292+ Choose an enterprise
293+ </option>
294+ {enterprises.map((enterprise) => (
295+ <option key={enterprise.id} value={enterprise.id}>
296+ {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
297+ </option>
298+ ))}
299+ </Select>
300+ </Field>
301+ <Button type="submit" variant="quiet">
302+ <Building2 size={14} />
303+ Move
304+ </Button>
305+ </form>
306+ )}
307+ </Section>
308+ );
309+}
+14−0
1+import { redirect } from "react-router";
2+
3+import type { Route } from "./+types/workspaces-redirect";
4+import { requireStaff } from "~/lib/staff";
5+
6+/** The workspaces list lives at `/`; `/workspaces` goes there too. */
7+export async function loader({ request, context }: Route.LoaderArgs) {
8+ requireStaff(context);
9+ return redirect(`/${new URL(request.url).search}`);
10+}
11+
12+export default function WorkspacesRedirect() {
13+ return null;
14+}
+276−0
1+import { ChevronRight, Search } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import { ADMIN_WORKSPACES_LIMIT } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/workspaces";
7+import { Owners } from "~/components/billing";
8+import { Avatar, Badge, Button, EmptyState, ExposureBar, Stat, TermsBadge, TrustBadge, When } from "~/components/ui";
9+import { usd } from "~/lib/money";
10+import { admin, identity } from "~/lib/services.server";
11+import { requireStaff } from "~/lib/staff";
12+import { type WorkspaceRow, joinWorkspaces, matchesQuery } from "~/lib/workspaces";
13+
14+export const meta: Route.MetaFunction = () => [{ title: "Workspaces · sudo" }, { name: "robots", content: "noindex, nofollow" }];
15+
16+const FILTERS = {
17+ attention: { label: "Stopped or warning", test: (row: WorkspaceRow) => row.billing.limit != null && row.billing.limit.state !== "ok" },
18+ terms: { label: "Comped or custom", test: (row: WorkspaceRow) => row.billing.terms.kind !== "standard" },
19+ enterprise: { label: "On an enterprise", test: (row: WorkspaceRow) => row.billing.billedTo != null },
20+} as const;
21+
22+type Filter = keyof typeof FILTERS;
23+
24+const SEVERITY = { stopped: 0, warning: 1, ok: 2 } as const;
25+
26+export async function loader({ request, context }: Route.LoaderArgs) {
27+ requireStaff(context);
28+ const url = new URL(request.url);
29+ const q = (url.searchParams.get("q") ?? "").trim().slice(0, 100);
30+ const show = url.searchParams.getAll("show").filter((value): value is Filter => value in FILTERS);
31+
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];
48+ }
49+
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+ );
58+
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);
61+ return {
62+ q,
63+ show,
64+ rows,
65+ total: all.length,
66+ capped: found.length >= ADMIN_WORKSPACES_LIMIT,
67+ 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,
75+ },
76+ };
77+}
78+
79+function workspaceHref(row: WorkspaceRow) {
80+ return `/workspaces/${encodeURIComponent(row.slug)}`;
81+}
82+
83+export default function Workspaces({ loaderData }: Route.ComponentProps) {
84+ const { q, show, rows, total, capped, totals } = loaderData;
85+ const margin = totals.charged - totals.cost;
86+ const filtered = q !== "" || show.length > 0;
87+
88+ return (
89+ <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
90+ <div>
91+ <h1 className="text-2xl font-semibold tracking-tight">Workspaces</h1>
92+ <p className="mt-1 text-sm text-muted">Every workspace, who owns it, and how it pays this month.</p>
93+ </div>
94+
95+ <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+ <Stat
100+ label="Needs attention"
101+ value={`${totals.stopped} stopped`}
102+ hint={`${totals.warning} near the limit`}
103+ tone={totals.stopped > 0 ? "danger" : totals.warning > 0 ? "warn" : "mint"}
104+ />
105+ </div>
106+
107+ <form method="get" action="/" role="search" className="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center">
108+ <div className="relative grow">
109+ <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
110+ <input
111+ type="search"
112+ name="q"
113+ defaultValue={q}
114+ placeholder="Search by workspace, owner, email or enterprise"
115+ aria-label="Search workspaces"
116+ autoComplete="off"
117+ data-1p-ignore
118+ className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-merged/60"
119+ />
120+ </div>
121+ <fieldset className="flex flex-wrap items-center gap-2">
122+ <legend className="sr-only">Show only</legend>
123+ {(Object.keys(FILTERS) as Filter[]).map((key) => (
124+ <label
125+ key={key}
126+ className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-line px-3 py-1.5 text-xs text-muted transition-colors select-none hover:border-line-strong has-checked:border-merged/50 has-checked:bg-merged/10 has-checked:text-merged"
127+ >
128+ <input type="checkbox" name="show" value={key} defaultChecked={show.includes(key)} className="size-3.5" />
129+ {FILTERS[key].label}
130+ </label>
131+ ))}
132+ <Button type="submit" variant="quiet" className="py-1.5">
133+ Apply
134+ </Button>
135+ {filtered && (
136+ <Link to="/" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
137+ Clear
138+ </Link>
139+ )}
140+ </fieldset>
141+ </form>
142+
143+ <p className="mt-4 text-xs text-faint">
144+ {rows.length === total ? `${total} workspaces` : `${rows.length} of ${total} workspaces`}
145+ {q && (
146+ <>
147+ {" "}
148+ matching <span className="font-mono text-muted">{q}</span>
149+ </>
150+ )}
151+ . Stopped and warning first, then newest.
152+ {capped && ` Only the newest ${ADMIN_WORKSPACES_LIMIT} are listed; search to find older ones.`}
153+ </p>
154+
155+ {rows.length === 0 ? (
156+ <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."}
159+ </EmptyState>
160+ </div>
161+ ) : (
162+ <>
163+ {/* Phones: one card per workspace. */}
164+ <ul className="mt-3 space-y-2 md:hidden">
165+ {rows.map((row) => (
166+ <li key={row.slug}>
167+ <Link to={workspaceHref(row)} className="block rounded-lg border border-line bg-surface p-4 transition-colors hover:border-line-strong">
168+ <div className="flex items-start justify-between gap-3">
169+ <WorkspaceName row={row} />
170+ <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint" />
171+ </div>
172+ <div className="mt-3 text-xs">
173+ <Owners owners={row.owners} compact />
174+ </div>
175+ <div className="mt-3">
176+ <Usage row={row} wide />
177+ </div>
178+ <dl className="tabular mt-3 grid grid-cols-3 gap-2 text-xs">
179+ <Figure label="Charged" value={usd(row.billing.chargedMicros)} />
180+ <Figure label="Cost to g1t" value={usd(row.billing.costMicros)} />
181+ <Figure label="Members" value={row.memberCount == null ? "—" : String(row.memberCount)} />
182+ </dl>
183+ </Link>
184+ </li>
185+ ))}
186+ </ul>
187+
188+ {/* Wider screens: a table. */}
189+ <div className="mt-3 hidden overflow-x-auto rounded-lg border border-line md:block">
190+ <table className="w-full text-sm">
191+ <thead>
192+ <tr className="border-b border-line bg-surface text-left text-xs text-muted">
193+ <th className="px-4 py-2.5 font-medium">Workspace</th>
194+ <th className="px-4 py-2.5 font-medium">Owners</th>
195+ <th className="px-4 py-2.5 text-right font-medium">Members</th>
196+ <th className="px-4 py-2.5 font-medium">Usage this month / limit</th>
197+ <th className="px-4 py-2.5 text-right font-medium">Charged</th>
198+ <th className="px-4 py-2.5 text-right font-medium">Cost to g1t</th>
199+ </tr>
200+ </thead>
201+ <tbody>
202+ {rows.map((row) => (
203+ <tr key={row.slug} className="border-b border-line align-top last:border-0 hover:bg-surface/60">
204+ <td className="px-4 py-3">
205+ <Link to={workspaceHref(row)} className="group block">
206+ <WorkspaceName row={row} />
207+ </Link>
208+ </td>
209+ <td className="max-w-56 px-4 py-3 text-sm">
210+ <Owners owners={row.owners} compact />
211+ </td>
212+ <td className="tabular px-4 py-3 text-right text-muted">{row.memberCount ?? "—"}</td>
213+ <td className="px-4 py-3">
214+ <Usage row={row} />
215+ </td>
216+ <td className="tabular px-4 py-3 text-right">{usd(row.billing.chargedMicros)}</td>
217+ <td className="tabular px-4 py-3 text-right text-muted">{usd(row.billing.costMicros)}</td>
218+ </tr>
219+ ))}
220+ </tbody>
221+ </table>
222+ </div>
223+ </>
224+ )}
225+ </main>
226+ );
227+}
228+
229+function WorkspaceName({ row }: { row: WorkspaceRow }) {
230+ const { billing } = row;
231+ return (
232+ <div className="flex min-w-0 items-start gap-2.5">
233+ <span className="mt-0.5">
234+ <Avatar name={row.slug} size={22} />
235+ </span>
236+ <div className="min-w-0">
237+ <p className="truncate font-medium group-hover:underline group-hover:underline-offset-4">{row.name}</p>
238+ <p className="truncate text-xs text-faint">
239+ <span className="font-mono">{row.slug}</span>
240+ {row.createdAt && (
241+ <>
242+ {" · "}created <When at={row.createdAt} />
243+ </>
244+ )}
245+ </p>
246+ <div className="mt-1.5 flex flex-wrap gap-1.5">
247+ {billing.billedTo && <Badge tone="lavender">Billed to {billing.billedTo.name}</Badge>}
248+ <TermsBadge terms={billing.terms} />
249+ {billing.limit && billing.terms.kind !== "comped" && <TrustBadge trust={billing.limit.trust} />}
250+ {!row.known && <Badge tone="warn">Billing only</Badge>}
251+ </div>
252+ </div>
253+ </div>
254+ );
255+}
256+
257+/** The limit bar; a workspace on an enterprise shares the enterprise's. */
258+function Usage({ row, wide = false }: { row: WorkspaceRow; wide?: boolean }) {
259+ const { limit, billedTo } = row.billing;
260+ if (!limit) return <p className="text-xs text-faint">No usage yet</p>;
261+ return (
262+ <div>
263+ <ExposureBar limit={limit} wide={wide} />
264+ {billedTo && <p className="mt-1 text-xs text-faint">via {billedTo.name}</p>}
265+ </div>
266+ );
267+}
268+
269+function Figure({ label, value }: { label: string; value: string }) {
270+ return (
271+ <div>
272+ <dt className="text-faint">{label}</dt>
273+ <dd className="mt-0.5 text-fg-soft">{value}</dd>
274+ </div>
275+ );
276+}
+1−0
44 namespace Cloudflare {
55 interface Env {
66 BILLING: ServiceBinding;
7+ IDENTITY: ServiceBinding;
78 ASSETS: Fetcher;
89 ACCESS_TEAM_DOMAIN: string;
910 ACCESS_AUD: string;
+6−1
1313 // Even the stylesheet goes through the worker, which checks the Access
1414 // token on every request before anything is served.
1515 "assets": { "binding": "ASSETS", "run_worker_first": true },
16− "services": [{ "binding": "BILLING", "service": "g1t-billing" }],
16+ // Billing's and identity's staff-only methods (admin_*). Nothing else
17+ // binds to identity's admin_* methods.
18+ "services": [
19+ { "binding": "BILLING", "service": "g1t-billing" },
20+ { "binding": "IDENTITY", "service": "g1t-identity" }
21+ ],
1722 "vars": {
1823 // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`.
1924 "ACCESS_TEAM_DOMAIN": "syntaqx.cloudflareaccess.com",
+2−2
394394 className="scroll-mt-28 rounded-xl border border-line"
395395 >
396396 <header
397− className={`sticky top-12 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
397+ className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${
398398 collapsed ? "rounded-xl" : "rounded-t-xl border-b"
399399 }`}
400400 >
707707 const allCollapsed = files.every((file) => collapsed.has(file.path));
708708 return (
709709 <div>
710− <div className="sticky top-12 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
710+ <div className="sticky top-14 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur">
711711 <span className="text-sm text-muted">
712712 <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"}
713713 </span>
+2−2
598598 return (
599599 <div className="flex h-full flex-col">
600600 {/* The same height and rule as the top bar, so the two read as one line. */}
601− <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
601+ <div className="flex h-14 shrink-0 items-center gap-1.5 border-b border-line px-3">
602602 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
603603 <Mark className="size-5" />
604604 </Link>
10461046 )}
10471047
10481048 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
1049− <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4">
1049+ <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
10501050 <button
10511051 type="button"
10521052 aria-label="Open menu"
+55−0
7373 const form = await request.formData();
7474 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
7575 const intent = form.get("intent");
76+ if (intent === "portal") {
77+ // Card, invoices and billing details live on Stripe's own page.
78+ const started = await billing.billingPortal(user, params.owner, page);
79+ if (!started.ok) return { error: started.error.message };
80+ throw redirect(started.value.url);
81+ }
7682 if (intent === "spend-limit" || intent === "no-spend-limit") {
7783 const amount = Number(form.get("limit"));
7884 if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) {
136142 <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
137143 )}
138144
145+ {account.status.enabled && limit?.trust !== "internal" && (
146+ <section className="mb-10 rounded-xl border border-line bg-surface p-5">
147+ <div className="flex flex-wrap items-center justify-between gap-3">
148+ <div>
149+ <h2 className="font-medium">Card and invoices</h2>
150+ {account.card ? (
151+ <p className="mt-1 text-sm">
152+ <span className="capitalize">{account.card.brand}</span> ending{" "}
153+ <span className="font-mono">{account.card.last4}</span>
154+ <span className="text-muted">
155+ {" "}
156+ · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}
157+ </span>
158+ </p>
159+ ) : (
160+ <p className="mt-1 max-w-xl text-sm text-muted">
161+ No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each
162+ month closes, so work never stops for a payment. Saving it charges nothing.
163+ </p>
164+ )}
165+ <p className="mt-2 max-w-xl text-xs text-faint">
166+ Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t
167+ never sees card numbers.
168+ </p>
169+ </div>
170+ {role === "owner" && (
171+ <Form method="post">
172+ <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}>
173+ <CreditCard size={14} />
174+ {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"}
175+ </Button>
176+ </Form>
177+ )}
178+ </div>
179+ {account.card && (
180+ <p className="mt-3 text-xs text-faint">
181+ Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card
182+ stops work until it is paid.
183+ </p>
184+ )}
185+ {!account.status.live && (
186+ <p className="mt-3 text-xs text-faint">
187+ Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged
188+ automatically.
189+ </p>
190+ )}
191+ </section>
192+ )}
193+
139194 <h2 className="font-medium">Plans</h2>
140195 <p className="mt-1 max-w-2xl text-sm text-muted">
141196 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
+65−0
7777 /// What a run on the workspace's own model provider is charged: g1t's
7878 /// sandbox and orchestration, with the model paid for elsewhere.
7979 pub orchestration_fee_micros: i64,
80+ /// The card g1t charges as the workspace nears its limit and when a
81+ /// month closes, if one is on file.
82+ #[serde(default)]
83+ pub card: Option<Card>,
8084 }
8185
86+/// A saved card, as far as it is safe to show.
87+#[derive(Clone, Debug, Serialize, Deserialize)]
88+#[serde(rename_all = "camelCase")]
89+pub struct Card {
90+ /// `visa`, `mastercard`, ...
91+ pub brand: String,
92+ pub last4: String,
93+ pub exp_month: u32,
94+ pub exp_year: u32,
95+}
96+
97+/// `billing_portal`: Stripe's hosted billing page for the workspace, where
98+/// an owner adds or replaces the card, sees invoices and receipts, and sets
99+/// the billing email and address. g1t never handles card numbers. Owners
100+/// only. Returns `Outcome<Checkout>` (its `url`); Stripe sends them back
101+/// to `return_url`.
102+#[derive(Debug, Serialize, Deserialize)]
103+pub struct BillingPortalArgs {
104+ pub actor: User,
105+ pub workspace: String,
106+ pub return_url: String,
107+}
108+
109+/// `admin_billing_link`: for staff to send a customer: their Stripe billing
110+/// page. Returns `Outcome<BillingLink>`.
111+#[derive(Debug, Serialize, Deserialize)]
112+pub struct AdminBillingLinkArgs {
113+ pub workspace: String,
114+ pub by: String,
115+}
116+
117+#[derive(Clone, Debug, Serialize, Deserialize)]
118+#[serde(rename_all = "camelCase")]
119+pub struct BillingLink {
120+ /// A one-time session on Stripe's billing page, signed in already.
121+ pub portal_url: String,
122+ /// The billing page's sign-in page, which does not expire: the
123+ /// customer signs in with the email Stripe has for them.
124+ pub login_url: Option<String>,
125+ pub customer_email: Option<String>,
126+ pub expires_note: String,
127+}
128+
82129 #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
83130 #[serde(rename_all = "snake_case")]
84131 pub enum EntryKind {
112159 pub created_by: Option<String>,
113160 /// RFC 3339.
114161 pub created_at: String,
162+ /// The workspace the line belongs to, which tells an enterprise's
163+ /// lines apart.
164+ #[serde(default, skip_serializing_if = "Option::is_none")]
165+ pub workspace: Option<String>,
115166 }
116167
117168 fn g1t() -> String {
593644 pub cost_micros: i64,
594645 /// Paid, ever.
595646 pub paid_micros: i64,
647+ /// The same figures for each of the account's workspaces that has
648+ /// any, so staff can see what one member of an enterprise used.
649+ #[serde(default)]
650+ pub by_workspace: Vec<WorkspaceFigures>,
651+}
652+
653+/// One workspace's share of an [`AccountSummary`].
654+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
655+#[serde(rename_all = "camelCase")]
656+pub struct WorkspaceFigures {
657+ pub workspace: String,
658+ pub charged_micros: i64,
659+ pub cost_micros: i64,
660+ pub paid_micros: i64,
596661 }
597662
598663 /// `admin_account`: one account in full. Returns `Outcome<AccountDetail>`.
+81−0
407407 /// The id and name g1t's agents act under.
408408 pub const AGENT_ID: &str = "usr_g1t_agent";
409409 pub const AGENT_NAME: &str = "g1t-agent";
410+
411+// --- Staff ---------------------------------------------------------------
412+//
413+// Staff-only methods, for sudo.g1t.sh. They take no viewer and check no
414+// membership: only sudo calls them, over its service binding, after it has
415+// verified a Cloudflare Access sign-in and its staff list. Nothing a
416+// customer can reach should ever forward to them.
417+
418+/// `notify_owners`: emails a short notice, with one link, to each owner of
419+/// a workspace with a confirmed address. Called by other services (billing
420+/// warns owners near their usage limit), never on a person's behalf.
421+/// Returns how many were sent.
422+#[derive(Clone, Debug, Serialize, Deserialize)]
423+pub struct NotifyOwnersArgs {
424+ pub workspace: String,
425+ pub subject: String,
426+ /// One or two sentences: what happened and what it means.
427+ pub intro: String,
428+ /// The button's words, such as `Open billing`.
429+ pub action: String,
430+ /// Where the button goes; must be on g1t.sh.
431+ pub link: String,
432+ /// Small print: why they got it.
433+ pub footer: String,
434+}
435+
436+/// `admin_workspaces`: every workspace, newest first, at most
437+/// [`ADMIN_WORKSPACES_LIMIT`], optionally only those whose slug, name or
438+/// an owner's username or email contains `query`. Returns
439+/// `Vec<AdminWorkspace>`. Staff only.
440+#[derive(Debug, Default, Serialize, Deserialize)]
441+pub struct AdminWorkspacesArgs {
442+ #[serde(default)]
443+ pub query: Option<String>,
444+}
445+
446+/// The most workspaces one `admin_workspaces` call returns.
447+pub const ADMIN_WORKSPACES_LIMIT: usize = 500;
448+
449+/// An owner of a workspace, as staff see them.
450+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
451+pub struct AdminOwner {
452+ pub username: String,
453+ pub email: Option<String>,
454+}
455+
456+/// A workspace as staff see it: who owns it and how many belong to it.
457+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
458+#[serde(rename_all = "camelCase")]
459+pub struct AdminWorkspace {
460+ pub slug: String,
461+ pub name: String,
462+ /// RFC 3339.
463+ pub created_at: String,
464+ pub owners: Vec<AdminOwner>,
465+ pub member_count: u32,
466+}
467+
468+/// `admin_workspace`: one workspace with every member, or null. Takes
469+/// `SlugArgs`; returns `Option<AdminWorkspaceDetail>`. Staff only.
470+#[derive(Clone, Debug, Serialize, Deserialize)]
471+#[serde(rename_all = "camelCase")]
472+pub struct AdminWorkspaceDetail {
473+ pub slug: String,
474+ pub name: String,
475+ pub description: Option<String>,
476+ /// RFC 3339.
477+ pub created_at: String,
478+ /// Owners first, then by username.
479+ pub members: Vec<AdminMember>,
480+}
481+
482+/// A member of a workspace, as staff see them.
483+#[derive(Clone, Debug, Serialize, Deserialize)]
484+pub struct AdminMember {
485+ pub username: String,
486+ pub email: Option<String>,
487+ pub role: crate::Role,
488+ /// When they joined the workspace. RFC 3339.
489+ pub joined: String,
490+}
+26−0
3434 marginPercent: number;
3535 /** What a run on the workspace's own model provider is charged instead. */
3636 orchestrationFeeMicros: number;
37+ /** The card charged near the limit and when a month closes, if one is saved. */
38+ card?: { brand: string; last4: string; expMonth: number; expYear: number } | null;
3739 };
3840
3941 /** One line of a workspace's statement. */
5759 createdBy: string | null;
5860 /** RFC 3339. */
5961 createdAt: string;
62+ /** The workspace the line belongs to, which tells an enterprise's lines apart. */
63+ workspace?: string | null;
6064 };
6165
6266 /** What lets a sandbox, and nothing else, report what its run cost. */
102106 chargedMicros: number;
103107 costMicros: number;
104108 paidMicros: number;
109+ /** The same figures for each of the account's workspaces that has any. */
110+ byWorkspace: WorkspaceFigures[];
111+};
112+
113+/** One workspace's share of an `AccountSummary`. */
114+export type WorkspaceFigures = { workspace: string; chargedMicros: number; costMicros: number; paidMicros: number };
115+
116+/** A customer's Stripe billing page, for staff to send them. */
117+export type BillingLink = {
118+ /** One-time and short-lived, signed in already. */
119+ portalUrl: string;
120+ /** The page's sign-in, which does not expire: the customer signs in by email. */
121+ loginUrl: string | null;
122+ customerEmail: string | null;
123+ expiresNote: string;
105124 };
106125
107126 export type AdminAction = { id: string; account: string; action: string; detail: string; by: string; createdAt: string };
121140 createEnterprise(name: string, workspaces: string[], by: string): Promise<Result<PayingAccount>>;
122141 attach(workspace: string, account: string | null, by: string): Promise<Result<PayingAccount>>;
123142 credit(workspace: string, amountMicros: number, note: string, by: string): Promise<Result<LedgerEntry>>;
143+ /** The workspace's Stripe billing page, to send to the customer. Logged. */
144+ billingLink(workspace: string, by: string): Promise<Result<BillingLink>>;
124145 }
125146
126147 /** How much a workspace has earned g1t's trust with money. */
246267 * `session`.
247268 */
248269 checkout(actor: User, workspace: string, amountCents: number, returnUrl: string): Promise<Result<{ url: string }>>;
270+ /**
271+ * Stripe's hosted billing page for the workspace: card, invoices, billing
272+ * email and address. g1t never handles card numbers. Owners only.
273+ */
274+ billingPortal(actor: User, workspace: string, returnUrl: string): Promise<Result<{ url: string }>>;
249275 /** Credits a payment once the processor says it was made. Safe to repeat. */
250276 confirm(workspace: string, viewer: Viewer, session: string): Promise<Result<BillingAccount>>;
251277 /**
+12−1
33 import type { DeploymentsApi } from "./deployments";
44 import type { ProjectsApi } from "./projects";
55 import type { EventsApi } from "./events";
6−import type { IdentityApi } from "./identity";
6+import type { IdentityAdminApi, IdentityApi } from "./identity";
77 import type { IntegrationsApi } from "./integrations";
88 import type { WebhooksApi } from "./webhooks";
99 import type { ReposApi } from "./repos";
9292 };
9393 }
9494
95+/** Staff-only identity. Only sudo binds to it; see `IdentityAdminApi`. */
96+export function identityAdminClient(service: ServiceBinding): IdentityAdminApi {
97+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
98+ return {
99+ workspaces: (query) => call("admin_workspaces", { query: query ?? null }),
100+ workspace: (slug) => call("admin_workspace", { slug }),
101+ };
102+}
103+
95104 export function reposClient(service: ServiceBinding): ReposApi {
96105 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
97106 return {
199208 call("cancel_subscription", { actor, workspace, feature, resume }),
200209 hasFeature: (workspace, feature) => call("has_feature", { workspace, feature }),
201210 chargeFeature: (charge) => call("charge_feature", charge),
211+ billingPortal: (actor, workspace, returnUrl) => call("billing_portal", { actor, workspace, return_url: returnUrl }),
202212 recordSandbox: (usage) => call("record_sandbox", usage),
203213 limit: (workspace, viewer) => call("limit", { workspace, viewer }),
204214 checkLimit: (workspace) => call("check_limit", { workspace }),
218228 createEnterprise: (name, workspaces, by) => call("admin_create_enterprise", { name, workspaces, by }),
219229 attach: (workspace, account, by) => call("admin_attach", { workspace, account, by }),
220230 credit: (workspace, amountMicros, note, by) => call("admin_credit", { workspace, amount_micros: amountMicros, note, by }),
231+ billingLink: (workspace, by) => call("admin_billing_link", { workspace, by }),
221232 };
222233 }
223234
+42−0
4444
4545 export type Member = { username: string; role: Role };
4646
47+/** An owner of a workspace, as staff see them. */
48+export type AdminOwner = { username: string; email: string | null };
49+
50+/** A workspace as staff see it. Mirrors `AdminWorkspace` in `crates/contracts/src/identity.rs`. */
51+export type AdminWorkspace = {
52+ slug: string;
53+ name: string;
54+ /** RFC 3339. */
55+ createdAt: string;
56+ owners: AdminOwner[];
57+ memberCount: number;
58+};
59+
60+/** A member of a workspace, as staff see them. */
61+export type AdminMember = { username: string; email: string | null; role: Role; /** RFC 3339. */ joined: string };
62+
63+export type AdminWorkspaceDetail = {
64+ slug: string;
65+ name: string;
66+ description: string | null;
67+ /** RFC 3339. */
68+ createdAt: string;
69+ /** Owners first, then by username. */
70+ members: AdminMember[];
71+};
72+
73+/** The most workspaces one `workspaces` call returns. */
74+export const ADMIN_WORKSPACES_LIMIT = 500;
75+
76+/**
77+ * Staff-only identity, for sudo.g1t.sh. It takes no viewer and checks no
78+ * membership: only sudo calls it, over its service binding, once Cloudflare
79+ * Access and its staff list have let someone in. Never call it on behalf of
80+ * a customer.
81+ */
82+export interface IdentityAdminApi {
83+ /** Every workspace, newest first, at most 500; `query` matches slug, name, or an owner's username or email. */
84+ workspaces(query?: string): Promise<AdminWorkspace[]>;
85+ /** One workspace with all its members, or null. */
86+ workspace(slug: string): Promise<AdminWorkspaceDetail | null>;
87+}
88+
4789 /** Who is asking. Every read and write in every service takes one. */
4890 export type Viewer = User | null;
4991
+16−0
1+-- Closing each month: what a workspace with a card on file owed when the
2+-- month ended, charged to that card. Once per workspace per month. See
3+-- `close_months` in src/limits.rs.
4+CREATE TABLE month_closes (
5+ workspace TEXT NOT NULL,
6+ -- YYYY-MM, the month that closed.
7+ month TEXT NOT NULL,
8+ -- paid, failed, nothing (owed nothing) or skipped (comped, or on an
9+ -- enterprise, which is invoiced).
10+ status TEXT NOT NULL,
11+ amount_micros INTEGER NOT NULL DEFAULT 0,
12+ payment_id TEXT,
13+ error TEXT,
14+ closed_at TEXT NOT NULL,
15+ PRIMARY KEY (workspace, month)
16+);
+6−0
1+-- Telling owners before work stops: the highest warning sent this month
2+-- (50, 80 or 100 percent of the limit), and when a declined card was last
3+-- reported. See `warn_limits` in src/limits.rs.
4+ALTER TABLE limits ADD COLUMN warned_month TEXT;
5+ALTER TABLE limits ADD COLUMN warned_level INTEGER NOT NULL DEFAULT 0;
6+ALTER TABLE limits ADD COLUMN declined_told_at TEXT;
+94−11
1717 use g1t_contracts::billing::{
1818 AccountDetail, AccountKind, AccountSummary, AdminAccountArgs, AdminAccountsArgs, AdminAction, AdminAttachArgs,
1919 AdminCreateEnterpriseArgs, AdminCreditArgs, AdminSetTermsArgs, BillingAccount, EntryKind, LedgerEntry, Terms,
20− TermsKind,
20+ TermsKind, WorkspaceFigures,
2121 };
2222 use g1t_contracts::time::rfc3339;
2323 use g1t_contracts::{FailureCode, Outcome, new_id};
230230 let limit = self.limit_of(&first).await?;
231231 #[derive(Deserialize)]
232232 struct Totals {
233+ workspace: String,
233234 charged: Option<i64>,
234235 cost: Option<i64>,
235236 }
236237 #[derive(Deserialize)]
237238 struct Paid {
239+ workspace: String,
238240 paid: Option<i64>,
239241 }
240242 let marks = vec!["?"; account.workspaces.len().max(1)].join(", ");
248250 let totals = self
249251 .db
250252 .prepare(format!(
251− "SELECT -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
252− WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ?"
253+ "SELECT workspace, -SUM(amount_micros) AS charged, SUM(cost_micros) AS cost FROM ledger
254+ WHERE kind = 'usage' AND workspace IN ({marks}) AND created_at >= ? GROUP BY workspace"
253255 ))
254256 .bind(&with_month)?
255− .first::<Totals>(None)
256− .await?;
257+ .all()
258+ .await?
259+ .results::<Totals>()?;
257260 let paid = self
258261 .db
259− .prepare(format!("SELECT SUM(amount_micros) AS paid FROM ledger WHERE kind = 'top_up' AND workspace IN ({marks})"))
262+ .prepare(format!(
263+ "SELECT workspace, SUM(amount_micros) AS paid FROM ledger
264+ WHERE kind = 'top_up' AND workspace IN ({marks}) GROUP BY workspace"
265+ ))
260266 .bind(&values)?
261− .first::<Paid>(None)
262− .await?;
267+ .all()
268+ .await?
269+ .results::<Paid>()?;
270+ // Each workspace's share, in the account's order; the account's
271+ // figures are their sum.
272+ let mut by_workspace: Vec<WorkspaceFigures> = vec![];
273+ for workspace in &account.workspaces {
274+ figures_for(&mut by_workspace, workspace);
275+ }
276+ for t in &totals {
277+ let f = figures_for(&mut by_workspace, &t.workspace);
278+ f.charged_micros += t.charged.unwrap_or(0);
279+ f.cost_micros += t.cost.unwrap_or(0);
280+ }
281+ for p in &paid {
282+ figures_for(&mut by_workspace, &p.workspace).paid_micros += p.paid.unwrap_or(0);
283+ }
263284 Ok(AccountSummary {
285+ charged_micros: by_workspace.iter().map(|f| f.charged_micros).sum(),
286+ cost_micros: by_workspace.iter().map(|f| f.cost_micros).sum(),
287+ paid_micros: by_workspace.iter().map(|f| f.paid_micros).sum(),
288+ by_workspace,
264289 account,
265290 limit,
266− charged_micros: totals.as_ref().and_then(|t| t.charged).unwrap_or(0),
267− cost_micros: totals.and_then(|t| t.cost).unwrap_or(0),
268− paid_micros: paid.and_then(|p| p.paid).unwrap_or(0),
269291 })
270292 }
271293
484506 Ok(Outcome::Ok(self.account_of(&workspace).await?))
485507 }
486508
509+ pub(crate) async fn admin_billing_link(
510+ &self,
511+ a: g1t_contracts::billing::AdminBillingLinkArgs,
512+ ) -> Result<Outcome<g1t_contracts::billing::BillingLink>> {
513+ let workspace = a.workspace.trim().to_lowercase();
514+ if workspace.is_empty() || a.by.trim().is_empty() {
515+ return Ok(Outcome::fail(FailureCode::Invalid, "Name the workspace, and who is asking."));
516+ }
517+ let Some(stripe) = &self.stripe else {
518+ return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
519+ };
520+ let customer = match self.customer_for(&workspace).await {
521+ Ok(customer) => customer,
522+ Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
523+ };
524+ let link = async {
525+ let configuration = stripe.portal_configuration().await?;
526+ let portal_url = stripe.portal_session(&customer, "https://g1t.sh/").await?;
527+ let customer_email = stripe.customer_email(&customer).await.ok().flatten();
528+ Ok::<_, worker::Error>(g1t_contracts::billing::BillingLink {
529+ portal_url,
530+ login_url: configuration.login_page.and_then(|page| page.url),
531+ customer_email,
532+ expires_note: "The one-time link works for a short while and only once; the sign-in page does not expire."
533+ .to_owned(),
534+ })
535+ }
536+ .await;
537+ match link {
538+ Ok(link) => {
539+ let account = self.account_of(&workspace).await?;
540+ self.audit(&account.id, "billing_link", &format!("Stripe billing link for {workspace}"), &a.by).await?;
541+ Ok(Outcome::Ok(link))
542+ }
543+ Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
544+ }
545+ }
546+
487547 pub(crate) async fn admin_credit(&self, a: AdminCreditArgs) -> Result<Outcome<LedgerEntry>> {
488548 let workspace = a.workspace.trim().to_lowercase();
489549 if workspace.is_empty() || a.note.trim().is_empty() || a.by.trim().is_empty() {
512572 }
513573 }
514574
575+/// A workspace's figures in `list`, added at the end the first time.
576+fn figures_for<'a>(list: &'a mut Vec<WorkspaceFigures>, workspace: &str) -> &'a mut WorkspaceFigures {
577+ let i = match list.iter().position(|f| f.workspace == workspace) {
578+ Some(i) => i,
579+ None => {
580+ list.push(WorkspaceFigures { workspace: workspace.to_owned(), ..Default::default() });
581+ list.len() - 1
582+ }
583+ };
584+ &mut list[i]
585+}
586+
515587 #[cfg(test)]
516588 mod tests {
517589 use super::*;
534606 assert_eq!(describe(&custom), "custom (20% off, ceiling $50.00): Design partner");
535607 assert_eq!(describe(&Terms::standard()), "standard");
536608 }
609+
610+ #[test]
611+ fn each_workspace_gets_one_share() {
612+ let mut list = vec![];
613+ figures_for(&mut list, "acme").charged_micros += 5;
614+ figures_for(&mut list, "beta").cost_micros += 2;
615+ figures_for(&mut list, "acme").charged_micros += 7;
616+ assert_eq!(list.len(), 2);
617+ assert_eq!(list[0], WorkspaceFigures { workspace: "acme".into(), charged_micros: 12, ..Default::default() });
618+ assert_eq!(list[1].cost_micros, 2);
619+ }
537620 }
+69−4
7575 created_by: Option<String>,
7676 created_at: String,
7777 billed_to: Option<String>,
78+ #[serde(default)]
79+ workspace: Option<String>,
7880 }
7981
8082 impl From<LedgerRow> for LedgerEntry {
9193 billed_to: row.billed_to.unwrap_or_else(|| "g1t".to_owned()),
9294 created_by: row.created_by,
9395 created_at: row.created_at,
96+ workspace: row.workspace,
9497 }
9598 }
9699 }
175178 }
176179
177180 async fn standing(&self, workspace: &str) -> Result<Account> {
181+ let row = self.row(workspace).await?;
182+ let card = match (&self.stripe, row.as_ref().and_then(|row| row.customer_id.as_deref())) {
183+ (Some(stripe), Some(customer)) => stripe.card(customer).await.ok().flatten().map(|card| Card {
184+ brand: card.brand,
185+ last4: card.last4,
186+ exp_month: card.exp_month,
187+ exp_year: card.exp_year,
188+ }),
189+ _ => None,
190+ };
178191 Ok(Account {
179192 workspace: workspace.to_owned(),
180− balance_micros: self
181− .row(workspace)
182− .await?
183− .map_or(0, |row| row.balance_micros),
193+ balance_micros: row.map_or(0, |row| row.balance_micros),
184194 status: self.status(),
185195 margin_percent: self.margin_percent,
186196 orchestration_fee_micros: self.orchestration_fee_micros,
197+ card,
187198 })
188199 }
189200
201+ /// The workspace's customer at Stripe, made the first time one is needed.
202+ pub(crate) async fn customer_for(&self, workspace: &str) -> Result<String> {
203+ let Some(stripe) = &self.stripe else {
204+ return Err(worker::Error::RustError("payments are not set up".into()));
205+ };
206+ if let Some(customer) = self.row(workspace).await?.and_then(|row| row.customer_id) {
207+ return Ok(customer);
208+ }
209+ let customer = stripe.create_customer(workspace).await?;
210+ self.db
211+ .prepare(
212+ "INSERT INTO accounts (workspace, balance_micros, customer_id, created_at) VALUES (?1, 0, ?2, ?3)
213+ ON CONFLICT (workspace) DO UPDATE SET customer_id = ?2",
214+ )
215+ .bind(&[workspace.into(), customer.as_str().into(), rfc3339(now_ms()).into()])?
216+ .run()
217+ .await?;
218+ Ok(customer)
219+ }
220+
221+ /// Stripe's hosted billing page for the workspace. Owners only.
222+ async fn billing_portal(&self, a: BillingPortalArgs) -> Result<Outcome<Checkout>> {
223+ let workspace = a.workspace.to_lowercase();
224+ if a.actor.role_in(&workspace) != Some(Role::Owner) {
225+ return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can manage the workspace's billing."));
226+ }
227+ let Some(stripe) = &self.stripe else {
228+ return Ok(Outcome::fail(FailureCode::Conflict, "Payments are not set up on this g1t."));
229+ };
230+ let customer = match self.customer_for(&workspace).await {
231+ Ok(customer) => customer,
232+ Err(error) => return Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe could not be reached: {error}"))),
233+ };
234+ match stripe.portal_session(&customer, &a.return_url).await {
235+ Ok(url) => Ok(Outcome::Ok(Checkout { url })),
236+ Err(error) if stripe::is_missing(&error) => {
237+ // The customer was removed at Stripe: a new one next time.
238+ self.forget_customer(&workspace).await?;
239+ Ok(Outcome::fail(FailureCode::Conflict, "Stripe no longer had this workspace's customer. Try again."))
240+ }
241+ Err(error) => Ok(Outcome::fail(FailureCode::Conflict, format!("Stripe's billing page could not be opened: {error}"))),
242+ }
243+ }
244+
190245 /// Adds a ledger entry and moves the balance by the same amount, as
191246 /// one write.
192247 #[allow(clippy::too_many_arguments)]
905960 if let Err(error) = billing.autopay().await {
906961 worker::console_error!("paying at the limit failed: {error}");
907962 }
963+ if let Err(error) = billing.close_months().await {
964+ worker::console_error!("closing the month failed: {error}");
965+ }
966+ if let Ok(identity) = env.service("IDENTITY") {
967+ if let Err(error) = billing.warn_limits(&identity).await {
968+ worker::console_error!("warning owners failed: {error}");
969+ }
970+ }
908971 // Once a day, and at once if the costs were never checked: check every
909972 // cost against what Cloudflare billed.
910973 if event.cron() == keeper::DAILY || billing.never_checked().await.unwrap_or(false) {
9431006 "check_limit" => reply(&billing.check_limit(args(body)?).await?),
9441007 "set_spend_limit" => reply(&billing.set_spend_limit(args(body)?).await?),
9451008 "prices" => reply(&billing.prices().await?),
1009+ "billing_portal" => reply(&billing.billing_portal(args(body)?).await?),
1010+ "admin_billing_link" => reply(&billing.admin_billing_link(args(body)?).await?),
9461011 "note_pending" => reply(&billing.note_pending(args(body)?).await?),
9471012 "admin_accounts" => reply(&billing.admin_accounts(args(body)?).await?),
9481013 "admin_account" => reply(&billing.admin_account(args(body)?).await?),
+306−3
145145 let used = used + pending;
146146 // Test-mode payments are not money: they pay nothing off.
147147 let live = self.stripe.as_ref().is_some_and(crate::stripe::Stripe::live);
148− let exposure = (used - if live { paid_month } else { 0 }).max(0);
148+ // Charges from earlier months still unpaid carry over, so a new
149+ // month is not a fresh allowance for an account that never pays.
150+ // Credits g1t gave count as paid; test-mode payments do not.
151+ let mut before = members.clone();
152+ before.push(month_start.as_str().into());
153+ let carried = self
154+ .db
155+ .prepare(format!(
156+ "SELECT SUM(CASE WHEN kind = 'usage' THEN amount_micros
157+ WHEN kind = 'top_up' AND ({live} = 1 OR reference LIKE 'crd%') THEN amount_micros
158+ ELSE 0 END) AS paid
159+ FROM ledger WHERE workspace IN ({marks}) AND created_at < ?",
160+ live = u8::from(live)
161+ ))
162+ .bind(&before)?
163+ .first::<Paid>(None)
164+ .await?
165+ .and_then(|row| row.paid)
166+ .map_or(0, |balance| (-balance).max(0));
167+ let exposure = (used - if live { paid_month } else { 0 }).max(0) + carried;
149168
150169 let (trust, trust_ceiling) = match account.terms.kind {
151170 TermsKind::Comped => (Trust::Internal, None),
311330 {
312331 continue;
313332 }
314− let cents = ((limit.exposure_micros + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS);
315− let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], limit.exposure_micros / 1_000_000);
333+ // What it owes: its charges less what it has paid, never the cost
334+ // of what was free to it. At least the minimum, which is credit
335+ // toward what comes next.
336+ let balance = self.row(&candidate.workspace).await?.map_or(0, |row| row.balance_micros);
337+ let owed = (-balance).max(0);
338+ if owed == 0 {
339+ continue;
340+ }
341+ let cents = ((owed + 9_999) / 10_000).max(AUTOPAY_MIN_CENTS);
342+ let key = format!("autopay/{}/{}/{}", candidate.workspace, &month_start[..7], owed / 1_000_000);
316343 let description = format!("g1t usage for {}, paid automatically near its limit", candidate.workspace);
317344 let now = rfc3339(now_ms());
318345 match stripe.charge_saved_card(&customer, cents, &description, &key).await {
319346 Ok(payment) if payment.status == "succeeded" => {
347+ // A retried charge is the same payment: credited once.
348+ let seen = self
349+ .db
350+ .prepare("SELECT id FROM ledger WHERE reference = ?")
351+ .bind(&[payment.id.as_str().into()])?
352+ .first::<serde_json::Value>(None)
353+ .await?;
354+ if seen.is_some() {
355+ continue;
356+ }
320357 self.enter(
321358 &candidate.workspace,
322359 g1t_contracts::billing::EntryKind::TopUp,
354391 Ok(())
355392 }
356393
394+ /// Closes last month for each workspace with a card on file: charges
395+ /// what it owed when the month ended. Live payments only, once per
396+ /// workspace and month; a declined card stops work until it is paid.
397+ /// Comped workspaces owe nothing, and enterprises are invoiced.
398+ pub(crate) async fn close_months(&self) -> Result<()> {
399+ let Some(stripe) = self.stripe.as_ref().filter(|stripe| stripe.live()) else {
400+ return Ok(());
401+ };
402+ let now = rfc3339(now_ms());
403+ let month_start = format!("{}-01", &now[..7]);
404+ let closing = previous_month(&now[..7]);
405+ #[derive(Deserialize)]
406+ struct Open {
407+ workspace: String,
408+ customer_id: String,
409+ balance: Option<i64>,
410+ }
411+ let open = self
412+ .db
413+ .prepare(
414+ "SELECT accounts.workspace AS workspace, accounts.customer_id AS customer_id,
415+ (SELECT SUM(amount_micros) FROM ledger
416+ WHERE ledger.workspace = accounts.workspace AND ledger.created_at < ?1) AS balance
417+ FROM accounts
418+ WHERE accounts.customer_id IS NOT NULL
419+ AND NOT EXISTS (SELECT 1 FROM month_closes
420+ WHERE month_closes.workspace = accounts.workspace AND month_closes.month = ?2)
421+ LIMIT 20",
422+ )
423+ .bind(&[month_start.as_str().into(), closing.as_str().into()])?
424+ .all()
425+ .await?
426+ .results::<Open>()?;
427+ for account in open {
428+ let record = |status: &str, amount: i64, payment: Option<&str>, error: Option<&str>| {
429+ self.db
430+ .prepare(
431+ "INSERT OR IGNORE INTO month_closes (workspace, month, status, amount_micros, payment_id, error, closed_at)
432+ VALUES (?, ?, ?, ?, ?, ?, ?)",
433+ )
434+ .bind(&[
435+ account.workspace.as_str().into(),
436+ closing.as_str().into(),
437+ status.into(),
438+ (amount as f64).into(),
439+ crate::optional(payment),
440+ crate::optional(error),
441+ now.as_str().into(),
442+ ])
443+ };
444+ let payer = self.account_of(&account.workspace).await?;
445+ if payer.terms.kind == TermsKind::Comped || payer.id.starts_with("ent_") {
446+ record("skipped", 0, None, None)?.run().await?;
447+ continue;
448+ }
449+ let owed = (-account.balance.unwrap_or(0)).max(0);
450+ if owed < 10_000 {
451+ // Under a cent: nothing worth charging.
452+ record("nothing", 0, None, None)?.run().await?;
453+ continue;
454+ }
455+ let cents = (owed + 9_999) / 10_000;
456+ let key = format!("close/{}/{closing}", account.workspace);
457+ let description = format!("g1t usage for {} in {closing}", account.workspace);
458+ match stripe.charge_saved_card(&account.customer_id, cents, &description, &key).await {
459+ Ok(payment) if payment.status == "succeeded" => {
460+ let seen = self
461+ .db
462+ .prepare("SELECT id FROM ledger WHERE reference = ?")
463+ .bind(&[payment.id.as_str().into()])?
464+ .first::<serde_json::Value>(None)
465+ .await?;
466+ if seen.is_none() {
467+ self.enter(
468+ &account.workspace,
469+ g1t_contracts::billing::EntryKind::TopUp,
470+ payment.amount_received.max(cents) * 10_000,
471+ &format!("Usage for {closing}, charged to the card on file when the month closed"),
472+ &payment.id,
473+ None,
474+ None,
475+ None,
476+ Some(&account.customer_id),
477+ )
478+ .await?;
479+ }
480+ record("paid", cents * 10_000, Some(&payment.id), None)?.run().await?;
481+ }
482+ outcome => {
483+ let error = match outcome {
484+ Ok(payment) => format!("the payment is {}", payment.status.replace('_', " ")),
485+ Err(error) => error.to_string().chars().take(200).collect(),
486+ };
487+ self.db
488+ .prepare(
489+ "INSERT INTO limits (workspace, autopay_failed_at, autopay_error, updated_at) VALUES (?1, ?2, ?3, ?2)
490+ ON CONFLICT (workspace) DO UPDATE SET autopay_failed_at = ?2, autopay_error = ?3, updated_at = ?2",
491+ )
492+ .bind(&[account.workspace.as_str().into(), now.as_str().into(), error.as_str().into()])?
493+ .run()
494+ .await?;
495+ record("failed", cents * 10_000, None, Some(&error))?.run().await?;
496+ }
497+ }
498+ }
499+ Ok(())
500+ }
501+
502+ /// Emails a workspace's owners as it passes 50%, 80% and 100% of its
503+ /// limit, once each a month, and when its card was declined, so that
504+ /// work never stops without warning.
505+ pub(crate) async fn warn_limits(&self, identity: &worker::Fetcher) -> Result<()> {
506+ if self.stripe.is_none() {
507+ return Ok(());
508+ }
509+ let now = rfc3339(now_ms());
510+ let month = &now[..7];
511+ #[derive(Deserialize)]
512+ struct Candidate {
513+ workspace: String,
514+ }
515+ let candidates = self
516+ .db
517+ .prepare(
518+ "SELECT DISTINCT workspace FROM ledger WHERE kind = 'usage' AND created_at >= ?1
519+ UNION SELECT workspace FROM limits WHERE autopay_failed_at IS NOT NULL",
520+ )
521+ .bind(&[format!("{month}-01").into()])?
522+ .all()
523+ .await?
524+ .results::<Candidate>()?;
525+ #[derive(Deserialize)]
526+ struct Told {
527+ warned_month: Option<String>,
528+ warned_level: Option<i64>,
529+ autopay_failed_at: Option<String>,
530+ declined_told_at: Option<String>,
531+ }
532+ for Candidate { workspace } in candidates {
533+ let limit = self.limit_of(&workspace).await?;
534+ let told = self
535+ .db
536+ .prepare("SELECT warned_month, warned_level, autopay_failed_at, declined_told_at FROM limits WHERE workspace = ?")
537+ .bind(&[workspace.as_str().into()])?
538+ .first::<Told>(None)
539+ .await?;
540+ let billing = format!("https://g1t.sh/{workspace}/-/billing");
541+
542+ // A declined card, once per decline.
543+ if let Some(Told { autopay_failed_at: Some(failed), declined_told_at, .. }) = &told {
544+ if declined_told_at.as_deref().is_none_or(|at| at < failed.as_str()) {
545+ let sent = notify(
546+ identity,
547+ &workspace,
548+ &format!("g1t: the card for {workspace} was declined"),
549+ &limit.message.clone().unwrap_or_else(|| format!("g1t could not charge the card on file for {workspace}.")),
550+ "Update the card",
551+ &billing,
552+ )
553+ .await;
554+ if sent {
555+ self.db
556+ .prepare("UPDATE limits SET declined_told_at = ? WHERE workspace = ?")
557+ .bind(&[now.as_str().into(), workspace.as_str().into()])?
558+ .run()
559+ .await?;
560+ }
561+ }
562+ }
563+
564+ let Some(ceiling) = limit.ceiling_micros.filter(|c| *c > 0) else { continue };
565+ let level = warning_level(limit.exposure_micros, ceiling);
566+ let already = told
567+ .as_ref()
568+ .filter(|t| t.warned_month.as_deref() == Some(month))
569+ .and_then(|t| t.warned_level)
570+ .unwrap_or(0);
571+ if level <= already {
572+ continue;
573+ }
574+ let (subject, intro) = match level {
575+ 100 => (
576+ format!("g1t: {workspace} reached its usage limit"),
577+ limit.message.clone().unwrap_or_else(|| format!("{workspace} reached its usage limit.")),
578+ ),
579+ _ => (
580+ format!("g1t: {workspace} has used {level}% of its usage limit"),
581+ format!(
582+ "{workspace} has used {} of its {} usage limit this month. At the limit its sandboxes, builds and apps stop until it pays or the month turns. With a card on file, g1t charges it as the limit nears, so work keeps going.",
583+ dollars_plain(limit.exposure_micros),
584+ dollars_plain(ceiling),
585+ ),
586+ ),
587+ };
588+ if notify(identity, &workspace, &subject, &intro, "Open billing", &billing).await {
589+ self.db
590+ .prepare(
591+ "INSERT INTO limits (workspace, warned_month, warned_level, updated_at) VALUES (?1, ?2, ?3, ?4)
592+ ON CONFLICT (workspace) DO UPDATE SET warned_month = ?2, warned_level = ?3, updated_at = ?4",
593+ )
594+ .bind(&[workspace.as_str().into(), month.into(), (level as f64).into(), now.as_str().into()])?
595+ .run()
596+ .await?;
597+ }
598+ }
599+ Ok(())
600+ }
601+
357602 pub(crate) async fn check_limit(&self, a: CheckLimitArgs) -> Result<Outcome<Limit>> {
358603 Ok(Outcome::Ok(self.limit_of(&a.workspace).await?))
359604 }
382627 }
383628 }
384629
630+/// Which warning a workspace has reached: 100, 80, 50 or none (0).
631+pub(crate) fn warning_level(exposure: i64, ceiling: i64) -> i64 {
632+ if exposure >= ceiling {
633+ 100
634+ } else if exposure * 5 >= ceiling * 4 {
635+ 80
636+ } else if exposure * 2 >= ceiling {
637+ 50
638+ } else {
639+ 0
640+ }
641+}
642+
643+/// Emails the workspace's owners through identity. False if nothing was sent.
644+async fn notify(identity: &worker::Fetcher, workspace: &str, subject: &str, intro: &str, action: &str, link: &str) -> bool {
645+ let args = g1t_contracts::identity::NotifyOwnersArgs {
646+ workspace: workspace.to_owned(),
647+ subject: subject.to_owned(),
648+ intro: intro.to_owned(),
649+ action: action.to_owned(),
650+ link: link.to_owned(),
651+ footer: "You get this because you own this workspace on g1t. Usage limits are explained at https://docs.g1t.sh/guides/usage-and-billing/#usage-limits".to_owned(),
652+ };
653+ match g1t_kit::call::<_, u32>(identity, "notify_owners", &args).await {
654+ Ok(sent) => sent > 0,
655+ Err(error) => {
656+ worker::console_error!("could not tell {workspace}'s owners: {error}");
657+ false
658+ }
659+ }
660+}
661+
662+/// `2026-09` for `2026-10`, and `2025-12` for `2026-01`.
663+pub(crate) fn previous_month(month: &str) -> String {
664+ let year: i32 = month[..4].parse().unwrap_or(1970);
665+ let number: u32 = month[5..7].parse().unwrap_or(1);
666+ if number == 1 {
667+ format!("{}-12", year - 1)
668+ } else {
669+ format!("{year}-{:02}", number - 1)
670+ }
671+}
672+
385673 #[cfg(test)]
386674 mod tests {
387675 use super::*;
388676
677+ #[test]
678+ fn warnings_come_at_half_four_fifths_and_the_limit() {
679+ assert_eq!(warning_level(0, 300), 0);
680+ assert_eq!(warning_level(149, 300), 0);
681+ assert_eq!(warning_level(150, 300), 50);
682+ assert_eq!(warning_level(240, 300), 80);
683+ assert_eq!(warning_level(300, 300), 100);
684+ }
685+
686+ #[test]
687+ fn the_month_before_wraps_the_year() {
688+ assert_eq!(previous_month("2026-10"), "2026-09");
689+ assert_eq!(previous_month("2026-01"), "2025-12");
690+ }
691+
389692 fn ceilings() -> Ceilings {
390693 Ceilings { new: 3_000_000, paid_min: 25_000_000, paid_max: 1_000_000_000 }
391694 }
+113−0
2727 pub subscription: Option<String>,
2828 }
2929
30+/// g1t's settings for Stripe's hosted billing page.
31+#[derive(Debug, Deserialize)]
32+pub struct PortalConfiguration {
33+ pub id: String,
34+ #[serde(default)]
35+ pub login_page: Option<LoginPage>,
36+ #[serde(default)]
37+ pub metadata: Option<std::collections::HashMap<String, String>>,
38+}
39+
40+#[derive(Debug, Deserialize)]
41+pub struct LoginPage {
42+ pub url: Option<String>,
43+}
44+
45+/// A saved card's details.
46+#[derive(Debug, Deserialize)]
47+pub struct SavedCard {
48+ pub brand: String,
49+ pub last4: String,
50+ pub exp_month: u32,
51+ pub exp_year: u32,
52+}
53+
3054 /// A monthly plan.
3155 #[derive(Deserialize)]
3256 pub struct StripeSubscription {
184208 self.send(Method::Post, "/payment_intents", Some(form(&fields)), Some(key)).await
185209 }
186210
211+ /// A customer for a workspace that has none yet.
212+ pub async fn create_customer(&self, workspace: &str) -> Result<String> {
213+ #[derive(Deserialize)]
214+ struct Customer {
215+ id: String,
216+ }
217+ let fields = [
218+ ("name", workspace.to_owned()),
219+ ("metadata[workspace]", workspace.to_owned()),
220+ ];
221+ let customer: Customer = self.call(Method::Post, "/customers", Some(form(&fields))).await?;
222+ Ok(customer.id)
223+ }
224+
225+ /// A session on Stripe's hosted billing page (the customer portal) for
226+ /// the customer, coming back to `return_url`.
227+ pub async fn portal_session(&self, customer: &str, return_url: &str) -> Result<String> {
228+ #[derive(Deserialize)]
229+ struct Portal {
230+ url: String,
231+ }
232+ let configuration = self.portal_configuration().await?;
233+ let fields = [
234+ ("customer", customer.to_owned()),
235+ ("return_url", return_url.to_owned()),
236+ ("configuration", configuration.id),
237+ ];
238+ let portal: Portal = self.call(Method::Post, "/billing_portal/sessions", Some(form(&fields))).await?;
239+ Ok(portal.url)
240+ }
241+
242+ /// g1t's billing page settings at Stripe, made the first time they are
243+ /// needed: cards, invoices, billing details, and a sign-in page.
244+ pub async fn portal_configuration(&self) -> Result<PortalConfiguration> {
245+ #[derive(Deserialize)]
246+ struct List {
247+ data: Vec<PortalConfiguration>,
248+ }
249+ let list: List = self
250+ .call(Method::Get, "/billing_portal/configurations?active=true&limit=20", None)
251+ .await?;
252+ if let Some(existing) = list
253+ .data
254+ .into_iter()
255+ .find(|c| c.metadata.as_ref().and_then(|m| m.get("g1t")).is_some())
256+ {
257+ return Ok(existing);
258+ }
259+ let fields = [
260+ ("business_profile[headline]", "g1t billing: your card, invoices and billing details".to_owned()),
261+ ("features[payment_method_update][enabled]", "true".to_owned()),
262+ ("features[invoice_history][enabled]", "true".to_owned()),
263+ ("features[customer_update][enabled]", "true".to_owned()),
264+ ("features[customer_update][allowed_updates][0]", "email".to_owned()),
265+ ("features[customer_update][allowed_updates][1]", "address".to_owned()),
266+ ("features[customer_update][allowed_updates][2]", "name".to_owned()),
267+ ("features[customer_update][allowed_updates][3]", "tax_id".to_owned()),
268+ ("login_page[enabled]", "true".to_owned()),
269+ ("metadata[g1t]", "billing".to_owned()),
270+ ];
271+ self.call(Method::Post, "/billing_portal/configurations", Some(form(&fields))).await
272+ }
273+
274+ /// The customer's email at Stripe, if they gave one.
275+ pub async fn customer_email(&self, customer: &str) -> Result<Option<String>> {
276+ #[derive(Deserialize)]
277+ struct Customer {
278+ email: Option<String>,
279+ }
280+ let found: Customer = self.call(Method::Get, &format!("/customers/{}", encode(customer)), None).await?;
281+ Ok(found.email)
282+ }
283+
284+ /// The customer's card, if one is saved.
285+ pub async fn card(&self, customer: &str) -> Result<Option<SavedCard>> {
286+ #[derive(Deserialize)]
287+ struct Methods {
288+ data: Vec<Method_>,
289+ }
290+ #[derive(Deserialize)]
291+ struct Method_ {
292+ card: Option<SavedCard>,
293+ }
294+ let methods: Methods = self
295+ .call(Method::Get, &format!("/payment_methods?customer={}&type=card&limit=1", encode(customer)), None)
296+ .await?;
297+ Ok(methods.data.into_iter().next().and_then(|m| m.card))
298+ }
299+
187300 /// Starts a page on which `amount_cents` of credit is paid for by card.
188301 /// The card is kept for the workspace, so that topping up again, by
189302 /// hand or automatically, needs no retyping.
+2−0
1818 "migrations_dir": "migrations"
1919 }
2020 ],
21+ // Identity emails owners as their workspace nears its usage limit.
22+ "services": [{ "binding": "IDENTITY", "service": "g1t-identity" }],
2123 "vars": {
2224 // What is added to a run's cost, in percent. It pays the card
2325 // processor's fee and the sandbox the agent ran in.
+252−0
1+//! Staff-only views of workspaces, for sudo.g1t.sh.
2+//!
3+//! These methods take no viewer and check no membership. Only sudo calls
4+//! them, over its service binding, once it has verified a Cloudflare Access
5+//! sign-in against its staff list; nothing a customer can reach forwards to
6+//! them. They read, and never change, anything.
7+//!
8+//! Every workspace is listed: there is one kind, and a person's own space
9+//! is simply a workspace with one member. A user with no workspace has
10+//! nothing to list, as nothing can exist outside one.
11+
12+use std::collections::HashMap;
13+
14+use g1t_contracts::Role;
15+use g1t_contracts::identity::*;
16+use serde::Deserialize;
17+use worker::Result;
18+
19+use crate::Identity;
20+
21+#[derive(Deserialize)]
22+struct ListRow {
23+ id: String,
24+ slug: String,
25+ name: String,
26+ created_at: String,
27+ member_count: u32,
28+}
29+
30+#[derive(Deserialize)]
31+struct OwnerRow {
32+ workspace_id: String,
33+ username: String,
34+ email: Option<String>,
35+}
36+
37+#[derive(Deserialize)]
38+struct DetailRow {
39+ id: String,
40+ slug: String,
41+ name: String,
42+ description: Option<String>,
43+ created_at: String,
44+}
45+
46+#[derive(Deserialize)]
47+struct MemberRow {
48+ username: String,
49+ email: Option<String>,
50+ role: Role,
51+ joined: String,
52+}
53+
54+/// A `LIKE` pattern matching `query` anywhere, lowercased, with `%`, `_`
55+/// and the escape character itself taken literally. None for a blank query.
56+fn like_pattern(query: Option<&str>) -> Option<String> {
57+ let query = query.map(str::trim).filter(|q| !q.is_empty())?;
58+ let mut pattern = String::from("%");
59+ for c in query.to_lowercase().chars().take(100) {
60+ if matches!(c, '%' | '_' | '\\') {
61+ pattern.push('\\');
62+ }
63+ pattern.push(c);
64+ }
65+ pattern.push('%');
66+ Some(pattern)
67+}
68+
69+/// The workspaces, in their order, each with its owners.
70+fn with_owners(rows: Vec<ListRow>, owners: Vec<OwnerRow>) -> Vec<AdminWorkspace> {
71+ let mut by_workspace: HashMap<String, Vec<AdminOwner>> = HashMap::new();
72+ for owner in owners {
73+ by_workspace.entry(owner.workspace_id).or_default().push(AdminOwner {
74+ username: owner.username,
75+ email: owner.email,
76+ });
77+ }
78+ rows.into_iter()
79+ .map(|row| AdminWorkspace {
80+ owners: by_workspace.remove(&row.id).unwrap_or_default(),
81+ slug: row.slug,
82+ name: row.name,
83+ created_at: row.created_at,
84+ member_count: row.member_count,
85+ })
86+ .collect()
87+}
88+
89+impl Identity {
90+ /// The workspaces staff can see, newest first. Staff only: see the
91+ /// module's note.
92+ /// Emails each owner of the workspace with a confirmed address. Only
93+ /// other services call this; the link must stay on g1t.sh, so a notice
94+ /// can never point owners anywhere else.
95+ pub async fn notify_owners(&self, a: NotifyOwnersArgs) -> Result<u32> {
96+ if !a.link.starts_with("https://g1t.sh/") {
97+ return Ok(0);
98+ }
99+ #[derive(Deserialize)]
100+ struct Owner {
101+ email: Option<String>,
102+ }
103+ let owners = self
104+ .db
105+ .prepare(
106+ "SELECT u.email FROM workspace_members m
107+ JOIN users u ON u.id = m.user_id
108+ JOIN workspaces w ON w.id = m.workspace_id
109+ WHERE w.slug = ? AND m.role = 'owner' AND u.email_verified_at IS NOT NULL",
110+ )
111+ .bind(&[a.workspace.to_lowercase().into()])?
112+ .all()
113+ .await?
114+ .results::<Owner>()?;
115+ let mut sent = 0;
116+ for email in owners.into_iter().filter_map(|owner| owner.email) {
117+ match crate::email::send_link(&self.env, &email, &a.subject, &a.intro, &a.action, &a.link, &a.footer).await {
118+ Ok(()) => sent += 1,
119+ Err(error) => worker::console_error!("could not email an owner of {}: {error}", a.workspace),
120+ }
121+ }
122+ Ok(sent)
123+ }
124+
125+ pub async fn admin_workspaces(&self, a: AdminWorkspacesArgs) -> Result<Vec<AdminWorkspace>> {
126+ let pattern = like_pattern(a.query.as_deref());
127+ // The same filter picks the workspaces and, below, their owners.
128+ let filter = if pattern.is_some() {
129+ "WHERE w.slug LIKE ?1 ESCAPE '\\' OR lower(w.name) LIKE ?1 ESCAPE '\\'
130+ OR EXISTS (SELECT 1 FROM workspace_members om JOIN users ou ON ou.id = om.user_id
131+ WHERE om.workspace_id = w.id AND om.role = 'owner'
132+ AND (lower(ou.username) LIKE ?1 ESCAPE '\\' OR lower(ou.email) LIKE ?1 ESCAPE '\\'))"
133+ } else {
134+ ""
135+ };
136+ let chosen = format!(
137+ "SELECT w.id FROM workspaces w {filter}
138+ ORDER BY w.created_at DESC, w.slug LIMIT {ADMIN_WORKSPACES_LIMIT}"
139+ );
140+ let binds: Vec<worker::wasm_bindgen::JsValue> = pattern.into_iter().map(Into::into).collect();
141+ let rows = self
142+ .db
143+ .prepare(format!(
144+ "SELECT w.id, w.slug, w.name, w.created_at,
145+ (SELECT count(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count
146+ FROM workspaces w WHERE w.id IN ({chosen})
147+ ORDER BY w.created_at DESC, w.slug"
148+ ))
149+ .bind(&binds)?
150+ .all()
151+ .await?
152+ .results::<ListRow>()?;
153+ let owners = self
154+ .db
155+ .prepare(format!(
156+ "SELECT m.workspace_id, u.username, u.email FROM workspace_members m
157+ JOIN users u ON u.id = m.user_id
158+ WHERE m.role = 'owner' AND m.workspace_id IN ({chosen})
159+ ORDER BY m.created_at, u.username"
160+ ))
161+ .bind(&binds)?
162+ .all()
163+ .await?
164+ .results::<OwnerRow>()?;
165+ Ok(with_owners(rows, owners))
166+ }
167+
168+ /// One workspace with every member, or None. Staff only: see the
169+ /// module's note.
170+ pub async fn admin_workspace(&self, a: SlugArgs) -> Result<Option<AdminWorkspaceDetail>> {
171+ let Some(row) = self
172+ .db
173+ .prepare("SELECT id, slug, name, description, created_at FROM workspaces WHERE slug = ?")
174+ .bind(&[a.slug.trim().to_lowercase().into()])?
175+ .first::<DetailRow>(None)
176+ .await?
177+ else {
178+ return Ok(None);
179+ };
180+ let members = self
181+ .db
182+ .prepare(
183+ "SELECT u.username, u.email, m.role, m.created_at AS joined FROM workspace_members m
184+ JOIN users u ON u.id = m.user_id
185+ WHERE m.workspace_id = ?
186+ ORDER BY m.role DESC, u.username",
187+ )
188+ .bind(&[row.id.as_str().into()])?
189+ .all()
190+ .await?
191+ .results::<MemberRow>()?
192+ .into_iter()
193+ .map(|m| AdminMember {
194+ username: m.username,
195+ email: m.email,
196+ role: m.role,
197+ joined: m.joined,
198+ })
199+ .collect();
200+ Ok(Some(AdminWorkspaceDetail {
201+ slug: row.slug,
202+ name: row.name,
203+ description: row.description,
204+ created_at: row.created_at,
205+ members,
206+ }))
207+ }
208+}
209+
210+#[cfg(test)]
211+mod tests {
212+ use super::*;
213+
214+ #[test]
215+ fn a_blank_query_matches_everything() {
216+ assert_eq!(like_pattern(None), None);
217+ assert_eq!(like_pattern(Some(" ")), None);
218+ }
219+
220+ #[test]
221+ fn a_query_is_matched_literally_anywhere() {
222+ assert_eq!(like_pattern(Some(" Acme ")).as_deref(), Some("%acme%"));
223+ assert_eq!(like_pattern(Some("50%_off\\")).as_deref(), Some("%50\\%\\_off\\\\%"));
224+ }
225+
226+ #[test]
227+ fn owners_go_to_their_workspaces_in_order() {
228+ let row = |id: &str, slug: &str| ListRow {
229+ id: id.into(),
230+ slug: slug.into(),
231+ name: slug.into(),
232+ created_at: "2026-10-04T00:00:00Z".into(),
233+ member_count: 2,
234+ };
235+ let owner = |workspace_id: &str, username: &str| OwnerRow {
236+ workspace_id: workspace_id.into(),
237+ username: username.into(),
238+ email: Some(format!("{username}@example.com")),
239+ };
240+ let listed = with_owners(
241+ vec![row("wsp_2", "newer"), row("wsp_1", "older"), row("wsp_3", "orphan")],
242+ vec![owner("wsp_1", "ada"), owner("wsp_2", "bob"), owner("wsp_1", "cy")],
243+ );
244+ let slugs: Vec<_> = listed.iter().map(|w| w.slug.as_str()).collect();
245+ assert_eq!(slugs, ["newer", "older", "orphan"]);
246+ let owners = |i: usize| listed[i].owners.iter().map(|o| o.username.as_str()).collect::<Vec<_>>();
247+ assert_eq!(owners(0), ["bob"]);
248+ assert_eq!(owners(1), ["ada", "cy"]);
249+ assert!(owners(2).is_empty());
250+ assert_eq!(listed[1].owners[0].email.as_deref(), Some("ada@example.com"));
251+ }
252+}
+1−1
1717 }
1818
1919 /// A short message with one link to follow.
20−async fn send_link(
20+pub async fn send_link(
2121 env: &Env,
2222 to: &str,
2323 subject: &str,
+5−0
33 //! Reached only through service bindings; see `g1t_contracts::identity` for
44 //! the methods and their arguments.
55
6+mod admin;
67 mod crypto;
78 mod device;
89 mod email;
573574 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
574575 "agent_scope" => reply(&identity.agent_scope(args(body)?).await?),
575576 "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?),
577+ // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
578+ "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
579+ "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
580+ "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
576581 _ => Response::error("Unknown method", 404),
577582 }
578583 }