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

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