Skip to content
709 linesCodeBlameRaw
1/**
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";
10import { type ReactNode, useState } from "react";
11import { Form, Link } from "react-router";
12
13import type { AiCredit, BillingDetails, FeatureState, Limit, UsageReport } from "@g1t/contracts";
14
15import { PLUS_TAX, type PlanStatus, cardFeeCents, dollars, feeAndTax, wholeDollars } from "../lib/billing";
16import { money } from "../lib/usage";
17import { Card } from "./billing";
18import { ErrorText, SubmitButton } from "./ui";
19import { Checkbox } from "./ui/checkbox";
20import { SelectField } from "./ui/select";
21import { 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"}
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"}`}>
92 {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>
107 {status.kind !== "comped" && status.kind !== "enterprise" && (
108 <span className="block text-xs text-faint">
109 {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ` : ""}
110 {plan?.cardFeeCents ? PLUS_TAX : "Plus tax where it applies"}
111 </span>
112 )}
113 </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 ? (
184 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
185 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;
214 // 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);
217 const stripeFee = `Stripe's ${(fee.percentMicros / 10_000).toFixed(1)}% + ${dollars(fee.fixedCents * 10_000)}`;
218 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 };
229 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."
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 ? ` (${dollars(rate)} per million tokens)` : ""}. Credit expires ${credit.expiresDays === 365 ? "1 year" : `${credit.expiresDays} days`} after purchase.`
241 }
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 ? (
260 <Form method="post" className="mt-4" onChange={(event) => choose(event.currentTarget)}>
261 <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>
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>
324 </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" />
333 <label className="flex cursor-pointer items-center gap-2">
334 <Checkbox name="enabled" defaultChecked={r.enabled} />
335 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>
345 <SubmitButton variant="quiet" match={{ intent: "ai-reload" }} pending="Saving…">
346 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">
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)}%` }} />
379 {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) => (
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)} />
393 {level}%
394 </label>
395 ))}
396 </fieldset>
397 <label className="flex cursor-pointer flex-wrap items-center gap-2">
398 <Checkbox name="pause" defaultChecked={pause} />
399 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>
406 <SubmitButton variant="quiet" match={{ intent: "budget" }} pending="Saving…">
407 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">
433 <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…">
434 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,
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",
479 on: security.on,
480 label: security.on ? "On" : "Needs the plan",
481 control: null,
482 });
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>
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>
505 {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
538/** 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
546const 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 (
564 <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 )}
582 <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">
607 <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 />
614 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
615 </span>
616 {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 )}
619 </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>
626 <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 />
633 </label>
634 {!disabled && (
635 <div className="flex items-end">
636 <SubmitButton variant="quiet" match={{ intent: "details" }} pending="Saving…">
637 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>
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"}`}>
670 {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}