g1t/apps/sudo/app/components/billing.tsx

449 lines19,280 bytesCodeBlame
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 */
6import { CreditCard, Gift, ScrollText, UserRound } from "lucide-react";
7import type { ReactNode } from "react";
8import { Link } from "react-router";
9
10import type { AdminAction, AdminOwner, BillingLink, LedgerEntry, Terms } from "@g1t/contracts";
11
12import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
13import { dollarsField, usd } from "~/lib/money";
14import type { Review, SectionError } from "~/lib/review";
15
16export 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
26export 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). */
37export 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
66function 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
76export 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
165const 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
171export 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
233export 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 */
294export 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
345const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
346
347export 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
411const 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
420export 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}