Skip to content
908 linesCodeBlameRaw
1/**
2 * The billing sections shared by a workspace's page and an enterprise's:
3 * confirmations, terms, plan, pools and caps, credit, bank transfers, the
4 * Stripe billing link, the ledger and the audit log. Plain forms; sudo ships no JavaScript.
5 */
6import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react";
7import type { ReactNode } from "react";
8import { Link } from "react-router";
9
10import type { AdminAction, AdminOwner, Allowances, BillingLink, CreditGrant, Credits, LedgerEntry, Terms } from "@g1t/contracts";
11
12import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
13import { CREDIT_KINDS, CREDIT_PRESETS, EXPIRIES, kindLabel } from "~/lib/credits";
14import { DISCOUNT_PRESETS, fullDiscount, percentOff, termsLabel } from "~/lib/terms";
15import { actionLabel } from "~/lib/ledgers";
16import { dollarsField, usd } from "~/lib/money";
17import { givenParts } from "~/lib/pricing";
18import type { Review, SectionError } from "~/lib/review";
19
20export function Hidden({ values }: { values: Record<string, string> }) {
21 return (
22 <>
23 {Object.entries(values).map(([name, value]) => (
24 <input key={name} type="hidden" name={name} value={value} />
25 ))}
26 </>
27 );
28}
29
30export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) {
31 return (
32 <div className="rounded-lg border border-line bg-surface px-4 py-3">
33 <p className="text-xs text-muted">{label}</p>
34 <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
35 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
36 </div>
37 );
38}
39
40/** Owners' usernames, each with their email beneath (and on hover). */
41export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) {
42 if (owners.length === 0) return <span className="text-faint">No owner</span>;
43 if (compact) {
44 return (
45 <span className="flex flex-col gap-0.5">
46 {owners.map((owner) => (
47 <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate">
48 <span className="font-mono text-fg-soft">{owner.username}</span>
49 {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>}
50 </span>
51 ))}
52 </span>
53 );
54 }
55 return (
56 <ul className="flex flex-wrap gap-2">
57 {owners.map((owner) => (
58 <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">
59 <Avatar name={owner.username} size={14} square={false} />
60 <span className="font-mono">{owner.username}</span>
61 {owner.email && <span className="text-faint">{owner.email}</span>}
62 </li>
63 ))}
64 </ul>
65 );
66}
67
68// --- Confirmation ------------------------------------------------------------
69
70function describeTerms(terms: Terms): [string, string][] {
71 return [
72 ["Terms", termsLabel(terms)],
73 ["Discount", `${percentOff(terms)}%`],
74 [
75 "Limit",
76 fullDiscount(terms)
77 ? terms.ceilingMicros == null
78 ? "The default monthly budget, at cost"
79 : `${usd(terms.ceilingMicros)} a month, at cost`
80 : terms.ceilingMicros == null
81 ? "By trust"
82 : usd(terms.ceilingMicros),
83 ],
84 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
85 ["Note", terms.note || "—"],
86 ];
87}
88
89/** Where a confirmed change lands, when its result is shown in place. */
90const RESULT_ANCHOR: Partial<Record<Review["intent"], string>> = { "billing-link": "billing-link", invoice: "invoices" };
91
92export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
93 let title: string;
94 let body: ReactNode;
95 let danger = false;
96 let confirm = "Confirm";
97 if (review.intent === "terms") {
98 const before = describeTerms(review.before);
99 const after = describeTerms(review.after);
100 title = "Confirm the new terms";
101 danger = fullDiscount(review.after);
102 body = (
103 <>
104 <div className="overflow-x-auto">
105 <table className="w-full text-sm">
106 <thead>
107 <tr className="text-left text-xs text-muted">
108 <th className="py-1.5 pr-4 font-medium" />
109 <th className="py-1.5 pr-4 font-medium">Now</th>
110 <th className="py-1.5 font-medium">After</th>
111 </tr>
112 </thead>
113 <tbody>
114 {after.map(([label, value], index) => (
115 <tr key={label} className="border-t border-line align-top">
116 <td className="py-1.5 pr-4 text-muted">{label}</td>
117 <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
118 <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
119 </tr>
120 ))}
121 </tbody>
122 </table>
123 </div>
124 {fullDiscount(review.after) && (
125 <p className="mt-3 text-sm text-warn">
126 A 100% discount: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. The workspace's
127 statement still shows its usage at price, with the discount beside it.
128 </p>
129 )}
130 </>
131 );
132 } else if (review.intent === "attach") {
133 title = `Bill ${review.workspace} to ${review.targetName}?`;
134 body = (
135 <p className="text-sm text-muted">
136 From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
137 <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
138 </p>
139 );
140 } else if (review.intent === "detach") {
141 title = `Move ${review.workspace} off ${review.from}?`;
142 danger = true;
143 body = (
144 <p className="text-sm text-muted">
145 <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit
146 its trust gives it. It may stop at once if its own limit is lower than what it owes.
147 </p>
148 );
149 } else if (review.intent === "billing-email") {
150 title = `Send ${review.name}'s invoices to ${review.after}?`;
151 body = (
152 <p className="text-sm text-muted">
153 Stripe emails its invoices to <span className="font-mono text-fg">{review.after}</span>
154 {review.before ? (
155 <>
156 {" "}
157 instead of <span className="font-mono">{review.before}</span>
158 </>
159 ) : null}
160 , from the next invoice on.{!review.before && " This also sets the enterprise up as a customer on Stripe."}
161 </p>
162 );
163 } else if (review.intent === "invoice") {
164 title = `Invoice ${review.name} now?`;
165 confirm = "Send the invoice";
166 body = (
167 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
168 <li>Makes one Stripe invoice, with a line for each of its {review.workspaces} workspace{review.workspaces === 1 ? "" : "s"} for what it owes now.</li>
169 <li>Net 30: due in 30 days.</li>
170 <li>
171 Stripe emails it to <span className="font-mono text-fg">{review.email ?? "the invoice email (none set yet)"}</span>, with a link to
172 pay on Stripe's page.
173 </li>
174 </ul>
175 );
176 } else {
177 title = `Make a Stripe billing link for ${review.workspace}?`;
178 confirm = "Make the link";
179 body = (
180 <p className="text-sm text-muted">
181 Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s
182 customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it
183 only to the workspace's owner. Making it is recorded with your email.
184 </p>
185 );
186 }
187 return (
188 <section id="review" className={`scroll-mt-20 rounded-lg border p-4 sm:p-5 ${danger ? "border-warn/40 bg-warn/5" : "border-accent/40 bg-accent/5"}`}>
189 <h2 className="font-semibold tracking-tight">{title}</h2>
190 <div className="mt-3">{body}</div>
191 <form method="post" action={`${pathname}#${RESULT_ANCHOR[review.intent] ?? "top"}`} className="mt-4 flex flex-wrap items-center gap-2">
192 <Hidden values={review.fields} />
193 <input type="hidden" name="intent" value={review.intent} />
194 <input type="hidden" name="confirm" value="yes" />
195 <Button type="submit" variant={danger ? "danger" : "lavender"}>
196 {confirm}
197 </Button>
198 <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
199 Cancel
200 </Link>
201 </form>
202 </section>
203 );
204}
205
206// --- Terms -------------------------------------------------------------------
207
208/**
209 * The account's terms: a discount (0, 25, 50, 100% or a custom percent),
210 * a limit, an end date and why. A 100% discount charges nothing; the
211 * workspace's statement still shows its usage at price. The custom percent
212 * shows only for Custom, by CSS alone (`.terms-form` in app.css).
213 */
214export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
215 const values = error?.values;
216 const current = percentOff(terms);
217 const preset = values?.preset ?? ((DISCOUNT_PRESETS as readonly number[]).includes(current) ? String(current) : "custom");
218 return (
219 <Section
220 id="terms"
221 title="Terms"
222 description={
223 terms.setBy ? (
224 <>
225 Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
226 {terms.note && <> · “{terms.note}”</>}
227 </>
228 ) : (
229 "Standard, as everyone starts."
230 )
231 }
232 >
233 <form method="post" action={`${pathname}#review`} className="terms-form space-y-4">
234 <input type="hidden" name="intent" value="terms" />
235 {error && <Notice tone="error">{error.error}</Notice>}
236 <fieldset>
237 <legend className="mb-1.5 text-sm font-medium text-muted">Discount</legend>
238 <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg">
239 {DISCOUNT_PRESETS.map((percent) => (
240 <Segment key={percent} name="preset" value={String(percent)} checked={preset === String(percent)}>
241 {percent === 0 ? "None" : `${percent}%`}
242 </Segment>
243 ))}
244 <Segment name="preset" value="custom" checked={preset === "custom"}>
245 Custom
246 </Segment>
247 </div>
248 <p className="mt-1.5 text-xs text-faint">Off every usage charge. 100%: nothing is charged, and the statement shows usage at price with the discount beside it.</p>
249 </fieldset>
250 <div className="when-custom">
251 <Field label="Custom discount %" hint="A whole percent, 1 to 100.">
252 <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="30" defaultValue={values?.discount ?? (preset === "custom" ? String(current) : "")} />
253 </Field>
254 </div>
255 <div className="grid gap-4 sm:grid-cols-2">
256 <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. At 100%: g1t's monthly budget for it, at cost; blank: the default ($150).">
257 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
258 </Field>
259 <Field label="Until" hint="Blank: no end. UTC.">
260 <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
261 </Field>
262 </div>
263 <Field label="Reason" hint="Required. Why, for whoever looks next; at 100%, shown as the reason for the discount.">
264 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. g1t's own workspace" />
265 </Field>
266 <div className="flex justify-end">
267 <Button type="submit">Review terms</Button>
268 </div>
269 </form>
270 </Section>
271 );
272}
273
274// --- Plan, pools and caps -------------------------------------------------------
275
276/** What staff have set beyond the terms, one line each; defaults left out. */
277export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] {
278 const lines: string[] = [];
279 if (comped) lines.push("The g1t plan, without its price (100% discount)");
280 else if (allowances?.plan) lines.push("The g1t plan, without its price");
281 if (!allowances) return lines;
282 if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`);
283 if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`);
284 if (allowances.auditRetentionDays != null) lines.push(`Audit log: ${allowances.auditRetentionDays} days, in place of the plan's`);
285 if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
286 return lines;
287}
288
289/**
290 * The g1t plan without its price, the account's share of g1t's pools, and
291 * staff's overrides of what owners set: the days of audit log kept (such as for an organization that pays
292 * for longer), and a hold on new compute. Accounts on a 100% discount have the plan
293 * anyway.
294 */
295export function AllowancesForm({
296 allowances,
297 comped,
298 pathname,
299 error,
300}: {
301 allowances: Allowances | undefined;
302 comped: boolean;
303 pathname: string;
304 error: SectionError;
305}) {
306 const values = error?.values;
307 const current: Allowances = allowances ?? { plan: false, ossRepoMicros: null, trialMicros: null };
308 const lines = allowanceLines(allowances, comped);
309 return (
310 <Section
311 id="allowances"
312 title="Plan, pools and caps"
313 description={
314 comped
315 ? "A 100% discount: the g1t plan is on without its price whatever is set here. The pools and caps still apply."
316 : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set."
317 }
318 >
319 <div className="mb-4 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm">
320 <p className="text-xs text-faint">Set now</p>
321 {lines.length === 0 ? (
322 <p className="mt-0.5 text-muted">Nothing: the defaults.</p>
323 ) : (
324 <ul className="mt-1 space-y-0.5">
325 {lines.map((line) => (
326 <li key={line} className={line.startsWith("Held:") ? "text-danger" : "text-fg-soft"}>
327 {line}
328 </li>
329 ))}
330 </ul>
331 )}
332 </div>
333 <form method="post" action={`${pathname}#allowances`} className="space-y-4">
334 <input type="hidden" name="intent" value="allowances" />
335 {error && <Notice tone="error">{error.error}</Notice>}
336 <label className="flex cursor-pointer items-start gap-2.5 text-sm">
337 <input type="checkbox" name="plan" defaultChecked={values ? values.plan === "on" : current.plan} className="mt-0.5" />
338 <span>
339 <span className="block font-medium">The g1t plan, without its price</span>
340 <span className="block text-xs text-muted">
341 Its included usage, storage and caps, with no $20 a month. Usage past it is charged as usual.
342 </span>
343 </span>
344 </label>
345 <div className="grid gap-4 sm:grid-cols-2">
346 <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default ($2).">
347 <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
348 </Field>
349 <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5).">
350 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
351 </Field>
352 <Field label="Audit log days" hint="Kept, in place of the plan's, longer or shorter, up to 400. Blank: the plan's (7 free, 90 on the plan).">
353 <Input
354 name="auditDays"
355 inputMode="numeric"
356 pattern="\d{1,3}"
357 placeholder="Plan's"
358 defaultValue={values?.auditDays ?? (current.auditRetentionDays != null ? String(current.auditRetentionDays) : "")}
359 />
360 </Field>
361 <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold.">
362 <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} />
363 </Field>
364 </div>
365 <Field label="Note" hint="Required. Why, for whoever looks next.">
366 <Textarea
367 name="note"
368 rows={2}
369 required
370 maxLength={500}
371 defaultValue={values?.note ?? ""}
372 placeholder="e.g. Open-source foundation, larger share through 2027"
373 />
374 </Field>
375 <div className="flex justify-end">
376 <Button type="submit">Save plan, pools and caps</Button>
377 </div>
378 </form>
379 </Section>
380 );
381}
382
383// --- Bank transfer ---------------------------------------------------------------
384
385/**
386 * Money that reached g1t outside Stripe's page, such as a bank transfer
387 * for a $1,000+ prepayment, entered as a payment.
388 */
389export function PaymentForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
390 const values = error?.values;
391 return (
392 <Section
393 id="payment"
394 title="Record a bank transfer"
395 description="Money that reached g1t's bank without Stripe's page, such as a $1,000+ prepayment. It goes on the statement as a payment and counts toward the limit at once."
396 >
397 <form method="post" action={`${pathname}#payment`} className="space-y-4">
398 <input type="hidden" name="intent" value="payment" />
399 {error && <Notice tone="error">{error.error}</Notice>}
400 <div className="grid gap-4 sm:grid-cols-2">
401 <Field label="Amount $" hint="As it arrived, up to $100,000.">
402 <Input name="amount" inputMode="decimal" required placeholder="1,500.00" defaultValue={values?.amount ?? ""} />
403 </Field>
404 <Field label="Reference" hint="As the bank shows it. Each is recorded once.">
405 <Input name="reference" required maxLength={100} className="font-mono" defaultValue={values?.reference ?? ""} />
406 </Field>
407 </div>
408 <Field label="Note" hint="Required. Shown on the statement after “Paid by bank transfer”.">
409 <Textarea
410 name="note"
411 rows={2}
412 required
413 maxLength={500}
414 placeholder="e.g. Prepayment for October, invoice INV-0042"
415 defaultValue={values?.note ?? ""}
416 />
417 </Field>
418 <Field
419 label="Confirm"
420 hint={
421 <>
422 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to record it.
423 </>
424 }
425 >
426 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
427 </Field>
428 <div className="flex justify-end">
429 <Button type="submit" variant="lavender">
430 <Landmark size={14} />
431 Record the payment
432 </Button>
433 </div>
434 </form>
435 </Section>
436 );
437}
438
439// --- Credit -------------------------------------------------------------------
440
441/** A radio drawn as one button of a segmented row; still a plain radio without CSS. */
442function Segment({ name, value, checked, children }: { name: string; value: string; checked: boolean; children: ReactNode }) {
443 return (
444 <label className="flex-1 cursor-pointer border-r border-line px-3 py-1.5 text-center text-sm whitespace-nowrap text-muted transition-colors last:border-r-0 hover:bg-raised hover:text-fg has-checked:bg-accent has-checked:font-medium has-checked:text-bg has-focus-visible:outline-2 has-focus-visible:-outline-offset-2 has-focus-visible:outline-accent">
445 <input type="radio" name={name} value={value} defaultChecked={checked} className="sr-only" />
446 {children}
447 </label>
448 );
449}
450
451/**
452 * Credit for a workspace: a preset amount or a custom one, a kind, an
453 * expiry, and a note. Fields that belong to one choice (the custom amount,
454 * a refund's details, an expiry date) show only for it, by CSS alone
455 * (`.credit-form` in app.css); without `:has()` every field shows and the
456 * ones that do not apply are ignored. Over $100 the slug is typed out too.
457 */
458export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
459 const values = error?.values;
460 const single = workspaces.length === 1 ? workspaces[0] : null;
461 const preset = values?.preset ?? "25";
462 const kind = values?.kind ?? "promotional";
463 const expires = values?.expires ?? "none";
464 return (
465 <Section
466 id="credit"
467 title="Give credit"
468 description="Added to the balance at once and spent before anything paid in advance. The owners are emailed."
469 >
470 {workspaces.length === 0 ? (
471 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
472 ) : (
473 <form method="post" action={`${pathname}#credit`} className="credit-form space-y-4">
474 <input type="hidden" name="intent" value="credit" />
475 {error && <Notice tone="error">{error.error}</Notice>}
476 {single ? (
477 <input type="hidden" name="workspace" value={single} />
478 ) : (
479 <Field label="Workspace">
480 <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
481 <option value="" disabled>
482 Choose a workspace
483 </option>
484 {workspaces.map((slug) => (
485 <option key={slug} value={slug}>
486 {slug}
487 </option>
488 ))}
489 </Select>
490 </Field>
491 )}
492 <fieldset>
493 <legend className="mb-1.5 text-sm font-medium text-muted">Amount</legend>
494 <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg">
495 {CREDIT_PRESETS.map((dollars) => (
496 <Segment key={dollars} name="preset" value={String(dollars)} checked={preset === String(dollars)}>
497 ${dollars}
498 </Segment>
499 ))}
500 <Segment name="preset" value="custom" checked={preset === "custom"}>
501 Custom
502 </Segment>
503 </div>
504 </fieldset>
505 <div className="when-custom space-y-4">
506 <Field label="Custom amount $" hint="Up to $10,000 at a time.">
507 <Input name="amount" inputMode="decimal" placeholder="15.00" defaultValue={values?.amount ?? ""} />
508 </Field>
509 </div>
510 <fieldset>
511 <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
512 <div className="grid gap-2">
513 {CREDIT_KINDS.map((option) => (
514 <label
515 key={option.value}
516 className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg px-3 py-2.5 transition-colors hover:border-line-strong has-checked:border-accent/60 has-checked:bg-accent/8"
517 >
518 <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
519 <span>
520 <span className="block text-sm font-medium">{option.title}</span>
521 <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
522 </span>
523 </label>
524 ))}
525 </div>
526 </fieldset>
527 <div className="when-refund">
528 <div className="grid gap-4 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
529 <Field label="Refund for" hint="Refunds only. On the statement and in the email.">
530 <Input name="refundFor" maxLength={200} placeholder="the failed runs on Oct 2" defaultValue={values?.refundFor ?? ""} />
531 </Field>
532 <Field label="Day refunded" hint="Comes off what was paid that day. Blank: today.">
533 <Input type="date" name="refundDay" defaultValue={values?.refundDay ?? ""} />
534 </Field>
535 </div>
536 </div>
537 <fieldset className="when-not-refund">
538 <legend className="mb-1.5 text-sm font-medium text-muted">Expires</legend>
539 <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg">
540 {EXPIRIES.map((option) => (
541 <Segment key={option.value} name="expires" value={option.value} checked={expires === option.value}>
542 {option.label}
543 </Segment>
544 ))}
545 </div>
546 <p className="mt-1.5 text-xs text-faint">Unused credit stops counting then. A refund never expires.</p>
547 </fieldset>
548 <div className="when-date when-not-refund">
549 <Field label="Expires on" hint="At the end of the day, UTC.">
550 <Input type="date" name="expiresOn" defaultValue={values?.expiresOn ?? ""} />
551 </Field>
552 </div>
553 <Field label="Note" hint="Required. On the workspace's statement and in the owners' email.">
554 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Welcome to g1t" defaultValue={values?.note ?? ""} />
555 </Field>
556 <div className="when-custom">
557 <Field
558 label="Confirm"
559 hint={
560 <>
561 Over $100 only: type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>}.
562 </>
563 }
564 >
565 <Input name="confirmation" placeholder={single ?? "workspace-slug"} className="font-mono" />
566 </Field>
567 </div>
568 <div className="flex justify-end">
569 <Button type="submit" variant="lavender">
570 <Gift size={14} />
571 Give credit
572 </Button>
573 </div>
574 </form>
575 )}
576 </Section>
577 );
578}
579
580const GRANT_STATE: Record<CreditGrant["state"], { label: string; tone: "mint" | "plain" | "warn" | "danger" }> = {
581 open: { label: "Open", tone: "mint" },
582 used: { label: "Used", tone: "plain" },
583 expired: { label: "Expired", tone: "warn" },
584 revoked: { label: "Revoked", tone: "danger" },
585};
586
587/**
588 * Credits g1t gave, newest first: amount, kind, note, who, when, used,
589 * left and expiry, and on an open one a form to take back what is left.
590 */
591export function CreditList({
592 grants,
593 pathname,
594 error,
595 showWorkspace = false,
596}: {
597 grants: CreditGrant[];
598 pathname: string;
599 error?: SectionError;
600 showWorkspace?: boolean;
601}) {
602 if (grants.length === 0) return <EmptyState title="No credits given">Credit given from sudo shows here, with what is left of it.</EmptyState>;
603 return (
604 <ul className="-my-1 divide-y divide-line">
605 {grants.map((grant) => {
606 const state = GRANT_STATE[grant.state];
607 const failed = error && error.values?.id === grant.id ? error.error : null;
608 return (
609 <li key={grant.id} id={`grant-${grant.id}`} className="scroll-mt-20 py-3">
610 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
611 <span className="tabular font-semibold">{usd(grant.amountMicros, { cents: true })}</span>
612 <Badge tone="lavender">{kindLabel(grant.kind)}</Badge>
613 <Badge tone={state.tone}>{state.label}</Badge>
614 {showWorkspace && (
615 <Link to={`/workspaces/${encodeURIComponent(grant.workspace)}#credits`} className="font-mono text-xs text-accent hover:underline">
616 {grant.workspace}
617 </Link>
618 )}
619 </div>
620 <p className="tabular mt-1 text-sm text-fg-soft">
621 Used {usd(grant.usedMicros, { cents: true })} · left {usd(grant.leftMicros, { cents: true })}
622 {grant.expiresAt ? (
623 <>
624 {" "}
625 · {grant.state === "expired" ? "expired" : "expires"} <When at={grant.expiresAt} />
626 </>
627 ) : grant.kind === "refund" ? (
628 " · never expires"
629 ) : (
630 " · no expiry"
631 )}
632 </p>
633 <p className="mt-0.5 text-sm break-words">
634 {grant.kind === "refund" && grant.refundFor && <span className="text-muted">For {grant.refundFor}{grant.refundDay ? ` (${grant.refundDay})` : ""}: </span>}
635 {grant.note}
636 </p>
637 <p className="mt-0.5 flex flex-wrap items-center gap-x-1 font-mono text-xs text-faint">
638 <UserRound size={11} />
639 {grant.createdBy} · <When at={grant.createdAt} time />
640 </p>
641 {grant.closedAt && grant.state === "revoked" && (
642 <p className="mt-1 text-xs text-muted">
643 {usd(grant.closedMicros ?? 0, { cents: true })} taken back by {grant.closedBy} on <When at={grant.closedAt} />
644 {grant.closedNote ? `: “${grant.closedNote}”` : ""}
645 </p>
646 )}
647 {grant.closedAt && grant.state === "expired" && (grant.closedMicros ?? 0) > 0 && (
648 <p className="mt-1 text-xs text-muted">{usd(grant.closedMicros ?? 0, { cents: true })} unused when it expired.</p>
649 )}
650 {grant.state === "open" && (
651 <details className="mt-2" open={failed != null}>
652 <summary className="cursor-pointer text-xs text-danger hover:underline">Revoke unused</summary>
653 <form method="post" action={`${pathname}#grant-${grant.id}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end">
654 <input type="hidden" name="intent" value="revoke-credit" />
655 <input type="hidden" name="id" value={grant.id} />
656 {showWorkspace && <input type="hidden" name="workspace" value={grant.workspace} />}
657 <Field label="Why" hint={`Takes ${usd(grant.leftMicros, { cents: true })} off the balance. Kept in the audit log.`} className="flex-1">
658 <Input name="reason" required maxLength={500} placeholder="e.g. Given to the wrong workspace" defaultValue={failed ? (error?.values?.reason ?? "") : ""} />
659 </Field>
660 <Button type="submit" variant="danger" className="shrink-0">
661 Revoke {usd(grant.leftMicros, { cents: true })}
662 </Button>
663 </form>
664 {failed && (
665 <div className="mt-2">
666 <Notice tone="error">{failed}</Notice>
667 </div>
668 )}
669 </details>
670 )}
671 </li>
672 );
673 })}
674 </ul>
675 );
676}
677
678/** A workspace's credits from g1t: what is left to spend, and each grant. */
679export function CreditsSection({
680 credits,
681 unavailable,
682 pathname,
683 error,
684}: {
685 credits: Credits | null;
686 unavailable: string | null;
687 pathname: string;
688 error: SectionError;
689}) {
690 return (
691 <Section
692 id="credits"
693 title="Credits"
694 description={
695 credits
696 ? `${usd(credits.leftMicros, { cents: true })} left to spend. Spent before anything paid in advance, the soonest-expiring first.`
697 : "Credits from g1t, with what is left of each."
698 }
699 >
700 {unavailable ? <Notice tone="warn">Billing did not answer for credits: {unavailable}</Notice> : <CreditList grants={credits?.grants ?? []} pathname={pathname} error={error} />}
701 </Section>
702 );
703}
704
705// --- Reset (testing) -----------------------------------------------------------
706
707/**
708 * Wipes a test workspace's billing so it starts again as a new customer.
709 * Only while billing runs on Stripe's test key; billing refuses 100% discounts and
710 * enterprise workspaces. The workspace, its members and repositories stay.
711 */
712export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
713 const values = error?.values;
714 return (
715 <Section
716 id="reset"
717 title="Reset billing (testing)"
718 description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. What its usage cost g1t is kept, and counted on Costs & margin as given away (testing resets). The workspace, its members and its repositories stay. Only while billing is on Stripe's test key."
719 >
720 <form method="post" action={`${pathname}#reset`} className="space-y-4">
721 <input type="hidden" name="intent" value="reset" />
722 {error && <Notice tone="error">{error.error}</Notice>}
723 <Field label="Why" hint="Required. Kept in the audit log.">
724 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Test workspace, starting the customer walk-through again" defaultValue={values?.note ?? ""} />
725 </Field>
726 <Field
727 label="Confirm"
728 hint={
729 <>
730 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to wipe its billing. It cannot be undone.
731 </>
732 }
733 >
734 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
735 </Field>
736 <div className="flex justify-end">
737 <Button type="submit" variant="danger">
738 <RotateCcw size={14} />
739 Reset billing
740 </Button>
741 </div>
742 </form>
743 </Section>
744 );
745}
746
747// --- Stripe billing link -----------------------------------------------------
748
749/**
750 * Card details, invoices and receipts live on Stripe's own billing page.
751 * sudo never shows a card field: staff send the customer a link instead.
752 */
753export function BillingLinkSection({
754 link,
755 pathname,
756 error,
757}: {
758 link: BillingLink | null;
759 pathname: string;
760 error: SectionError;
761}) {
762 return (
763 <Section
764 id="billing-link"
765 title="Stripe billing page"
766 description="Where the customer updates their card and sees invoices. g1t never takes card numbers."
767 >
768 {error && (
769 <div className="mb-4">
770 <Notice tone="error">{error.error}</Notice>
771 </div>
772 )}
773 {link ? (
774 <div className="space-y-4">
775 <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice>
776 <Field label="One-time link" hint={link.expiresNote}>
777 <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" />
778 </Field>
779 {link.loginUrl && (
780 <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}>
781 <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" />
782 </Field>
783 )}
784 {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>}
785 </div>
786 ) : (
787 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
788 <input type="hidden" name="intent" value="billing-link" />
789 <p className="text-sm text-muted">
790 Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner.
791 </p>
792 <Button type="submit" variant="quiet" className="shrink-0">
793 <CreditCard size={14} />
794 Make a link
795 </Button>
796 </form>
797 )}
798 </Section>
799 );
800}
801
802// --- Ledger and audit ---------------------------------------------------------
803
804const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
805
806export function LedgerSection({
807 ledger,
808 showWorkspace = false,
809 description = "Recent lines of the statement, newest first.",
810}: {
811 ledger: LedgerEntry[];
812 showWorkspace?: boolean;
813 description?: ReactNode;
814}) {
815 return (
816 <Section title="Ledger" description={description}>
817 {ledger.length === 0 ? (
818 <EmptyState title="Nothing yet" />
819 ) : (
820 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
821 <table className="w-full min-w-[36rem] text-sm">
822 <thead>
823 <tr className="border-b border-line text-left text-xs text-muted">
824 <th className="px-4 py-2 font-medium sm:pl-5">When</th>
825 <th className="px-4 py-2 font-medium">What</th>
826 <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
827 </tr>
828 </thead>
829 <tbody>
830 {ledger.map((entry) => (
831 <tr key={entry.id} className="border-b border-line align-top last:border-0">
832 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
833 <When at={entry.createdAt} time />
834 </td>
835 <td className="px-4 py-2.5">
836 <div className="flex flex-wrap items-center gap-1.5">
837 <Badge tone={entry.creditKind ? "lavender" : entry.amountMicros > 0 ? "mint" : "plain"}>
838 {entry.creditKind ? `Credit · ${kindLabel(entry.creditKind)}` : (ENTRY_KIND[entry.kind] ?? entry.kind)}
839 </Badge>
840 {showWorkspace && entry.workspace && (
841 <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-accent hover:underline">
842 {entry.workspace}
843 </Link>
844 )}
845 <span className="break-words">{entry.description}</span>
846 {givenParts(entry).map((part) => (
847 <Badge key={part.label} tone="lavender">
848 Given · {part.label} {usd(part.micros)}
849 </Badge>
850 ))}
851 </div>
852 <p className="mt-0.5 font-mono text-xs text-faint">
853 {[
854 entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
855 entry.task,
856 entry.model,
857 entry.billedTo === "workspace" ? "own provider" : null,
858 entry.createdBy && `by ${entry.createdBy}`,
859 ]
860 .filter(Boolean)
861 .join(" · ")}
862 </p>
863 </td>
864 <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-success" : "text-fg-soft"}`}>
865 {usd(entry.amountMicros, { signed: true })}
866 </td>
867 </tr>
868 ))}
869 </tbody>
870 </table>
871 </div>
872 )}
873 </Section>
874 );
875}
876
877
878
879export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
880 return (
881 <Section title="Audit log" description={description}>
882 {audit.length === 0 ? (
883 <p className="flex items-center gap-2 text-sm text-muted">
884 <ScrollText size={14} />
885 No changes yet.
886 </p>
887 ) : (
888 <ol className="space-y-3">
889 {audit.map((entry) => (
890 <li key={entry.id} className="border-l-2 border-accent/40 pl-3">
891 <p className="flex flex-wrap items-center gap-x-2 text-sm">
892 <span className="font-medium text-accent">{actionLabel(entry.action)}</span>
893 <span className="text-xs text-faint">
894 <When at={entry.createdAt} time />
895 </span>
896 </p>
897 {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
898 <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
899 <UserRound size={11} />
900 {entry.by}
901 </p>
902 </li>
903 ))}
904 </ol>
905 )}
906 </Section>
907 );
908}