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

716 lines31,690 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
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put10import type { AdminAction, AdminOwner, Allowances, BillingLink, 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";
sudo: Invoices, Audit log, and follow-ups due13import { actionLabel } from "~/lib/ledgers";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace14import { dollarsField, usd } from "~/lib/money";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look15import { givenParts } from "~/lib/pricing";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace16import type { Review, SectionError } from "~/lib/review";
17
18export function Hidden({ values }: { values: Record<string, string> }) {
19 return (
20 <>
21 {Object.entries(values).map(([name, value]) => (
22 <input key={name} type="hidden" name={name} value={value} />
23 ))}
24 </>
25 );
26}
27
28export function Figure({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) {
29 return (
30 <div className="rounded-lg border border-line bg-surface px-4 py-3">
31 <p className="text-xs text-muted">{label}</p>
32 <p className="tabular mt-1 text-lg font-semibold tracking-tight">{value}</p>
33 {hint && <p className="mt-0.5 text-xs text-faint">{hint}</p>}
34 </div>
35 );
36}
37
38/** Owners' usernames, each with their email beneath (and on hover). */
39export function Owners({ owners, compact = false }: { owners: AdminOwner[]; compact?: boolean }) {
40 if (owners.length === 0) return <span className="text-faint">No owner</span>;
41 if (compact) {
42 return (
43 <span className="flex flex-col gap-0.5">
44 {owners.map((owner) => (
45 <span key={owner.username} title={owner.email ?? "No email"} className="min-w-0 truncate">
46 <span className="font-mono text-fg-soft">{owner.username}</span>
47 {owner.email && <span className="block truncate text-xs text-faint">{owner.email}</span>}
48 </span>
49 ))}
50 </span>
51 );
52 }
53 return (
54 <ul className="flex flex-wrap gap-2">
55 {owners.map((owner) => (
56 <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">
57 <Avatar name={owner.username} size={14} square={false} />
58 <span className="font-mono">{owner.username}</span>
59 {owner.email && <span className="text-faint">{owner.email}</span>}
60 </li>
61 ))}
62 </ul>
63 );
64}
65
66// --- Confirmation ------------------------------------------------------------
67
68function describeTerms(terms: Terms): [string, string][] {
69 return [
70 ["Terms", terms.kind === "custom" ? "Custom" : terms.kind === "comped" ? "Comped" : "Standard"],
71 ["Discount", terms.kind === "custom" ? `${terms.discountPercent}%` : "—"],
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays72 [
73 "Limit",
74 terms.kind === "comped"
75 ? terms.ceilingMicros == null
76 ? "The default monthly budget, at cost"
77 : `${usd(terms.ceilingMicros)} a month, at cost`
78 : terms.ceilingMicros == null
79 ? "By trust"
80 : usd(terms.ceilingMicros),
81 ],
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace82 ["Until", terms.until ? terms.until.slice(0, 10) : "No end"],
83 ["Note", terms.note || "—"],
84 ];
85}
86
Stripe webhooks, enterprise invoices, and sudo for both87/** Where a confirmed change lands, when its result is shown in place. */
88const RESULT_ANCHOR: Partial<Record<Review["intent"], string>> = { "billing-link": "billing-link", invoice: "invoices" };
89
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace90export function ReviewPanel({ review, pathname }: { review: Review; pathname: string }) {
91 let title: string;
92 let body: ReactNode;
93 let danger = false;
94 let confirm = "Confirm";
95 if (review.intent === "terms") {
96 const before = describeTerms(review.before);
97 const after = describeTerms(review.after);
98 title = "Confirm the new terms";
99 danger = review.after.kind === "comped";
100 body = (
101 <>
102 <div className="overflow-x-auto">
103 <table className="w-full text-sm">
104 <thead>
105 <tr className="text-left text-xs text-muted">
106 <th className="py-1.5 pr-4 font-medium" />
107 <th className="py-1.5 pr-4 font-medium">Now</th>
108 <th className="py-1.5 font-medium">After</th>
109 </tr>
110 </thead>
111 <tbody>
112 {after.map(([label, value], index) => (
113 <tr key={label} className="border-t border-line align-top">
114 <td className="py-1.5 pr-4 text-muted">{label}</td>
115 <td className="py-1.5 pr-4 break-words text-faint">{before[index][1]}</td>
116 <td className={`py-1.5 break-words ${value !== before[index][1] ? "font-medium text-fg" : "text-muted"}`}>{value}</td>
117 </tr>
118 ))}
119 </tbody>
120 </table>
121 </div>
122 {review.after.kind === "comped" && (
123 <p className="mt-3 text-sm text-warn">
124 Comped: nothing will be charged{review.after.until ? ` until ${review.after.until.slice(0, 10)}` : ""}. Usage is still recorded
125 at cost.
126 </p>
127 )}
128 </>
129 );
130 } else if (review.intent === "attach") {
131 title = `Bill ${review.workspace} to ${review.targetName}?`;
132 body = (
133 <p className="text-sm text-muted">
134 From now on <span className="font-mono text-fg">{review.workspace}</span>'s usage is billed to{" "}
135 <span className="text-fg">{review.targetName}</span> and counts against its limit and terms, not its own.
136 </p>
137 );
138 } else if (review.intent === "detach") {
139 title = `Move ${review.workspace} off ${review.from}?`;
140 danger = true;
141 body = (
142 <p className="text-sm text-muted">
143 <span className="font-mono text-fg">{review.workspace}</span> goes back to paying for itself, on its own terms and the limit
144 its trust gives it. It may stop at once if its own limit is lower than what it owes.
145 </p>
146 );
Stripe webhooks, enterprise invoices, and sudo for both147 } else if (review.intent === "billing-email") {
148 title = `Send ${review.name}'s invoices to ${review.after}?`;
149 body = (
150 <p className="text-sm text-muted">
151 Stripe emails its invoices to <span className="font-mono text-fg">{review.after}</span>
152 {review.before ? (
153 <>
154 {" "}
155 instead of <span className="font-mono">{review.before}</span>
156 </>
157 ) : null}
158 , from the next invoice on.{!review.before && " This also sets the enterprise up as a customer on Stripe."}
159 </p>
160 );
161 } else if (review.intent === "invoice") {
162 title = `Invoice ${review.name} now?`;
163 confirm = "Send the invoice";
164 body = (
165 <ul className="list-disc space-y-1 pl-5 text-sm text-muted">
166 <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>
167 <li>Net 30: due in 30 days.</li>
168 <li>
169 Stripe emails it to <span className="font-mono text-fg">{review.email ?? "the invoice email (none set yet)"}</span>, with a link to
170 pay on Stripe's page.
171 </li>
172 </ul>
173 );
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace174 } else {
175 title = `Make a Stripe billing link for ${review.workspace}?`;
176 confirm = "Make the link";
177 body = (
178 <p className="text-sm text-muted">
179 Opens a one-time session on Stripe's billing page for <span className="font-mono text-fg">{review.workspace}</span>'s
180 customer, where they update their card and see their invoices. Anyone with the link can use it until it expires, so send it
181 only to the workspace's owner. Making it is recorded with your email.
182 </p>
183 );
184 }
185 return (
186 <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"}`}>
187 <h2 className="font-semibold tracking-tight">{title}</h2>
188 <div className="mt-3">{body}</div>
Stripe webhooks, enterprise invoices, and sudo for both189 <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 workspace190 <Hidden values={review.fields} />
191 <input type="hidden" name="intent" value={review.intent} />
192 <input type="hidden" name="confirm" value="yes" />
193 <Button type="submit" variant={danger ? "danger" : "lavender"}>
194 {confirm}
195 </Button>
196 <Link to={pathname} className="px-2 text-sm text-muted hover:text-fg">
197 Cancel
198 </Link>
199 </form>
200 </section>
201 );
202}
203
204// --- Terms -------------------------------------------------------------------
205
206const KINDS: { value: Terms["kind"]; title: string; text: string }[] = [
207 { value: "standard", title: "Standard", text: "Published prices; the limit comes from trust." },
208 { value: "comped", title: "Comped", text: "Nothing charged. Usage still recorded at cost." },
209 { value: "custom", title: "Custom", text: "A discount, a custom limit, or both." },
210];
211
212export function TermsForm({ terms, pathname, error }: { terms: Terms; pathname: string; error: SectionError }) {
213 const values = error?.values;
214 const kind = values?.kind ?? terms.kind;
215 return (
216 <Section
217 id="terms"
218 title="Terms"
219 description={
220 terms.setBy ? (
221 <>
222 Set by <span className="font-mono">{terms.setBy}</span> on <When at={terms.setAt} />
223 {terms.note && <> · “{terms.note}”</>}
224 </>
225 ) : (
226 "Standard, as everyone starts."
227 )
228 }
229 >
230 <form method="post" action={`${pathname}#review`} className="space-y-4">
231 <input type="hidden" name="intent" value="terms" />
232 {error && <Notice tone="error">{error.error}</Notice>}
233 <fieldset>
234 <legend className="mb-1.5 text-sm font-medium text-muted">Kind</legend>
235 <div className="grid gap-2 sm:grid-cols-3">
236 {KINDS.map((option) => (
237 <label
238 key={option.value}
239 className="flex cursor-pointer gap-2.5 rounded-md border border-line bg-bg p-3 transition-colors hover:border-line-strong has-checked:border-merged/60 has-checked:bg-merged/8"
240 >
241 <input type="radio" name="kind" value={option.value} defaultChecked={kind === option.value} className="mt-0.5" required />
242 <span>
243 <span className="block text-sm font-medium">{option.title}</span>
244 <span className="mt-0.5 block text-xs text-muted">{option.text}</span>
245 </span>
246 </label>
247 ))}
248 </div>
249 </fieldset>
250 <div className="grid gap-4 sm:grid-cols-3">
251 <Field label="Discount %" hint="Custom only.">
252 <Input name="discount" inputMode="numeric" pattern="\d{1,3}" placeholder="0" defaultValue={values?.discount ?? (terms.discountPercent ? String(terms.discountPercent) : "")} />
253 </Field>
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays254 <Field label="Limit $" hint="Unpaid usage allowed; blank: trust decides. Comped: the monthly budget at cost; blank: the default ($150).">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace255 <Input name="ceiling" inputMode="decimal" placeholder="By trust" defaultValue={values?.ceiling ?? dollarsField(terms.ceilingMicros)} />
256 </Field>
257 <Field label="Until" hint="Blank: no end. UTC.">
258 <Input type="date" name="until" defaultValue={values?.until ?? (terms.until ? terms.until.slice(0, 10) : "")} />
259 </Field>
260 </div>
261 <Field label="Note" hint="Required. Why, for whoever looks next.">
262 <Textarea name="note" rows={2} required maxLength={500} defaultValue={values?.note ?? ""} placeholder="e.g. Design partner through launch" />
263 </Field>
264 <div className="flex justify-end">
265 <Button type="submit">Review terms</Button>
266 </div>
267 </form>
268 </Section>
269 );
270}
271
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look272// --- Plan, pools and caps -------------------------------------------------------
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put273
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274/** What staff have set beyond the terms, one line each; defaults left out. */
275export function allowanceLines(allowances: Allowances | undefined, comped: boolean): string[] {
276 const lines: string[] = [];
277 if (comped) lines.push("The g1t plan, without its price (comped)");
278 else if (allowances?.plan) lines.push("The g1t plan, without its price");
279 if (!allowances) return lines;
280 if (allowances.ossRepoMicros != null) lines.push(`Open-source pool: ${usd(allowances.ossRepoMicros)} a month for each public repository`);
281 if (allowances.trialMicros != null) lines.push(`Trial: ${usd(allowances.trialMicros)}, outside the monthly pool`);
282 if (allowances.maxConcurrentAgents != null) {
283 lines.push(`${allowances.maxConcurrentAgents} agent${allowances.maxConcurrentAgents === 1 ? "" : "s"} at once`);
284 }
285 if (allowances.runCapMicros != null) lines.push(`Run cap: ${usd(allowances.runCapMicros)} a run`);
286 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 sudo287 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 look288 if (allowances.hold) lines.push(`Held: ${allowances.hold}`);
289 return lines;
290}
291
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put292/**
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look293 * The g1t plan without its price, the account's share of g1t's pools, and
294 * 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 sudo295 * caps, the days of audit log kept (such as for an organization that pays
296 * for longer), and a hold on new compute. Comped accounts have the plan
297 * anyway.
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put298 */
299export function AllowancesForm({
300 allowances,
301 comped,
302 pathname,
303 error,
304}: {
305 allowances: Allowances | undefined;
306 comped: boolean;
307 pathname: string;
308 error: SectionError;
309}) {
310 const values = error?.values;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look311 const current: Allowances = allowances ?? { plan: false, ossRepoMicros: null, trialMicros: null };
312 const lines = allowanceLines(allowances, comped);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put313 return (
314 <Section
315 id="allowances"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look316 title="Plan, pools and caps"
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put317 description={
318 comped
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look319 ? "Comped: the g1t plan is on without its price whatever is set here. The pools and caps still apply."
320 : "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 put321 }
322 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look323 <div className="mb-4 rounded-md border border-line bg-bg px-3.5 py-2.5 text-sm">
324 <p className="text-xs text-faint">Set now</p>
325 {lines.length === 0 ? (
326 <p className="mt-0.5 text-muted">Nothing: the defaults.</p>
327 ) : (
328 <ul className="mt-1 space-y-0.5">
329 {lines.map((line) => (
330 <li key={line} className={line.startsWith("Held:") ? "text-danger" : "text-fg-soft"}>
331 {line}
332 </li>
333 ))}
334 </ul>
335 )}
336 </div>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put337 <form method="post" action={`${pathname}#allowances`} className="space-y-4">
338 <input type="hidden" name="intent" value="allowances" />
339 {error && <Notice tone="error">{error.error}</Notice>}
340 <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 look341 <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 put342 <span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look343 <span className="block font-medium">The g1t plan, without its price</span>
344 <span className="block text-xs text-muted">
345 Its included usage, storage and caps, with no $20 a month. Usage past it is charged as usual.
346 </span>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put347 </span>
348 </label>
349 <div className="grid gap-4 sm:grid-cols-2">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look350 <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 put351 <Input name="oss" inputMode="decimal" placeholder="Default" defaultValue={values?.oss ?? dollarsField(current.ossRepoMicros)} />
352 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 <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 put354 <Input name="trial" inputMode="decimal" placeholder="Default" defaultValue={values?.trial ?? dollarsField(current.trialMicros)} />
355 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look356 <Field label="Agents at once" hint="Blank: the plan's (2 in the first month or on the trial, then 10).">
357 <Input
358 name="agents"
359 inputMode="numeric"
360 pattern="\d{1,3}"
361 placeholder="Default"
362 defaultValue={values?.agents ?? (current.maxConcurrentAgents != null ? String(current.maxConcurrentAgents) : "")}
363 />
364 </Field>
365 <Field label="Run cap $" hint="One run's spend, over the owners'. Blank: theirs, or $2.">
366 <Input name="runCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.runCap ?? dollarsField(current.runCapMicros)} />
367 </Field>
368 <Field label="Issue cap $" hint="One issue's agents in all, over the owners'. Blank: theirs, or $10.">
369 <Input name="issueCap" inputMode="decimal" placeholder="Owners'" defaultValue={values?.issueCap ?? dollarsField(current.issueCapMicros)} />
370 </Field>
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo371 <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).">
372 <Input
373 name="auditDays"
374 inputMode="numeric"
375 pattern="\d{1,3}"
376 placeholder="Plan's"
377 defaultValue={values?.auditDays ?? (current.auditRetentionDays != null ? String(current.auditRetentionDays) : "")}
378 />
379 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look380 <Field label="Hold" hint="Pauses new compute and tells the owners why. Blank: no hold.">
381 <Input name="hold" maxLength={200} placeholder="No hold" defaultValue={values?.hold ?? current.hold ?? ""} />
382 </Field>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put383 </div>
384 <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 look385 <Textarea
386 name="note"
387 rows={2}
388 required
389 maxLength={500}
390 defaultValue={values?.note ?? ""}
391 placeholder="e.g. Open-source foundation, larger share through 2027"
392 />
393 </Field>
394 <div className="flex justify-end">
395 <Button type="submit">Save plan, pools and caps</Button>
396 </div>
397 </form>
398 </Section>
399 );
400}
401
402// --- Bank transfer ---------------------------------------------------------------
403
404/**
405 * Money that reached g1t outside Stripe's page, such as a bank transfer
406 * for a $1,000+ prepayment, entered as a payment.
407 */
408export function PaymentForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
409 const values = error?.values;
410 return (
411 <Section
412 id="payment"
413 title="Record a bank transfer"
414 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."
415 >
416 <form method="post" action={`${pathname}#payment`} className="space-y-4">
417 <input type="hidden" name="intent" value="payment" />
418 {error && <Notice tone="error">{error.error}</Notice>}
419 <div className="grid gap-4 sm:grid-cols-2">
420 <Field label="Amount $" hint="As it arrived, up to $100,000.">
421 <Input name="amount" inputMode="decimal" required placeholder="1,500.00" defaultValue={values?.amount ?? ""} />
422 </Field>
423 <Field label="Reference" hint="As the bank shows it. Each is recorded once.">
424 <Input name="reference" required maxLength={100} className="font-mono" defaultValue={values?.reference ?? ""} />
425 </Field>
426 </div>
427 <Field label="Note" hint="Required. Shown on the statement after “Paid by bank transfer”.">
428 <Textarea
429 name="note"
430 rows={2}
431 required
432 maxLength={500}
433 placeholder="e.g. Prepayment for October, invoice INV-0042"
434 defaultValue={values?.note ?? ""}
435 />
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put436 </Field>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look437 <Field
438 label="Confirm"
439 hint={
440 <>
441 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to record it.
442 </>
443 }
444 >
445 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
446 </Field>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put447 <div className="flex justify-end">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look448 <Button type="submit" variant="lavender">
449 <Landmark size={14} />
450 Record the payment
451 </Button>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put452 </div>
453 </form>
454 </Section>
455 );
456}
457
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace458// --- Credit -------------------------------------------------------------------
459
460export function CreditForm({ workspaces, pathname, error }: { workspaces: string[]; pathname: string; error: SectionError }) {
461 const values = error?.values;
462 const single = workspaces.length === 1 ? workspaces[0] : null;
463 return (
464 <Section id="credit" title="Issue credit" description="A refund or goodwill. Added to the workspace's balance at once.">
465 {workspaces.length === 0 ? (
466 <p className="text-sm text-muted">Add a workspace first: credit goes to a workspace.</p>
467 ) : (
468 <form method="post" action={`${pathname}#credit`} className="space-y-4">
469 <input type="hidden" name="intent" value="credit" />
470 {error && <Notice tone="error">{error.error}</Notice>}
471 {single ? (
472 <input type="hidden" name="workspace" value={single} />
473 ) : (
474 <Field label="Workspace">
475 <Select name="workspace" required defaultValue={values?.workspace ?? ""}>
476 <option value="" disabled>
477 Choose a workspace
478 </option>
479 {workspaces.map((slug) => (
480 <option key={slug} value={slug}>
481 {slug}
482 </option>
483 ))}
484 </Select>
485 </Field>
486 )}
487 <Field label="Amount $" hint="Up to $10,000 at a time.">
488 <Input name="amount" inputMode="decimal" required placeholder="25.00" defaultValue={values?.amount ?? ""} />
489 </Field>
490 <Field label="Note" hint="Required. Shown on the workspace's statement.">
491 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Refund for the failed runs on Oct 2" defaultValue={values?.note ?? ""} />
492 </Field>
493 <Field
494 label="Confirm"
495 hint={
496 <>
497 Type the workspace's slug{single && <> (<span className="font-mono text-muted">{single}</span>)</>} to issue it.
498 </>
499 }
500 >
501 <Input name="confirmation" required placeholder={single ?? "workspace-slug"} className="font-mono" />
502 </Field>
503 <div className="flex justify-end">
504 <Button type="submit" variant="lavender">
505 <Gift size={14} />
506 Issue credit
507 </Button>
508 </div>
509 </form>
510 )}
511 </Section>
512 );
513}
514
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's515// --- Reset (testing) -----------------------------------------------------------
516
517/**
518 * Wipes a test workspace's billing so it starts again as a new customer.
519 * Only while billing runs on Stripe's test key; billing refuses comped and
520 * enterprise workspaces. The workspace, its members and repositories stay.
521 */
522export function ResetBillingForm({ workspace, pathname, error }: { workspace: string; pathname: string; error: SectionError }) {
523 const values = error?.values;
524 return (
525 <Section
526 id="reset"
527 title="Reset billing (testing)"
528 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."
529 >
530 <form method="post" action={`${pathname}#reset`} className="space-y-4">
531 <input type="hidden" name="intent" value="reset" />
532 {error && <Notice tone="error">{error.error}</Notice>}
533 <Field label="Why" hint="Required. Kept in the audit log.">
534 <Textarea name="note" rows={2} required maxLength={500} placeholder="e.g. Test workspace, starting the customer walk-through again" defaultValue={values?.note ?? ""} />
535 </Field>
536 <Field
537 label="Confirm"
538 hint={
539 <>
540 Type the workspace's slug (<span className="font-mono text-muted">{workspace}</span>) to wipe its billing. It cannot be undone.
541 </>
542 }
543 >
544 <Input name="confirmation" required placeholder={workspace} className="font-mono" />
545 </Field>
546 <div className="flex justify-end">
547 <Button type="submit" variant="danger">
548 <RotateCcw size={14} />
549 Reset billing
550 </Button>
551 </div>
552 </form>
553 </Section>
554 );
555}
556
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace557// --- Stripe billing link -----------------------------------------------------
558
559/**
560 * Card details, invoices and receipts live on Stripe's own billing page.
561 * sudo never shows a card field: staff send the customer a link instead.
562 */
563export function BillingLinkSection({
564 link,
565 pathname,
566 error,
567}: {
568 link: BillingLink | null;
569 pathname: string;
570 error: SectionError;
571}) {
572 return (
573 <Section
574 id="billing-link"
575 title="Stripe billing page"
576 description="Where the customer updates their card and sees invoices. g1t never takes card numbers."
577 >
578 {error && (
579 <div className="mb-4">
580 <Notice tone="error">{error.error}</Notice>
581 </div>
582 )}
583 {link ? (
584 <div className="space-y-4">
585 <Notice tone="info">Send this to the customer. g1t never takes card numbers; they enter them on Stripe.</Notice>
586 <Field label="One-time link" hint={link.expiresNote}>
587 <Input readOnly value={link.portalUrl} className="font-mono text-xs" aria-label="One-time Stripe billing link" />
588 </Field>
589 {link.loginUrl && (
590 <Field label="Permanent sign-in page" hint={`The customer signs in with ${link.customerEmail ?? "the email Stripe has for them"}.`}>
591 <Input readOnly value={link.loginUrl} className="font-mono text-xs" aria-label="Stripe billing sign-in page" />
592 </Field>
593 )}
594 {!link.loginUrl && link.customerEmail && <p className="text-xs text-faint">Stripe has {link.customerEmail} for this customer.</p>}
595 </div>
596 ) : (
597 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
598 <input type="hidden" name="intent" value="billing-link" />
599 <p className="text-sm text-muted">
600 Never take card details over the phone or by email. Make a link to Stripe's billing page and send it to the owner.
601 </p>
602 <Button type="submit" variant="quiet" className="shrink-0">
603 <CreditCard size={14} />
604 Make a link
605 </Button>
606 </form>
607 )}
608 </Section>
609 );
610}
611
612// --- Ledger and audit ---------------------------------------------------------
613
614const ENTRY_KIND: Record<string, string> = { top_up: "Top-up", usage: "Usage", credit: "Credit" };
615
616export function LedgerSection({
617 ledger,
618 showWorkspace = false,
619 description = "Recent lines of the statement, newest first.",
620}: {
621 ledger: LedgerEntry[];
622 showWorkspace?: boolean;
623 description?: ReactNode;
624}) {
625 return (
626 <Section title="Ledger" description={description}>
627 {ledger.length === 0 ? (
628 <EmptyState title="Nothing yet" />
629 ) : (
630 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
631 <table className="w-full min-w-[36rem] text-sm">
632 <thead>
633 <tr className="border-b border-line text-left text-xs text-muted">
634 <th className="px-4 py-2 font-medium sm:pl-5">When</th>
635 <th className="px-4 py-2 font-medium">What</th>
636 <th className="px-4 py-2 text-right font-medium sm:pr-5">Amount</th>
637 </tr>
638 </thead>
639 <tbody>
640 {ledger.map((entry) => (
641 <tr key={entry.id} className="border-b border-line align-top last:border-0">
642 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pl-5">
643 <When at={entry.createdAt} time />
644 </td>
645 <td className="px-4 py-2.5">
646 <div className="flex flex-wrap items-center gap-1.5">
647 <Badge tone={entry.amountMicros > 0 ? "mint" : "plain"}>{ENTRY_KIND[entry.kind] ?? entry.kind}</Badge>
648 {showWorkspace && entry.workspace && (
649 <Link to={`/workspaces/${encodeURIComponent(entry.workspace)}`} className="font-mono text-xs text-merged hover:underline">
650 {entry.workspace}
651 </Link>
652 )}
653 <span className="break-words">{entry.description}</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look654 {givenParts(entry).map((part) => (
655 <Badge key={part.label} tone="lavender">
656 Given · {part.label} {usd(part.micros)}
657 </Badge>
658 ))}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace659 </div>
660 <p className="mt-0.5 font-mono text-xs text-faint">
661 {[
662 entry.repo && (entry.number != null ? `${entry.repo}#${entry.number}` : entry.repo),
663 entry.task,
664 entry.model,
665 entry.billedTo === "workspace" ? "own provider" : null,
666 entry.createdBy && `by ${entry.createdBy}`,
667 ]
668 .filter(Boolean)
669 .join(" · ")}
670 </p>
671 </td>
672 <td className={`tabular px-4 py-2.5 text-right whitespace-nowrap sm:pr-5 ${entry.amountMicros > 0 ? "text-accent" : "text-fg-soft"}`}>
673 {usd(entry.amountMicros, { signed: true })}
674 </td>
675 </tr>
676 ))}
677 </tbody>
678 </table>
679 </div>
680 )}
681 </Section>
682 );
683}
684
685
sudo: Invoices, Audit log, and follow-ups due686
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace687export function AuditSection({ audit, description = "Every change made in sudo, and by whom." }: { audit: AdminAction[]; description?: ReactNode }) {
688 return (
689 <Section title="Audit log" description={description}>
690 {audit.length === 0 ? (
691 <p className="flex items-center gap-2 text-sm text-muted">
692 <ScrollText size={14} />
693 No changes yet.
694 </p>
695 ) : (
696 <ol className="space-y-3">
697 {audit.map((entry) => (
698 <li key={entry.id} className="border-l-2 border-merged/40 pl-3">
699 <p className="flex flex-wrap items-center gap-x-2 text-sm">
sudo: Invoices, Audit log, and follow-ups due700 <span className="font-medium text-merged">{actionLabel(entry.action)}</span>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace701 <span className="text-xs text-faint">
702 <When at={entry.createdAt} time />
703 </span>
704 </p>
705 {entry.detail && <p className="mt-0.5 text-sm break-words text-fg-soft">{entry.detail}</p>}
706 <p className="mt-0.5 flex items-center gap-1 font-mono text-xs text-faint">
707 <UserRound size={11} />
708 {entry.by}
709 </p>
710 </li>
711 ))}
712 </ol>
713 )}
714 </Section>
715 );
716}