Skip to content

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

929 lines42,112 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1/**
2 * The billing sections shared by a workspace's page and an enterprise's:
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look3 * 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.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace5 */
sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's6import { CreditCard, Gift, Landmark, RotateCcw, ScrollText, UserRound } from "lucide-react";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace7import type { ReactNode } from "react";
8import { Link } from "react-router";
9
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging10import type { AdminAction, AdminOwner, Allowances, BillingLink, CreditGrant, Credits, LedgerEntry, Terms } from "@g1t/contracts";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace11
12import { Avatar, Badge, Button, EmptyState, Field, Input, Notice, Section, Select, Textarea, When } from "~/components/ui";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging13import { CREDIT_KINDS, CREDIT_PRESETS, EXPIRIES, kindLabel } from "~/lib/credits";
14import { DISCOUNT_PRESETS, fullDiscount, percentOff, termsLabel } from "~/lib/terms";
sudo: Invoices, Audit log, and follow-ups due15import { actionLabel } from "~/lib/ledgers";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace16import { dollarsField, usd } from "~/lib/money";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17import { givenParts } from "~/lib/pricing";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace18import 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 [
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging72 ["Terms", termsLabel(terms)],
73 ["Discount", `${percentOff(terms)}%`],
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays74 [
75 "Limit",
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging76 fullDiscount(terms)
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays77 ? 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 ],
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace84 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
85 ["Note", terms.note || "—"],
86 ];
87}
88
Stripe webhooks, enterprise invoices, and sudo for both89/** 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
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace92export 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";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging101 danger = fullDiscount(review.after);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace102 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>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging124 {fullDiscount(review.after) && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace125 <p className="mt-3 text-sm text-warn">
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging126 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.
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace128 </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 );
Stripe webhooks, enterprise invoices, and sudo for both149 } 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 );
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace176 } 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-merged/40 bg-merged/5"}`}>
189 <h2 className="font-semibold tracking-tight">{title}</h2>
190 <div className="mt-3">{body}</div>
Stripe webhooks, enterprise invoices, and sudo for both191 <form method="post" action={`${pathname}#${RESULT_ANCHOR[review.intent] ?? "top"}`} className="mt-4 flex flex-wrap items-center gap-2">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace192 <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
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging208/**
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 */
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace214export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
215 const values = error?.values;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging216 const current = percentOff(terms);
217 const preset = values?.preset ?? ((DISCOUNT_PRESETS as readonly number[]).includes(current) ? String(current) : "custom");
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace218 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 >
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging233 <form method="post" action={`${pathname}#review`} className="terms-form space-y-4">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace234 <input type="hidden" name="intent" value="terms" />
235 {error && <Notice tone="error">{error.error}</Notice>}
236 <fieldset>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging237 <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>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace243 ))}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging244 <Segment name="preset" value="custom" checked={preset === "custom"}>
245 Custom
246 </Segment>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace247 </div>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging248 <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>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace249 </fieldset>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging250 <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) : "")} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace253 </Field>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging254 </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).">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace257 <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>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging263 <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" />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace265 </Field>
266 <div className="flex justify-end">
267 <Button type="submit">Review terms</Button>
268 </div>
269 </form>
270 </Section>
271 );
272}
273
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274// --- Plan, pools and caps -------------------------------------------------------
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put275
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276/** 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[] = [];
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging279 if (comped) lines.push("The g1t plan, without its price (100% discount)");
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look280 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.maxConcurrentAgents != null) {
285 lines.push(`${allowances.maxConcurrentAgents} agent${allowances.maxConcurrentAgents === 1 ? "" : "s"} at once`);
286 }
287 if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`);
288 if (allowances.issueCapMicros != null) lines.push(`Issue cap: ${usd(allowances.issueCapMicros)} an issue`);
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo289 if (allowances.auditRetentionDays != null) lines.push(`Audit log: ${allowances.auditRetentionDays} days, in place of the plan's`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look290 if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
291 return lines;
292}
293
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put294/**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295 * The g1t plan without its price, the account's share of g1t's pools, and
296 * staff's overrides of what owners set: agents at once, the run and issue
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo297 * caps, the days of audit log kept (such as for an organization that pays
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging298 * for longer), and a hold on new compute. Accounts on a 100% discount have the plan
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo299 * anyway.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put300 */
301export function AllowancesForm({
302 allowances,
303 comped,
304 pathname,
305 error,
306}: {
307 allowances: Allowances | undefined;
308 comped: boolean;
309 pathname: string;
310 error: SectionError;
311}) {
312 const values = error?.values;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look313 const current: Allowances = allowances ?? { plan: false, ossRepoMicros: null, trialMicros: null };
314 const lines = allowanceLines(allowances, comped);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put315 return (
316 <Section
317 id="allowances"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look318 title="Plan, pools and caps"
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put319 description={
320 comped
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging321 ? "A 100% discount: the g1t plan is on without its price whatever is set here. The pools and caps still apply."
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look322 : "The g1t plan without its price, this account's share of g1t's pools, and overrides of what owners set."
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put323 }
324 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look325 <div className="mb-4 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm">
326 <p className="text-xs text-faint">Set now</p>
327 {lines.length === 0 ? (
328 <p className="mt-0.5 text-muted">Nothing: the defaults.</p>
329 ) : (
330 <ul className="mt-1 space-y-0.5">
331 {lines.map((line) => (
332 <li key={line} className={line.startsWith("Held:") ? "text-danger" : "text-fg-soft"}>
333 {line}
334 </li>
335 ))}
336 </ul>
337 )}
338 </div>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put339 <form method="post" action={`${pathname}#allowances`} className="space-y-4">
340 <input type="hidden" name="intent" value="allowances" />
341 {error && <Notice tone="error">{error.error}</Notice>}
342 <label className="flex cursor-pointer items-start gap-2.5 text-sm">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look343 <input type="checkbox" name="plan" defaultChecked={values ? values.plan === "on" : current.plan} className="mt-0.5" />
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put344 <span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look345 <span className="block font-medium">The g1t plan, without its price</span>
346 <span className="block text-xs text-muted">
347 Its included usage, storage and caps, with no $20 a month. Usage past it is charged as usual.
348 </span>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put349 </span>
350 </label>
351 <div className="grid gap-4 sm:grid-cols-2">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352 <Field label="Open-source share $" hint="Each public repository, a month. Blank: the default ($2).">
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put353 <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
354 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look355 <Field label="Trial $" hint="Each workspace, outside the monthly pool. Blank: the default ($5).">
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put356 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
357 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look358 <Field label="Agents at once" hint="Blank: the plan's (2 in the first month or on the trial, then 10).">
359 <Input
360 name="agents"
361 inputMode="numeric"
362 pattern="\d{1,3}"
363 placeholder="Default"
364 defaultValue={values?.agents ?? (current.maxConcurrentAgents != null ? String(current.maxConcurrentAgents) : "")}
365 />
366 </Field>
367 <Field label="Run cap $" hint="One run's spend, over the owners'. Blank: theirs, or $2.">
368 <Input name="runCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.runCap ?? dollarsField(current.runCapMicros)} />
369 </Field>
370 <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10.">
371 <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} />
372 </Field>
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo373 <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).">
374 <Input
375 name="auditDays"
376 inputMode="numeric"
377 pattern="\d{1,3}"
378 placeholder="Plan's"
379 defaultValue={values?.auditDays ?? (current.auditRetentionDays != null ? String(current.auditRetentionDays) : "")}
380 />
381 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look382 <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold.">
383 <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} />
384 </Field>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put385 </div>
386 <Field label="Note" hint="Required. Why, for whoever looks next.">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look387 <Textarea
388 name="note"
389 rows={2}
390 required
391 maxLength={500}
392 defaultValue={values?.note ?? ""}
393 placeholder="e.g. Open-source foundation, larger share through 2027"
394 />
395 </Field>
396 <div className="flex justify-end">
397 <Button type="submit">Save plan, pools and caps</Button>
398 </div>
399 </form>
400 </Section>
401 );
402}
403
404// --- Bank transfer ---------------------------------------------------------------
405
406/**
407 * Money that reached g1t outside Stripe's page, such as a bank transfer
408 * for a $1,000+ prepayment, entered as a payment.
409 */
410export function PaymentForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
411 const values = error?.values;
412 return (
413 <Section
414 id="payment"
415 title="Record a bank transfer"
416 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."
417 >
418 <form method="post" action={`${pathname}#payment`} className="space-y-4">
419 <input type="hidden" name="intent" value="payment" />
420 {error && <Notice tone="error">{error.error}</Notice>}
421 <div className="grid gap-4 sm:grid-cols-2">
422 <Field label="Amount $" hint="As it arrived, up to $100,000.">
423 <Input name="amount" inputMode="decimal" required placeholder="1,500.00" defaultValue={values?.amount ?? ""} />
424 </Field>
425 <Field label="Reference" hint="As the bank shows it. Each is recorded once.">
426 <Input name="reference" required maxLength={100} className="font-mono" defaultValue={values?.reference ?? ""} />
427 </Field>
428 </div>
429 <Field label="Note" hint="Required. Shown on the statement after “Paid by bank transfer”.">
430 <Textarea
431 name="note"
432 rows={2}
433 required
434 maxLength={500}
435 placeholder="e.g. Prepayment for October, invoice INV-0042"
436 defaultValue={values?.note ?? ""}
437 />
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put438 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look439 <Field
440 label="Confirm"
441 hint={
442 <>
443 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to record it.
444 </>
445 }
446 >
447 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
448 </Field>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put449 <div className="flex justify-end">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look450 <Button type="submit" variant="lavender">
451 <Landmark size={14} />
452 Record the payment
453 </Button>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put454 </div>
455 </form>
456 </Section>
457 );
458}
459
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace460// --- Credit -------------------------------------------------------------------
461
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging462/** A radio drawn as one button of a segmented row; still a plain radio without CSS. */
463function Segment({ name, value, checked, children }: { name: string; value: string; checked: boolean; children: ReactNode }) {
464 return (
465 <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-merged has-checked:font-medium has-checked:text-bg has-focus-visible:outline-2 has-focus-visible:-outline-offset-2 has-focus-visible:outline-merged">
466 <input type="radio" name={name} value={value} defaultChecked={checked} className="sr-only" />
467 {children}
468 </label>
469 );
470}
471
472/**
473 * Credit for a workspace: a preset amount or a custom one, a kind, an
474 * expiry, and a note. Fields that belong to one choice (the custom amount,
475 * a refund's details, an expiry date) show only for it, by CSS alone
476 * (`.credit-form` in app.css); without `:has()` every field shows and the
477 * ones that do not apply are ignored. Over $100 the slug is typed out too.
478 */
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace479export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
480 const values = error?.values;
481 const single = workspaces.length === 1 ? workspaces[0] : null;
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging482 const preset = values?.preset ?? "25";
483 const kind = values?.kind ?? "promotional";
484 const expires = values?.expires ?? "none";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace485 return (
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging486 <Section
487 id="credit"
488 title="Give credit"
489 description="Added to the balance at once and spent before anything paid in advance. The owners are emailed."
490 >
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace491 {workspaces.length === 0 ? (
492 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
493 ) : (
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging494 <form method="post" action={`${pathname}#credit`} className="credit-form space-y-4">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace495 <input type="hidden" name="intent" value="credit" />
496 {error && <Notice tone="error">{error.error}</Notice>}
497 {single ? (
498 <input type="hidden" name="workspace" value={single} />
499 ) : (
500 <Field label="Workspace">
501 <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
502 <option value="" disabled>
503 Choose a workspace
504 </option>
505 {workspaces.map((slug) => (
506 <option key={slug} value={slug}>
507 {slug}
508 </option>
509 ))}
510 </Select>
511 </Field>
512 )}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging513 <fieldset>
514 <legend className="mb-1.5 text-sm font-medium text-muted">Amount</legend>
515 <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg">
516 {CREDIT_PRESETS.map((dollars) => (
517 <Segment key={dollars} name="preset" value={String(dollars)} checked={preset === String(dollars)}>
518 ${dollars}
519 </Segment>
520 ))}
521 <Segment name="preset" value="custom" checked={preset === "custom"}>
522 Custom
523 </Segment>
524 </div>
525 </fieldset>
526 <div className="when-custom space-y-4">
527 <Field label="Custom amount $" hint="Up to $10,000 at a time.">
528 <Input name="amount" inputMode="decimal" placeholder="15.00" defaultValue={values?.amount ?? ""} />
529 </Field>
530 </div>
531 <fieldset>
532 <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
533 <div className="grid gap-2">
534 {CREDIT_KINDS.map((option) => (
535 <label
536 key={option.value}
537 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-merged/60 has-checked:bg-merged/8"
538 >
539 <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
540 <span>
541 <span className="block text-sm font-medium">{option.title}</span>
542 <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
543 </span>
544 </label>
545 ))}
546 </div>
547 </fieldset>
548 <div className="when-refund">
549 <div className="grid gap-4 sm:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
550 <Field label="Refund for" hint="Refunds only. On the statement and in the email.">
551 <Input name="refundFor" maxLength={200} placeholder="the failed runs on Oct 2" defaultValue={values?.refundFor ?? ""} />
552 </Field>
553 <Field label="Day refunded" hint="Comes off what was paid that day. Blank: today.">
554 <Input type="date" name="refundDay" defaultValue={values?.refundDay ?? ""} />
555 </Field>
556 </div>
557 </div>
558 <fieldset className="when-not-refund">
559 <legend className="mb-1.5 text-sm font-medium text-muted">Expires</legend>
560 <div className="flex flex-wrap overflow-hidden rounded-md border border-line bg-bg">
561 {EXPIRIES.map((option) => (
562 <Segment key={option.value} name="expires" value={option.value} checked={expires === option.value}>
563 {option.label}
564 </Segment>
565 ))}
566 </div>
567 <p className="mt-1.5 text-xs text-faint">Unused credit stops counting then. A refund never expires.</p>
568 </fieldset>
569 <div className="when-date when-not-refund">
570 <Field label="Expires on" hint="At the end of the day, UTC.">
571 <Input type="date" name="expiresOn" defaultValue={values?.expiresOn ?? ""} />
572 </Field>
573 </div>
574 <Field label="Note" hint="Required. On the workspace's statement and in the owners' email.">
575 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Welcome to g1t" defaultValue={values?.note ?? ""} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace576 </Field>
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging577 <div className="when-custom">
578 <Field
579 label="Confirm"
580 hint={
581 <>
582 Over $100 only: type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>}.
583 </>
584 }
585 >
586 <Input name="confirmation" placeholder={single ?? "workspace-slug"} className="font-mono" />
587 </Field>
588 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace589 <div className="flex justify-end">
590 <Button type="submit" variant="lavender">
591 <Gift size={14} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging592 Give credit
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace593 </Button>
594 </div>
595 </form>
596 )}
597 </Section>
598 );
599}
600
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging601const GRANT_STATE: Record<CreditGrant["state"], { label: string; tone: "mint" | "plain" | "warn" | "danger" }> = {
602 open: { label: "Open", tone: "mint" },
603 used: { label: "Used", tone: "plain" },
604 expired: { label: "Expired", tone: "warn" },
605 revoked: { label: "Revoked", tone: "danger" },
606};
607
608/**
609 * Credits g1t gave, newest first: amount, kind, note, who, when, used,
610 * left and expiry, and on an open one a form to take back what is left.
611 */
612export function CreditList({
613 grants,
614 pathname,
615 error,
616 showWorkspace = false,
617}: {
618 grants: CreditGrant[];
619 pathname: string;
620 error?: SectionError;
621 showWorkspace?: boolean;
622}) {
623 if (grants.length === 0) return <EmptyState title="No credits given">Credit given from sudo shows here, with what is left of it.</EmptyState>;
624 return (
625 <ul className="-my-1 divide-y divide-line">
626 {grants.map((grant) => {
627 const state = GRANT_STATE[grant.state];
628 const failed = error && error.values?.id === grant.id ? error.error : null;
629 return (
630 <li key={grant.id} id={`grant-${grant.id}`} className="scroll-mt-20 py-3">
631 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
632 <span className="tabular font-semibold">{usd(grant.amountMicros, { cents: true })}</span>
633 <Badge tone="lavender">{kindLabel(grant.kind)}</Badge>
634 <Badge tone={state.tone}>{state.label}</Badge>
635 {showWorkspace && (
636 <Link to={`/workspaces/${encodeURIComponent(grant.workspace)}#credits`} className="font-mono text-xs text-merged hover:underline">
637 {grant.workspace}
638 </Link>
639 )}
640 </div>
641 <p className="tabular mt-1 text-sm text-fg-soft">
642 Used {usd(grant.usedMicros, { cents: true })} · left {usd(grant.leftMicros, { cents: true })}
643 {grant.expiresAt ? (
644 <>
645 {" "}
646 · {grant.state === "expired" ? "expired" : "expires"} <When at={grant.expiresAt} />
647 </>
648 ) : grant.kind === "refund" ? (
649 " · never expires"
650 ) : (
651 " · no expiry"
652 )}
653 </p>
654 <p className="mt-0.5 text-sm break-words">
655 {grant.kind === "refund" && grant.refundFor && <span className="text-muted">For {grant.refundFor}{grant.refundDay ? ` (${grant.refundDay})` : ""}: </span>}
656 {grant.note}
657 </p>
658 <p className="mt-0.5 flex flex-wrap items-center gap-x-1 font-mono text-xs text-faint">
659 <UserRound size={11} />
660 {grant.createdBy} · <When at={grant.createdAt} time />
661 </p>
662 {grant.closedAt && grant.state === "revoked" && (
663 <p className="mt-1 text-xs text-muted">
664 {usd(grant.closedMicros ?? 0, { cents: true })} taken back by {grant.closedBy} on <When at={grant.closedAt} />
665 {grant.closedNote ? `: “${grant.closedNote}”` : ""}
666 </p>
667 )}
668 {grant.closedAt && grant.state === "expired" && (grant.closedMicros ?? 0) > 0 && (
669 <p className="mt-1 text-xs text-muted">{usd(grant.closedMicros ?? 0, { cents: true })} unused when it expired.</p>
670 )}
671 {grant.state === "open" && (
672 <details className="mt-2" open={failed != null}>
673 <summary className="cursor-pointer text-xs text-danger hover:underline">Revoke unused</summary>
674 <form method="post" action={`${pathname}#grant-${grant.id}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-end">
675 <input type="hidden" name="intent" value="revoke-credit" />
676 <input type="hidden" name="id" value={grant.id} />
677 {showWorkspace && <input type="hidden" name="workspace" value={grant.workspace} />}
678 <Field label="Why" hint={`Takes ${usd(grant.leftMicros, { cents: true })} off the balance. Kept in the audit log.`} className="flex-1">
679 <Input name="reason" required maxLength={500} placeholder="e.g. Given to the wrong workspace" defaultValue={failed ? (error?.values?.reason ?? "") : ""} />
680 </Field>
681 <Button type="submit" variant="danger" className="shrink-0">
682 Revoke {usd(grant.leftMicros, { cents: true })}
683 </Button>
684 </form>
685 {failed && (
686 <div className="mt-2">
687 <Notice tone="error">{failed}</Notice>
688 </div>
689 )}
690 </details>
691 )}
692 </li>
693 );
694 })}
695 </ul>
696 );
697}
698
699/** A workspace's credits from g1t: what is left to spend, and each grant. */
700export function CreditsSection({
701 credits,
702 unavailable,
703 pathname,
704 error,
705}: {
706 credits: Credits | null;
707 unavailable: string | null;
708 pathname: string;
709 error: SectionError;
710}) {
711 return (
712 <Section
713 id="credits"
714 title="Credits"
715 description={
716 credits
717 ? `${usd(credits.leftMicros, { cents: true })} left to spend. Spent before anything paid in advance, the soonest-expiring first.`
718 : "Credits from g1t, with what is left of each."
719 }
720 >
721 {unavailable ? <Notice tone="warn">Billing did not answer for credits: {unavailable}</Notice> : <CreditList grants={credits?.grants ?? []} pathname={pathname} error={error} />}
722 </Section>
723 );
724}
725
sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's726// --- Reset (testing) -----------------------------------------------------------
727
728/**
729 * Wipes a test workspace's billing so it starts again as a new customer.
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging730 * Only while billing runs on Stripe's test key; billing refuses 100% discounts and
sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's731 * enterprise workspaces. The workspace, its members and repositories stay.
732 */
733export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
734 const values = error?.values;
735 return (
736 <Section
737 id="reset"
738 title="Reset billing (testing)"
739 description="Wipes this workspace's billing: its ledger and balance, plan, limits, trial, invoices, holds, signals and cost rows. The workspace, its members and its repositories stay. Only while billing is on Stripe's test key."
740 >
741 <form method="post" action={`${pathname}#reset`} className="space-y-4">
742 <input type="hidden" name="intent" value="reset" />
743 {error && <Notice tone="error">{error.error}</Notice>}
744 <Field label="Why" hint="Required. Kept in the audit log.">
745 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Test workspace, starting the customer walk-through again" defaultValue={values?.note ?? ""} />
746 </Field>
747 <Field
748 label="Confirm"
749 hint={
750 <>
751 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to wipe its billing. It cannot be undone.
752 </>
753 }
754 >
755 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
756 </Field>
757 <div className="flex justify-end">
758 <Button type="submit" variant="danger">
759 <RotateCcw size={14} />
760 Reset billing
761 </Button>
762 </div>
763 </form>
764 </Section>
765 );
766}
767
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace768// --- Stripe billing link -----------------------------------------------------
769
770/**
771 * Card details, invoices and receipts live on Stripe's own billing page.
772 * sudo never shows a card field: staff send the customer a link instead.
773 */
774export function BillingLinkSection({
775 link,
776 pathname,
777 error,
778}: {
779 link: BillingLink | null;
780 pathname: string;
781 error: SectionError;
782}) {
783 return (
784 <Section
785 id="billing-link"
786 title="Stripe billing page"
787 description="Where the customer updates their card and sees invoices. g1t never takes card numbers."
788 >
789 {error && (
790 <div className="mb-4">
791 <Notice tone="error">{error.error}</Notice>
792 </div>
793 )}
794 {link ? (
795 <div className="space-y-4">
796 <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice>
797 <Field label="One-time link" hint={link.expiresNote}>
798 <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" />
799 </Field>
800 {link.loginUrl && (
801 <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}>
802 <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" />
803 </Field>
804 )}
805 {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>}
806 </div>
807 ) : (
808 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
809 <input type="hidden" name="intent" value="billing-link" />
810 <p className="text-sm text-muted">
811 Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner.
812 </p>
813 <Button type="submit" variant="quiet" className="shrink-0">
814 <CreditCard size={14} />
815 Make a link
816 </Button>
817 </form>
818 )}
819 </Section>
820 );
821}
822
823// --- Ledger and audit ---------------------------------------------------------
824
825const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
826
827export function LedgerSection({
828 ledger,
829 showWorkspace = false,
830 description = "Recent lines of the statement, newest first.",
831}: {
832 ledger: LedgerEntry[];
833 showWorkspace?: boolean;
834 description?: ReactNode;
835}) {
836 return (
837 <Section title="Ledger" description={description}>
838 {ledger.length === 0 ? (
839 <EmptyState title="Nothing yet" />
840 ) : (
841 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
842 <table className="w-full min-w-[36rem] text-sm">
843 <thead>
844 <tr className="border-b border-line text-left text-xs text-muted">
845 <th className="px-4 py-2 font-medium sm:pl-5">When</th>
846 <th className="px-4 py-2 font-medium">What</th>
847 <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
848 </tr>
849 </thead>
850 <tbody>
851 {ledger.map((entry) => (
852 <tr key={entry.id} className="border-b border-line align-top last:border-0">
853 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
854 <When at={entry.createdAt} time />
855 </td>
856 <td className="px-4 py-2.5">
857 <div className="flex flex-wrap items-center gap-1.5">
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging858 <Badge tone={entry.creditKind ? "lavender" : entry.amountMicros > 0 ? "mint" : "plain"}>
859 {entry.creditKind ? `Credit · ${kindLabel(entry.creditKind)}` : (ENTRY_KIND[entry.kind] ?? entry.kind)}
860 </Badge>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace861 {showWorkspace && entry.workspace && (
862 <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline">
863 {entry.workspace}
864 </Link>
865 )}
866 <span className="break-words">{entry.description}</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look867 {givenParts(entry).map((part) => (
868 <Badge key={part.label} tone="lavender">
869 Given · {part.label} {usd(part.micros)}
870 </Badge>
871 ))}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace872 </div>
873 <p className="mt-0.5 font-mono text-xs text-faint">
874 {[
875 entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
876 entry.task,
877 entry.model,
878 entry.billedTo === "workspace" ? "own provider" : null,
879 entry.createdBy && `by ${entry.createdBy}`,
880 ]
881 .filter(Boolean)
882 .join(" · ")}
883 </p>
884 </td>
885 <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
886 {usd(entry.amountMicros, { signed: true })}
887 </td>
888 </tr>
889 ))}
890 </tbody>
891 </table>
892 </div>
893 )}
894 </Section>
895 );
896}
897
898
sudo: Invoices, Audit log, and follow-ups due899
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace900export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
901 return (
902 <Section title="Audit log" description={description}>
903 {audit.length === 0 ? (
904 <p className="flex items-center gap-2 text-sm text-muted">
905 <ScrollText size={14} />
906 No changes yet.
907 </p>
908 ) : (
909 <ol className="space-y-3">
910 {audit.map((entry) => (
911 <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
912 <p className="flex flex-wrap items-center gap-x-2 text-sm">
sudo: Invoices, Audit log, and follow-ups due913 <span className="font-medium text-merged">{actionLabel(entry.action)}</span>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace914 <span className="text-xs text-faint">
915 <When at={entry.createdAt} time />
916 </span>
917 </p>
918 {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
919 <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
920 <UserRound size={11} />
921 {entry.by}
922 </p>
923 </li>
924 ))}
925 </ol>
926 )}
927 </Section>
928 );
929}