Skip to content
708 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 rows.push({
474 key: "security",
475 name: security.plan.title,
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.476 about: "Custom patterns, validity checks, code scanning and dependency review on private repositories. No price of its own: scans are charged at cost plus 20%.",
477 price: "With the plan",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit478 on: security.on,
Pricing says it plainly: models at the provider's price, the agent rate for what g1t runs around every model call (the gateway, secrets, routing, context and pass-through to your own provider, so your own keys too), everything else at cost plus 20%, your own runners free, no seats; Security and quality comes with the plan with no separate fee, and live activations end. Each agent has an effort setting, Auto to Max, with what a typical task has cost at each level, and Spend's Spend less, keep quality suggests a lower level only when the agent's own past work shows quality held, to apply or dismiss. The pricing, spend and agents guides say how.479 label: security.on ? "On" : "Needs the plan",
480 control: null,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit481 });
482 }
483 rows.push({
484 key: "deployments",
485 name: "Production deployments",
486 about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.",
487 price: "With the plan",
488 on: planOn,
489 label: planOn ? "On" : "Needs the plan",
490 control: null,
491 });
492 return (
493 <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
494 <h2 className="px-5 pt-5 font-medium">Add-ons</h2>
495 <ul className="mt-3 divide-y divide-line border-t border-line">
496 {rows.map((row) => (
497 <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm">
498 <div className="min-w-0 flex-1 basis-60">
499 <p className="font-medium">{row.name}</p>
500 <p className="text-xs text-muted">{row.about}</p>
501 </div>
502 <span className="tabular-nums text-muted">{row.price}</span>
Merge branch 'worktree-agent-ad7c6d88d93adc817'503 <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 audit504 {row.control}
505 </li>
506 ))}
507 </ul>
508 {error && (
509 <div className="px-5 pb-4">
510 <ErrorText>{error}</ErrorText>
511 </div>
512 )}
513 </section>
514 );
515}
516
517// --- Invoice details ------------------------------------------------------------------------------
518
519const TAX_IDS: [string, string][] = [
520 ["", "None"],
521 ["eu_vat", "EU VAT"],
522 ["gb_vat", "UK VAT"],
523 ["us_ein", "US EIN"],
524 ["ca_bn", "Canadian BN"],
525 ["au_abn", "Australian ABN"],
526 ["ch_vat", "Swiss VAT"],
527 ["no_vat", "Norwegian VAT"],
528 ["in_gst", "Indian GST"],
529 ["jp_cn", "Japanese corporate number"],
530 ["br_cnpj", "Brazilian CNPJ"],
531 ["mx_rfc", "Mexican RFC"],
532 ["sg_uen", "Singapore UEN"],
533 ["nz_gst", "New Zealand GST"],
534 ["za_vat", "South African VAT"],
535];
536
Merge Stripe Tax, the card fee on card payments, and one free workspace per person537/** What Stripe's check of a tax ID found. */
538const TAX_ID_STATUS: Record<string, string> = {
539 verified: "Verified by Stripe.",
540 pending: "Stripe is checking it.",
541 unverified: "Stripe could not verify it. Check the number.",
542 unavailable: "Stripe cannot check this kind of ID.",
543};
544
Usage, Billing settings and prepaid AI credit; fixes from the UX audit545const LANGUAGES: [string, string][] = [
546 ["", "Automatic"],
547 ["en", "English"],
548 ["en-GB", "English (UK)"],
549 ["de", "German"],
550 ["fr", "French"],
551 ["es", "Spanish"],
552 ["it", "Italian"],
553 ["nl", "Dutch"],
554 ["pt-BR", "Portuguese (Brazil)"],
555 ["ja", "Japanese"],
556 ["zh", "Chinese"],
557];
558
559export function InvoiceDetailsCard({ details, owner, enabled, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; error?: string }) {
560 const a = details?.address;
561 const disabled = !owner || !enabled;
562 return (
Merge Stripe Tax, the card fee on card payments, and one free workspace per person563 <Card
564 id="details"
565 icon={<ReceiptText size={16} />}
566 title="Invoice details"
567 about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address."
568 >
569 {details?.customer && !details.taxLocation && (
570 <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"}`}>
571 {details.taxAddressNeededAt
572 ? "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."
573 : "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."}
574 </p>
575 )}
576 {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && (
577 <p className="mt-3 text-xs text-muted">
578 {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."}
579 </p>
580 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit581 <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
582 <input type="hidden" name="intent" value="details" />
583 <fieldset disabled={disabled} className="contents">
584 <label className="block">
585 <span className="text-xs text-muted">Invoice email</span>
586 <input name="email" type="email" defaultValue={details?.email ?? ""} className={`mt-1 ${FIELD}`} />
587 </label>
588 <label className="block">
589 <span className="text-xs text-muted">Company name</span>
590 <input name="name" defaultValue={details?.name ?? ""} className={`mt-1 ${FIELD}`} />
591 </label>
592 <label className="block sm:col-span-2">
593 <span className="text-xs text-muted">Address</span>
594 <input name="line1" placeholder="Street" defaultValue={a?.line1 ?? ""} className={`mt-1 ${FIELD}`} />
595 <input name="line2" placeholder="Suite, floor (optional)" aria-label="Address line 2" defaultValue={a?.line2 ?? ""} className={`mt-2 ${FIELD}`} />
596 </label>
597 <div className="grid grid-cols-2 gap-3 sm:col-span-2 sm:grid-cols-4">
598 <input name="city" placeholder="City" aria-label="City" defaultValue={a?.city ?? ""} className={FIELD} />
599 <input name="state" placeholder="State or region" aria-label="State or region" defaultValue={a?.state ?? ""} className={FIELD} />
600 <input name="postalCode" placeholder="Postal code" aria-label="Postal code" defaultValue={a?.postalCode ?? ""} className={FIELD} />
601 <input name="country" placeholder="Country (US)" aria-label="Country, two letters" maxLength={2} defaultValue={a?.country ?? ""} className={`${FIELD} uppercase`} />
602 </div>
603 <label className="block">
604 <span className="text-xs text-muted">Tax ID</span>
605 <span className="mt-1 flex gap-2">
Chat controls, public profiles, shadcn selects, and no Docs tab in a project606 <SelectField
607 name="taxIdType"
608 defaultValue={details?.taxIdType ?? ""}
609 aria-label="Kind of tax ID"
610 className="h-auto w-auto shrink-0 px-2.5 py-1.5"
611 options={TAX_IDS.map(([value, label]) => ({ value, label }))}
612 />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit613 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
614 </span>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person615 {details?.taxId && details.taxIdStatus && (
616 <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span>
617 )}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit618 </label>
619 <label className="block">
620 <span className="text-xs text-muted">Purchase order</span>
621 <input name="poNumber" defaultValue={details?.poNumber ?? ""} className={`mt-1 ${FIELD}`} />
622 </label>
623 <label className="block">
624 <span className="text-xs text-muted">Invoice language</span>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project625 <SelectField
626 name="language"
627 defaultValue={details?.language ?? ""}
628 aria-label="Invoice language"
629 className="mt-1 h-auto px-2.5 py-1.5"
630 options={LANGUAGES.map(([value, label]) => ({ value, label }))}
631 />
Usage, Billing settings and prepaid AI credit; fixes from the UX audit632 </label>
633 {!disabled && (
634 <div className="flex items-end">
635 <SubmitButton variant="quiet" match={{ intent: "details" }} pending="Saving…">
636 Save invoice details
637 </SubmitButton>
638 </div>
639 )}
640 </fieldset>
641 </Form>
642 {!owner && <p className="mt-3 text-xs text-faint">An owner changes these.</p>}
643 <ErrorText>{error}</ErrorText>
644 </Card>
645 );
646}
647
648// --- Invoices ---------------------------------------------------------------------------------------
649
650const STATUS: Record<string, string> = { paid: "Paid", open: "Open", void: "Void", uncollectible: "Uncollectible", draft: "Draft" };
651
652export function InvoicesCard({ details }: { details: BillingDetails | null }) {
653 const invoices = details?.invoices ?? [];
654 return (
655 <section id="invoices" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
656 <div className="px-5 pt-5">
657 <h2 className="font-medium">Invoices</h2>
658 <p className="mt-1 text-sm text-muted">The plan, add-ons, AI credit and each month's usage, as Stripe sent them.</p>
659 </div>
660 {invoices.length === 0 ? (
661 <p className="px-5 py-5 text-sm text-faint">No invoices yet.</p>
662 ) : (
663 <ul className="mt-3 divide-y divide-line border-t border-line">
664 {invoices.map((invoice) => (
665 <li key={invoice.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-5 py-2.5 text-sm">
666 <span className="w-24 shrink-0 text-muted tabular-nums">{new Date(invoice.createdAt).toLocaleDateString("en-US", { timeZone: "UTC" })}</span>
667 <span className="min-w-0 flex-1 truncate">{invoice.description ?? invoice.number ?? invoice.id}</span>
Merge branch 'worktree-agent-ad7c6d88d93adc817'668 <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 audit669 {STATUS[invoice.status] ?? invoice.status}
670 </span>
671 <span className="w-20 text-right tabular-nums">{money(invoice.totalCents * 10_000)}</span>
672 <span className="flex gap-3 text-xs">
673 {invoice.hostedUrl && (
674 <a href={invoice.hostedUrl} className="text-muted hover:text-fg">
675 View
676 </a>
677 )}
678 {invoice.pdfUrl && (
679 <a href={invoice.pdfUrl} className="text-muted hover:text-fg">
680 PDF
681 </a>
682 )}
683 </span>
684 </li>
685 ))}
686 </ul>
687 )}
688 </section>
689 );
690}
691
692/** Stripe's sections while Stripe is asked: the same shapes. */
693export function StripeSkeleton() {
694 return (
695 <div aria-busy="true">
696 <span role="status" className="sr-only">
697 Loading from Stripe…
698 </span>
699 {[5.5, 14, 8].map((rem, i) => (
700 <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}>
701 <Skeleton className="h-4 w-36" />
702 <Skeleton className="mt-3 h-3 w-72 max-w-full" />
703 <Skeleton className="mt-4 h-8 w-full" />
704 </div>
705 ))}
706 </div>
707 );
708}

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