Skip to content

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

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