Skip to content
729 linesCodeBlameRaw

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

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