Skip to content

g1t/apps/web/app/components/billing-settings.tsx

728 lines36,498 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.

Usage, Billing settings and prepaid AI credit; fixes from the UX audit1/**
2 * Billing settings' newer sections: the plan at a glance with the upcoming
3 * invoice, prepaid AI credit with auto-reload, the budget's alerts, the
4 * payment method (kept on Stripe), add-ons, invoice details and the
5 * invoices. Cards and payment methods are handled on Stripe's own page:
6 * g1t has no card form. Each form posts to the billing route's action with
7 * an `intent`.
8 */
9import { ArrowUpRight, Bell, Bot, CreditCard, FileText, Mail, Plus, ReceiptText, Sparkles } from "lucide-react";
Merge Stripe Tax, the card fee on card payments, and one free workspace per person10import { type ReactNode, useState } from "react";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit11import { Form, Link } from "react-router";
12
13import type { AiCredit, BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts";
14
Merge Stripe Tax, the card fee on card payments, and one free workspace per person15import { PLUS_TAX, type PlanStatus, cardFeeCents, dollars, feeAndTax, wholeDollars } from "../lib/billing";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit16import { money } from "../lib/usage";
17import { Card } from "./billing";
18import { ErrorText, SubmitButton } from "./ui";
19import { Skeleton } from "./ui/skeleton";
20
21const FIELD =
22 "w-full rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
23
24function Dollar({ name, defaultValue, label, className }: { name: string; defaultValue?: string; label: string; className?: string }) {
25 return (
26 <span className={`flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim ${className ?? ""}`}>
27 <span className="text-sm text-muted">$</span>
28 <input
29 name={name}
30 inputMode="decimal"
31 aria-label={label}
32 defaultValue={defaultValue}
33 autoComplete="off"
34 data-1p-ignore
35 data-lpignore="true"
36 className="w-20 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none"
37 />
38 </span>
39 );
40}
41
42function whole(micros: number): string {
43 return String(Math.round(micros / 1_000_000));
44}
45
46// --- The plan ---------------------------------------------------------------------------------
47
48/**
49 * The plan at a glance: its name and standing, the billing period, the
50 * included usage, and the upcoming invoice, from g1t's own ledger.
51 */
52export function PlanSummary({
53 slug,
54 state,
55 status,
56 details,
57 report,
58 owner,
59 enabled,
60 staff,
61 error,
62}: {
63 slug: string;
64 state: FeatureState | null;
65 status: PlanStatus;
66 details: BillingDetails | null;
67 report: UsageReport | null;
68 owner: boolean;
69 enabled: boolean;
70 staff: boolean;
71 error?: string;
72}) {
73 const plan = state?.plan;
74 const subscription = state?.subscription ?? null;
75 const on = ["paid", "canceling", "comped", "enterprise", "past_due"].includes(status.kind);
76 const now = new Date();
77 const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
78 const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
79 const period = `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`;
80 const upcoming = details?.upcoming;
81 return (
82 <Card
83 id="plan"
84 tone={status.kind === "past_due" || status.kind === "canceling" ? "warn" : on ? "accent" : "plain"}
85 icon={<Sparkles size={16} />}
86 title={
87 <>
88 {plan?.title ?? "g1t"}
Merge branch 'worktree-agent-ad7c6d88d93adc817'89 <span className={`rounded-full px-2 py-0.5 text-xs ${on && status.kind !== "past_due" && status.kind !== "canceling" ? "bg-success/15 text-success" : status.kind === "free" || status.kind === "trial" ? "border border-line text-muted" : "bg-warn/15 text-warn"}`}>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit90 {status.label}
91 {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""}
92 </span>
93 </>
94 }
95 about={
96 on
97 ? `Billing period ${period}. One price for the workspace, never per seat.`
98 : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Agents, checks, workflows and deployments need the plan, or the trial."
99 }
100 aside={
101 <p className="shrink-0 sm:text-right">
102 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
103 <span className="text-sm text-muted"> / month</span>
104 <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person105 {status.kind !== "comped" && status.kind !== "enterprise" && (
106 <span className="block text-xs text-faint">
107 {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ` : ""}
108 {plan?.cardFeeCents ? PLUS_TAX : "Plus tax where it applies"}
109 </span>
110 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit111 </p>
112 }
113 >
114 <div className="mt-4 grid gap-4 sm:grid-cols-2">
115 {report?.included ? (
116 <div className="rounded-lg border border-line bg-bg/40 p-3">
117 <p className="flex justify-between text-sm">
118 <span className="text-muted">Included usage</span>
119 <span className="tabular-nums">
120 {money(report.included.used)} <span className="text-faint">/ {money(report.included.of)}</span>
121 </span>
122 </p>
123 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
124 <div className="h-full rounded-full bg-accent" style={{ width: `${Math.min(100, (report.included.used / Math.max(1, report.included.of)) * 100)}%` }} />
125 </div>
126 <p className="mt-1.5 text-xs text-faint">Used first; starts again on the 1st. Unused usage does not carry over.</p>
127 </div>
128 ) : (
129 <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm">
130 <p className="text-muted">Usage at price this month</p>
131 <p className="mt-1 text-lg font-semibold tabular-nums">{report ? money(report.totals.priceMicros) : "—"}</p>
132 {(report?.discountPercent ?? 0) > 0 && report && (
133 <p className="text-xs text-faint">
134 Discount ({report.discountPercent}%) −{money(report.totals.discountMicros)} · charged {money(report.totals.chargedMicros)}
135 </p>
136 )}
137 </div>
138 )}
139 <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm">
140 <p className="flex justify-between">
141 <span className="text-muted">Upcoming invoice</span>
142 <span className="text-lg font-semibold tabular-nums">{upcoming ? money(upcoming.totalMicros) : "—"}</span>
143 </p>
144 <p className="text-xs text-faint">Including add-ons and usage, from g1t's ledger{upcoming ? `; the month closes ${new Date(upcoming.closesAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}` : ""}.</p>
145 {upcoming && (
146 <details className="mt-1.5">
147 <summary className="cursor-pointer text-xs text-muted hover:text-fg">View upcoming invoice</summary>
148 <dl className="mt-1.5 space-y-1 text-xs">
149 <div className="flex justify-between">
150 <dt className="text-muted">Plan and add-ons</dt>
151 <dd className="tabular-nums">{money(upcoming.subscriptionsMicros)}</dd>
152 </div>
153 <div className="flex justify-between">
154 <dt className="text-muted">Usage owed, after included usage, credit and discount</dt>
155 <dd className="tabular-nums">{money(upcoming.usageMicros)}</dd>
156 </div>
157 </dl>
158 </details>
159 )}
160 </div>
161 </div>
162 <div className="mt-4 flex flex-wrap items-center gap-2">
163 <Link to={`/${slug}/-/usage`} className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:text-fg">
164 <FileText size={14} /> Usage
165 </Link>
166 <a href="#invoices" className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm text-fg/80 hover:border-line-strong hover:text-fg">
167 <ReceiptText size={14} /> Invoices
168 </a>
169 {owner && enabled && (
170 <Form method="post" className="contents">
171 <input type="hidden" name="feature" value="plan" />
172 {status.kind === "free" || status.kind === "trial" ? (
173 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
174 <CreditCard size={14} />
175 Start the g1t plan
176 </SubmitButton>
177 ) : status.kind === "canceling" ? (
178 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
179 Keep the plan
180 </SubmitButton>
181 ) : status.kind === "paid" && subscription ? (
182 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
183 Downgrade to free at the period's end
184 </SubmitButton>
185 ) : null}
186 </Form>
187 )}
188 <a href="mailto:sales@g1t.sh?subject=Custom%20needs" className="ml-auto inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
189 <Mail size={14} /> Custom needs? Contact us
190 </a>
191 </div>
192 {staff && enabled && (status.kind === "free" || status.kind === "trial") && (
193 <p className="mt-3 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>
194 )}
195 {(status.kind === "free" || status.kind === "trial") && (
196 <p className="mt-3 text-xs text-faint">
197 Starting the plan gives $5 of AI credit, once. Your first month starts at a {wholeDollars(100_000_000)} limit on usage past
198 what is included, so a stolen card cannot run up a bill.
199 </p>
200 )}
201 <ErrorText>{error}</ErrorText>
202 </Card>
203 );
204}
205
206// --- AI credit ----------------------------------------------------------------------------------
207
208/** Prepaid AI credit: the balance, buying more, and auto-reload. */
209export function AiCreditCard({ credit, owner, enabled, staff, error }: { credit: AiCredit; owner: boolean; enabled: boolean; staff: boolean; error?: string }) {
210 const rate = credit.agentRateMicros;
211 const fee = credit.cardFee;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person212 // What is chosen, so the fee shows before Stripe's page does.
213 const [chosenCents, setChosenCents] = useState(2_500);
214 const chosenFee = cardFeeCents(chosenCents, fee);
215 const stripeFee = `Stripe's ${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}`;
216 const feeText = !fee.on
217 ? `${feeAndTax(0)}.`
218 : chosenCents > 0
219 ? `On $${(chosenCents / 100).toLocaleString("en-US")}: ${feeAndTax(chosenFee).replace(/^C/, "c")} (the fee is ${stripeFee}, its own line at checkout).`
220 : `A card processing fee (${stripeFee}) is its own line at checkout, ${PLUS_TAX}.`;
221 const choose = (form: HTMLFormElement) => {
222 const data = new FormData(form);
223 const amount = String(data.get("amount") ?? "");
224 const dollarsChosen = amount === "custom" ? Number(String(data.get("custom") ?? "").replace(/[$,\s]/g, "")) : Number(amount);
225 setChosenCents(Number.isFinite(dollarsChosen) && dollarsChosen > 0 ? Math.round(dollarsChosen * 100) : 0);
226 };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit227 return (
228 <Card
229 id="ai-credit"
230 tone={credit.blocked ? "warn" : "plain"}
231 icon={<Bot size={16} />}
232 title="AI credit"
233 about={
234 credit.freeViaDiscount
235 ? "Agent and AI Gateway usage is free for this workspace under its discount: shown at its price, then the discount."
236 : credit.postpaid
237 ? "This workspace's enterprise is invoiced for Agent and AI Gateway usage after use: no credit needed."
238 : `Prepaid credit for Agent and AI Gateway usage, spent before anything else. Models at the provider's price${credit.modelMarkupPercent ? ` plus ${credit.modelMarkupPercent}%` : ""}, plus the agent rate${rate > 0 ? ` (${dollars(rate)} per million tokens)` : ""}. Credit expires ${credit.expiresDays === 365 ? "1 year" : `${credit.expiresDays} days`} after purchase.`
239 }
240 aside={
241 <p className="shrink-0 sm:text-right">
242 <span className="text-2xl font-semibold tabular-nums tracking-tight">{money(credit.balanceMicros)}</span>
243 <span className="block text-xs text-faint">
244 {credit.givenMicros > 0 ? `${money(credit.purchasedMicros)} bought, ${money(credit.givenMicros)} from g1t` : "left"}
245 </span>
246 </p>
247 }
248 >
249 {credit.blocked && (
250 <p className="mt-3 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-sm">
251 Out of AI credit, with this month's included usage used: new runs on g1t's models wait until you buy credit or turn on
252 auto-reload. Runs on your own model provider keep going.
253 </p>
254 )}
255 {!credit.freeViaDiscount && !credit.postpaid && (
256 <>
257 {credit.canBuy && owner && enabled ? (
Merge Stripe Tax, the card fee on card payments, and one free workspace per person258 <Form method="post" className="mt-4" onChange={(event) => choose(event.currentTarget)}>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit259 <input type="hidden" name="intent" value="buy-ai-credit" />
260 <fieldset className="flex flex-wrap items-center gap-2">
261 <legend className="mb-2 text-xs text-muted">Buy AI credit</legend>
262 {credit.presetsCents.map((cents) => (
263 <label key={cents} className="cursor-pointer">
264 <input type="radio" name="amount" value={String(cents / 100)} defaultChecked={cents === 2_500} className="peer sr-only" />
265 <span className="inline-block rounded-md border border-line px-3 py-1.5 text-sm tabular-nums peer-checked:border-accent peer-checked:bg-accent/10 peer-focus-visible:ring-2 peer-focus-visible:ring-accent-dim">
266 ${cents / 100}
267 </span>
268 </label>
269 ))}
270 <label className="cursor-pointer">
271 <input type="radio" name="amount" value="custom" className="peer sr-only" />
272 <span className="inline-block rounded-md border border-line px-3 py-1.5 text-sm peer-checked:border-accent peer-checked:bg-accent/10">Custom</span>
273 </label>
274 <Dollar name="custom" label="Custom amount in dollars" />
275 <SubmitButton variant="accent" match={{ intent: "buy-ai-credit" }} pending="Opening Stripe…">
276 <Plus size={14} /> Buy AI credit
277 </SubmitButton>
278 </fieldset>
279 <p className="mt-2 text-xs text-faint">
280 ${credit.minCents / 100} to ${(credit.maxCents / 100).toLocaleString("en-US")}, on Stripe's page. {feeText}
281 {staff ? " Test mode: card 4242 4242 4242 4242." : ""}
282 </p>
283 </Form>
284 ) : !credit.canBuy ? (
285 <p className="mt-4 text-sm text-muted">AI credit is for workspaces on the g1t plan. Starting the plan comes with $5 of it.</p>
286 ) : (
287 <p className="mt-4 text-sm text-muted">An owner buys AI credit.</p>
288 )}
289 {credit.canBuy && <AutoReload credit={credit} owner={owner && enabled} />}
290 </>
291 )}
292 {credit.grants.length > 0 && (
293 <details className="mt-4">
294 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Purchases and grants</summary>
295 <ul className="mt-2 divide-y divide-line/60 text-sm">
296 {credit.grants.map((grant) => (
297 <li key={grant.id} className="flex flex-wrap justify-between gap-2 py-1.5">
298 <span className="min-w-0">
299 {grant.kind === "purchased" ? "Bought" : grant.source === "upgrade" ? "For starting the plan" : "From g1t"}{" "}
300 <span className="text-faint">· {new Date(grant.createdAt).toLocaleDateString()}{grant.expiresAt ? `, expires ${new Date(grant.expiresAt).toLocaleDateString()}` : ""}</span>
301 </span>
302 <span className="tabular-nums">
303 {money(grant.leftMicros)} <span className="text-faint">of {money(grant.amountMicros)}</span>
304 </span>
305 </li>
306 ))}
307 </ul>
308 </details>
309 )}
310 <ErrorText>{error}</ErrorText>
311 </Card>
312 );
313}
314
315function AutoReload({ credit, owner }: { credit: AiCredit; owner: boolean }) {
316 const r = credit.reload;
317 return (
318 <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4">
319 <div className="flex flex-wrap items-baseline justify-between gap-2">
320 <p className="text-sm font-medium">Auto-reload</p>
Merge branch 'worktree-agent-ad7c6d88d93adc817'321 <span className={`rounded-full px-2 py-0.5 text-xs ${r.enabled ? "bg-success/15 text-success" : "border border-line text-muted"}`}>{r.enabled ? "On" : "Off"}</span>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit322 </div>
323 {r.failedAt && (
324 <p className="mt-2 text-sm text-danger">
325 Turned off on {new Date(r.failedAt).toLocaleDateString()}: {r.error ?? "the card was declined"}. Update the card on Stripe, then turn it on again.
326 </p>
327 )}
328 {owner ? (
329 <Form method="post" className="mt-3 space-y-3 text-sm">
330 <input type="hidden" name="intent" value="ai-reload" />
331 <label className="flex items-center gap-2">
332 <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--g1t-accent)]" />
333 Reload from the saved card
334 </label>
335 <div className="flex flex-wrap items-center gap-2 text-muted">
336 When AI credit falls below <Dollar name="threshold" label="Threshold in dollars" defaultValue={whole(r.thresholdMicros)} />
337 reload it to <Dollar name="target" label="Target in dollars" defaultValue={whole(r.targetMicros)} />
338 </div>
339 <div className="flex flex-wrap items-center gap-2 text-muted">
340 At most <Dollar name="monthly" label="Monthly maximum in dollars" defaultValue={whole(r.monthlyMaxMicros)} /> a month
341 {(r.reloadedMicros ?? 0) > 0 && <span className="text-faint">· {money(r.reloadedMicros ?? 0)} reloaded this month</span>}
342 </div>
343 <SubmitButton variant="quiet" match={{ intent: "ai-reload" }} pending="Saving…">
344 Save auto-reload
345 </SubmitButton>
346 <p className="text-xs text-faint">A reload that cannot be charged turns auto-reload off and tells the owners by email.</p>
347 </Form>
348 ) : (
349 <p className="mt-2 text-sm text-muted">
350 {r.enabled ? `Below ${money(r.thresholdMicros)}, back to ${money(r.targetMicros)}, at most ${money(r.monthlyMaxMicros)} a month.` : "Off."}
351 </p>
352 )}
353 </div>
354 );
355}
356
357// --- Budget alerts --------------------------------------------------------------------------------
358
359/** The budget's alerts, whether it pauses usage, and its webhook: beside the spend limit. */
360export function BudgetAlerts({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
361 const levels = limit.alertLevels ?? [100, 90, 75, 50];
362 const pause = limit.pauseAtLimit ?? true;
363 const spent = limit.spentMicros ?? 0;
364 const of = limit.spendLimitMicros;
365 return (
366 <Card id="budget" icon={<Bell size={16} />} title="Budget alerts" about="On usage past what the plan includes, measured against the spend limit above. Each alert is emailed to the owners once a month.">
367 {of != null && (
368 <div className="mt-3">
369 <div className="flex justify-between text-sm">
370 <span className="text-muted">Spent this month</span>
371 <span className="tabular-nums">
372 {money(spent)} <span className="text-faint">of {money(of)}</span>
373 </span>
374 </div>
375 <div className="relative mt-1.5 h-1.5 rounded-full bg-line" role="presentation">
Merge branch 'worktree-agent-ad7c6d88d93adc817'376 <div className={`h-full rounded-full ${spent >= of ? "bg-danger" : spent * 4 >= of * 3 ? "bg-warn" : "bg-success"}`} style={{ width: `${Math.min(100, (spent / Math.max(1, of)) * 100)}%` }} />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit377 {levels.filter((l) => l < 100).map((l) => (
378 <span key={l} className="absolute -top-0.5 h-2.5 w-px bg-fg/40" style={{ left: `${l}%` }} />
379 ))}
380 </div>
381 </div>
382 )}
383 {owner ? (
384 <Form method="post" className="mt-4 space-y-3 text-sm">
385 <input type="hidden" name="intent" value="budget" />
386 <fieldset className="flex flex-wrap items-center gap-3">
387 <legend className="mb-1.5 text-xs text-muted">Alert at</legend>
388 {[50, 75, 90, 100].map((level) => (
389 <label key={level} className="flex items-center gap-1.5">
390 <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--g1t-accent)]" />
391 {level}%
392 </label>
393 ))}
394 </fieldset>
395 <label className="flex items-center gap-2">
396 <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--g1t-accent)]" />
397 Pause usage at 100%
398 <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span>
399 </label>
400 <label className="block">
401 <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span>
402 <input name="webhook" type="url" placeholder="https://hooks.example.com/g1t" defaultValue={limit.budgetWebhook ?? ""} className={`mt-1 ${FIELD} max-w-md`} />
403 </label>
404 <SubmitButton variant="quiet" match={{ intent: "budget" }} pending="Saving…">
405 Save alerts
406 </SubmitButton>
407 </Form>
408 ) : (
409 <p className="mt-3 text-sm text-muted">
410 Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%.
411 </p>
412 )}
413 <ErrorText>{error}</ErrorText>
414 </Card>
415 );
416}
417
418// --- Payment method -------------------------------------------------------------------------------
419
420export function PaymentMethodCard({ details, owner, enabled, staff, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; staff: boolean; error?: string }) {
421 const method = details?.paymentMethod;
422 return (
423 <Card
424 id="payment"
425 icon={<CreditCard size={16} />}
426 title="Payment method"
427 about="Cards are added, removed and made the default on Stripe's billing page. g1t never sees card numbers."
428 aside={
429 owner && enabled ? (
430 <Form method="post">
431 <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…">
432 Manage in Stripe
433 <ArrowUpRight size={14} />
434 </SubmitButton>
435 </Form>
436 ) : null
437 }
438 >
439 <div className="mt-3 text-sm">
440 {method ? (
441 <p className="flex flex-wrap items-center gap-2">
442 <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span>
443 {method.last4 && (
444 <span>
445 ending <span className="font-mono">{method.last4}</span>
446 </span>
447 )}
448 {method.expMonth && method.expYear && (
449 <span className="text-muted">
450 · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear}
451 </span>
452 )}
453 <span className="text-xs text-faint">default</span>
454 </p>
455 ) : (
456 <p className="text-muted">No payment method yet. Starting the plan or buying AI credit saves the card you pay with.</p>
457 )}
458 {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>}
459 {staff && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
460 </div>
461 <ErrorText>{error}</ErrorText>
462 </Card>
463 );
464}
465
466// --- Add-ons ------------------------------------------------------------------------------------
467
468export function AddOns({ plan, security, owner, enabled, error }: { plan: FeatureState | null; security: FeatureState | null; owner: boolean; enabled: boolean; error?: string }) {
469 const planOn = !!plan?.on;
470 const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = [];
471 if (security) {
472 const status = security.subscription?.status;
473 rows.push({
474 key: "security",
475 name: security.plan.title,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person476 about: `Custom patterns, validity checks, code scanning and dependency review on private repositories. ${
477 security.plan.cardFeeCents ? `Plus a ${dollars(security.plan.cardFeeCents * 10_000)} card processing fee a month, and tax where it applies.` : "Plus tax where it applies."
478 }`,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit479 price: `$${(security.plan.monthlyCents / 100).toFixed(security.plan.monthlyCents % 100 ? 2 : 0)} / month`,
480 on: security.on,
481 label: security.included ? "Included" : status === "canceling" ? "Ends at the period's end" : security.on ? "On" : "Off",
482 control:
483 !owner || !enabled || security.included ? null : (
484 <Form method="post">
485 <input type="hidden" name="feature" value="security" />
486 {!security.on ? (
487 <SubmitButton variant="quiet" name="intent" value="subscribe" pending="Opening Stripe…">
488 Turn on
489 </SubmitButton>
490 ) : status === "canceling" ? (
491 <SubmitButton variant="quiet" name="intent" value="resume" pending="Saving…">
492 Keep it on
493 </SubmitButton>
494 ) : (
495 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
496 Turn off
497 </SubmitButton>
498 )}
499 </Form>
500 ),
501 });
502 }
503 rows.push({
504 key: "deployments",
505 name: "Production deployments",
506 about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.",
507 price: "With the plan",
508 on: planOn,
509 label: planOn ? "On" : "Needs the plan",
510 control: null,
511 });
512 return (
513 <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
514 <h2 className="px-5 pt-5 font-medium">Add-ons</h2>
515 <ul className="mt-3 divide-y divide-line border-t border-line">
516 {rows.map((row) => (
517 <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm">
518 <div className="min-w-0 flex-1 basis-60">
519 <p className="font-medium">{row.name}</p>
520 <p className="text-xs text-muted">{row.about}</p>
521 </div>
522 <span className="tabular-nums text-muted">{row.price}</span>
Merge branch 'worktree-agent-ad7c6d88d93adc817'523 <span className={`rounded-full px-2 py-0.5 text-xs ${row.on ? "bg-success/15 text-success" : "border border-line text-muted"}`}>{row.label}</span>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit524 {row.control}
525 </li>
526 ))}
527 </ul>
528 {error && (
529 <div className="px-5 pb-4">
530 <ErrorText>{error}</ErrorText>
531 </div>
532 )}
533 </section>
534 );
535}
536
537// --- Invoice details ------------------------------------------------------------------------------
538
539const TAX_IDS: [string, string][] = [
540 ["", "None"],
541 ["eu_vat", "EU VAT"],
542 ["gb_vat", "UK VAT"],
543 ["us_ein", "US EIN"],
544 ["ca_bn", "Canadian BN"],
545 ["au_abn", "Australian ABN"],
546 ["ch_vat", "Swiss VAT"],
547 ["no_vat", "Norwegian VAT"],
548 ["in_gst", "Indian GST"],
549 ["jp_cn", "Japanese corporate number"],
550 ["br_cnpj", "Brazilian CNPJ"],
551 ["mx_rfc", "Mexican RFC"],
552 ["sg_uen", "Singapore UEN"],
553 ["nz_gst", "New Zealand GST"],
554 ["za_vat", "South African VAT"],
555];
556
Merge Stripe Tax, the card fee on card payments, and one free workspace per person557/** What Stripe's check of a tax ID found. */
558const TAX_ID_STATUS: Record<string, string> = {
559 verified: "Verified by Stripe.",
560 pending: "Stripe is checking it.",
561 unverified: "Stripe could not verify it. Check the number.",
562 unavailable: "Stripe cannot check this kind of ID.",
563};
564
Usage, Billing settings and prepaid AI credit; fixes from the UX audit565const LANGUAGES: [string, string][] = [
566 ["", "Automatic"],
567 ["en", "English"],
568 ["en-GB", "English (UK)"],
569 ["de", "German"],
570 ["fr", "French"],
571 ["es", "Spanish"],
572 ["it", "Italian"],
573 ["nl", "Dutch"],
574 ["pt-BR", "Portuguese (Brazil)"],
575 ["ja", "Japanese"],
576 ["zh", "Chinese"],
577];
578
579export function InvoiceDetailsCard({ details, owner, enabled, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; error?: string }) {
580 const a = details?.address;
581 const disabled = !owner || !enabled;
582 return (
Merge Stripe Tax, the card fee on card payments, and one free workspace per person583 <Card
584 id="details"
585 icon={<ReceiptText size={16} />}
586 title="Invoice details"
587 about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address."
588 >
589 {details?.customer && !details.taxLocation && (
590 <p className={`mt-3 rounded-lg border px-3 py-2 text-sm ${details.taxAddressNeededAt ? "border-warn/40 bg-warn/5" : "border-line bg-bg/40 text-muted"}`}>
591 {details.taxAddressNeededAt
592 ? "Add the billing address: Stripe needs at least the country (and in the US the ZIP code) to work out tax, so g1t is not charging the card until it is here."
593 : "No billing address yet. Stripe needs at least the country (and in the US the ZIP code) to work out tax before g1t charges the card."}
594 </p>
595 )}
596 {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && (
597 <p className="mt-3 text-xs text-muted">
598 {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."}
599 </p>
600 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit601 <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
602 <input type="hidden" name="intent" value="details" />
603 <fieldset disabled={disabled} className="contents">
604 <label className="block">
605 <span className="text-xs text-muted">Invoice email</span>
606 <input name="email" type="email" defaultValue={details?.email ?? ""} className={`mt-1 ${FIELD}`} />
607 </label>
608 <label className="block">
609 <span className="text-xs text-muted">Company name</span>
610 <input name="name" defaultValue={details?.name ?? ""} className={`mt-1 ${FIELD}`} />
611 </label>
612 <label className="block sm:col-span-2">
613 <span className="text-xs text-muted">Address</span>
614 <input name="line1" placeholder="Street" defaultValue={a?.line1 ?? ""} className={`mt-1 ${FIELD}`} />
615 <input name="line2" placeholder="Suite, floor (optional)" aria-label="Address line 2" defaultValue={a?.line2 ?? ""} className={`mt-2 ${FIELD}`} />
616 </label>
617 <div className="grid grid-cols-2 gap-3 sm:col-span-2 sm:grid-cols-4">
618 <input name="city" placeholder="City" aria-label="City" defaultValue={a?.city ?? ""} className={FIELD} />
619 <input name="state" placeholder="State or region" aria-label="State or region" defaultValue={a?.state ?? ""} className={FIELD} />
620 <input name="postalCode" placeholder="Postal code" aria-label="Postal code" defaultValue={a?.postalCode ?? ""} className={FIELD} />
621 <input name="country" placeholder="Country (US)" aria-label="Country, two letters" maxLength={2} defaultValue={a?.country ?? ""} className={`${FIELD} uppercase`} />
622 </div>
623 <label className="block">
624 <span className="text-xs text-muted">Tax ID</span>
625 <span className="mt-1 flex gap-2">
626 <select name="taxIdType" defaultValue={details?.taxIdType ?? ""} className={`${FIELD} w-auto`} aria-label="Kind of tax ID">
627 {TAX_IDS.map(([value, label]) => (
628 <option key={value} value={value}>
629 {label}
630 </option>
631 ))}
632 </select>
633 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
634 </span>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person635 {details?.taxId && details.taxIdStatus && (
636 <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span>
637 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit638 </label>
639 <label className="block">
640 <span className="text-xs text-muted">Purchase order</span>
641 <input name="poNumber" defaultValue={details?.poNumber ?? ""} className={`mt-1 ${FIELD}`} />
642 </label>
643 <label className="block">
644 <span className="text-xs text-muted">Invoice language</span>
645 <select name="language" defaultValue={details?.language ?? ""} className={`mt-1 ${FIELD}`}>
646 {LANGUAGES.map(([value, label]) => (
647 <option key={value} value={value}>
648 {label}
649 </option>
650 ))}
651 </select>
652 </label>
653 {!disabled && (
654 <div className="flex items-end">
655 <SubmitButton variant="quiet" match={{ intent: "details" }} pending="Saving…">
656 Save invoice details
657 </SubmitButton>
658 </div>
659 )}
660 </fieldset>
661 </Form>
662 {!owner && <p className="mt-3 text-xs text-faint">An owner changes these.</p>}
663 <ErrorText>{error}</ErrorText>
664 </Card>
665 );
666}
667
668// --- Invoices ---------------------------------------------------------------------------------------
669
670const STATUS: Record<string, string> = { paid: "Paid", open: "Open", void: "Void", uncollectible: "Uncollectible", draft: "Draft" };
671
672export function InvoicesCard({ details }: { details: BillingDetails | null }) {
673 const invoices = details?.invoices ?? [];
674 return (
675 <section id="invoices" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
676 <div className="px-5 pt-5">
677 <h2 className="font-medium">Invoices</h2>
678 <p className="mt-1 text-sm text-muted">The plan, add-ons, AI credit and each month's usage, as Stripe sent them.</p>
679 </div>
680 {invoices.length === 0 ? (
681 <p className="px-5 py-5 text-sm text-faint">No invoices yet.</p>
682 ) : (
683 <ul className="mt-3 divide-y divide-line border-t border-line">
684 {invoices.map((invoice) => (
685 <li key={invoice.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-5 py-2.5 text-sm">
686 <span className="w-24 shrink-0 text-muted tabular-nums">{new Date(invoice.createdAt).toLocaleDateString("en-US", { timeZone: "UTC" })}</span>
687 <span className="min-w-0 flex-1 truncate">{invoice.description ?? invoice.number ?? invoice.id}</span>
Merge branch 'worktree-agent-ad7c6d88d93adc817'688 <span className={`rounded-full px-2 py-0.5 text-xs ${invoice.status === "paid" ? "bg-success/15 text-success" : invoice.status === "open" ? "bg-warn/15 text-warn" : "border border-line text-muted"}`}>
Usage, Billing settings and prepaid AI credit; fixes from the UX audit689 {STATUS[invoice.status] ?? invoice.status}
690 </span>
691 <span className="w-20 text-right tabular-nums">{money(invoice.totalCents * 10_000)}</span>
692 <span className="flex gap-3 text-xs">
693 {invoice.hostedUrl && (
694 <a href={invoice.hostedUrl} className="text-muted hover:text-fg">
695 View
696 </a>
697 )}
698 {invoice.pdfUrl && (
699 <a href={invoice.pdfUrl} className="text-muted hover:text-fg">
700 PDF
701 </a>
702 )}
703 </span>
704 </li>
705 ))}
706 </ul>
707 )}
708 </section>
709 );
710}
711
712/** Stripe's sections while Stripe is asked: the same shapes. */
713export function StripeSkeleton() {
714 return (
715 <div aria-busy="true">
716 <span role="status" className="sr-only">
717 Loading from Stripe…
718 </span>
719 {[5.5, 14, 8].map((rem, i) => (
720 <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}>
721 <Skeleton className="h-4 w-36" />
722 <Skeleton className="mt-3 h-3 w-72 max-w-full" />
723 <Skeleton className="mt-4 h-8 w-full" />
724 </div>
725 ))}
726 </div>
727 );
728}

This file's history is long; its oldest lines are credited to the oldest commit read.