Skip to content
729 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 { SelectField } from "./ui/select";
20import { 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"}
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"}`}>
91 {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>
106 {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 )}
112 </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;
213 // 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 };
228 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 ? (
259 <Form method="post" className="mt-4" onChange={(event) => choose(event.currentTarget)}>
260 <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>
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>
323 </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">
333 <input type="checkbox" name="enabled" defaultChecked={r.enabled} className="accent-[var(--g1t-accent)]" />
334 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">
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)}%` }} />
378 {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">
391 <input type="checkbox" name="alert" value={String(level)} defaultChecked={levels.includes(level)} className="accent-[var(--g1t-accent)]" />
392 {level}%
393 </label>
394 ))}
395 </fieldset>
396 <label className="flex items-center gap-2">
397 <input type="checkbox" name="pause" defaultChecked={pause} className="accent-[var(--g1t-accent)]" />
398 Pause usage at 100%
399 <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span>
400 </label>
401 <label className="block">
402 <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span>
403 <input name="webhook" type="url" placeholder="https://hooks.example.com/g1t" defaultValue={limit.budgetWebhook ?? ""} className={`mt-1 ${FIELD} max-w-md`} />
404 </label>
405 <SubmitButton variant="quiet" match={{ intent: "budget" }} pending="Saving…">
406 Save alerts
407 </SubmitButton>
408 </Form>
409 ) : (
410 <p className="mt-3 text-sm text-muted">
411 Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%.
412 </p>
413 )}
414 <ErrorText>{error}</ErrorText>
415 </Card>
416 );
417}
418
419// --- Payment method -------------------------------------------------------------------------------
420
421export function PaymentMethodCard({ details, owner, enabled, staff, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; staff: boolean; error?: string }) {
422 const method = details?.paymentMethod;
423 return (
424 <Card
425 id="payment"
426 icon={<CreditCard size={16} />}
427 title="Payment method"
428 about="Cards are added, removed and made the default on Stripe's billing page. g1t never sees card numbers."
429 aside={
430 owner && enabled ? (
431 <Form method="post">
432 <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…">
433 Manage in Stripe
434 <ArrowUpRight size={14} />
435 </SubmitButton>
436 </Form>
437 ) : null
438 }
439 >
440 <div className="mt-3 text-sm">
441 {method ? (
442 <p className="flex flex-wrap items-center gap-2">
443 <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span>
444 {method.last4 && (
445 <span>
446 ending <span className="font-mono">{method.last4}</span>
447 </span>
448 )}
449 {method.expMonth && method.expYear && (
450 <span className="text-muted">
451 · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear}
452 </span>
453 )}
454 <span className="text-xs text-faint">default</span>
455 </p>
456 ) : (
457 <p className="text-muted">No payment method yet. Starting the plan or buying AI credit saves the card you pay with.</p>
458 )}
459 {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>}
460 {staff && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
461 </div>
462 <ErrorText>{error}</ErrorText>
463 </Card>
464 );
465}
466
467// --- Add-ons ------------------------------------------------------------------------------------
468
469export function AddOns({ plan, security, owner, enabled, error }: { plan: FeatureState | null; security: FeatureState | null; owner: boolean; enabled: boolean; error?: string }) {
470 const planOn = !!plan?.on;
471 const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = [];
472 if (security) {
473 const status = security.subscription?.status;
474 rows.push({
475 key: "security",
476 name: security.plan.title,
477 about: `Custom patterns, validity checks, code scanning and dependency review on private repositories. ${
478 security.plan.cardFeeCents ? `Plus a ${dollars(security.plan.cardFeeCents * 10_000)} card processing fee a month, and tax where it applies.` : "Plus tax where it applies."
479 }`,
480 price: `$${(security.plan.monthlyCents / 100).toFixed(security.plan.monthlyCents % 100 ? 2 : 0)} / month`,
481 on: security.on,
482 label: security.included ? "Included" : status === "canceling" ? "Ends at the period's end" : security.on ? "On" : "Off",
483 control:
484 !owner || !enabled || security.included ? null : (
485 <Form method="post">
486 <input type="hidden" name="feature" value="security" />
487 {!security.on ? (
488 <SubmitButton variant="quiet" name="intent" value="subscribe" pending="Opening Stripe…">
489 Turn on
490 </SubmitButton>
491 ) : status === "canceling" ? (
492 <SubmitButton variant="quiet" name="intent" value="resume" pending="Saving…">
493 Keep it on
494 </SubmitButton>
495 ) : (
496 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
497 Turn off
498 </SubmitButton>
499 )}
500 </Form>
501 ),
502 });
503 }
504 rows.push({
505 key: "deployments",
506 name: "Production deployments",
507 about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.",
508 price: "With the plan",
509 on: planOn,
510 label: planOn ? "On" : "Needs the plan",
511 control: null,
512 });
513 return (
514 <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
515 <h2 className="px-5 pt-5 font-medium">Add-ons</h2>
516 <ul className="mt-3 divide-y divide-line border-t border-line">
517 {rows.map((row) => (
518 <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm">
519 <div className="min-w-0 flex-1 basis-60">
520 <p className="font-medium">{row.name}</p>
521 <p className="text-xs text-muted">{row.about}</p>
522 </div>
523 <span className="tabular-nums text-muted">{row.price}</span>
524 <span className={`rounded-full px-2 py-0.5 text-xs ${row.on ? "bg-success/15 text-success" : "border border-line text-muted"}`}>{row.label}</span>
525 {row.control}
526 </li>
527 ))}
528 </ul>
529 {error && (
530 <div className="px-5 pb-4">
531 <ErrorText>{error}</ErrorText>
532 </div>
533 )}
534 </section>
535 );
536}
537
538// --- Invoice details ------------------------------------------------------------------------------
539
540const TAX_IDS: [string, string][] = [
541 ["", "None"],
542 ["eu_vat", "EU VAT"],
543 ["gb_vat", "UK VAT"],
544 ["us_ein", "US EIN"],
545 ["ca_bn", "Canadian BN"],
546 ["au_abn", "Australian ABN"],
547 ["ch_vat", "Swiss VAT"],
548 ["no_vat", "Norwegian VAT"],
549 ["in_gst", "Indian GST"],
550 ["jp_cn", "Japanese corporate number"],
551 ["br_cnpj", "Brazilian CNPJ"],
552 ["mx_rfc", "Mexican RFC"],
553 ["sg_uen", "Singapore UEN"],
554 ["nz_gst", "New Zealand GST"],
555 ["za_vat", "South African VAT"],
556];
557
558/** What Stripe's check of a tax ID found. */
559const TAX_ID_STATUS: Record<string, string> = {
560 verified: "Verified by Stripe.",
561 pending: "Stripe is checking it.",
562 unverified: "Stripe could not verify it. Check the number.",
563 unavailable: "Stripe cannot check this kind of ID.",
564};
565
566const LANGUAGES: [string, string][] = [
567 ["", "Automatic"],
568 ["en", "English"],
569 ["en-GB", "English (UK)"],
570 ["de", "German"],
571 ["fr", "French"],
572 ["es", "Spanish"],
573 ["it", "Italian"],
574 ["nl", "Dutch"],
575 ["pt-BR", "Portuguese (Brazil)"],
576 ["ja", "Japanese"],
577 ["zh", "Chinese"],
578];
579
580export function InvoiceDetailsCard({ details, owner, enabled, error }: { details: BillingDetails | null; owner: boolean; enabled: boolean; error?: string }) {
581 const a = details?.address;
582 const disabled = !owner || !enabled;
583 return (
584 <Card
585 id="details"
586 icon={<ReceiptText size={16} />}
587 title="Invoice details"
588 about="Who invoices are for. Kept on the workspace's Stripe customer and printed on every invoice. Tax is worked out from the address."
589 >
590 {details?.customer && !details.taxLocation && (
591 <p className={`mt-3 rounded-lg border px-3 py-2 text-sm ${details.taxAddressNeededAt ? "border-warn/40 bg-warn/5" : "border-line bg-bg/40 text-muted"}`}>
592 {details.taxAddressNeededAt
593 ? "Add the billing address: Stripe needs at least the country (and in the US the ZIP code) to work out tax, so g1t is not charging the card until it is here."
594 : "No billing address yet. Stripe needs at least the country (and in the US the ZIP code) to work out tax before g1t charges the card."}
595 </p>
596 )}
597 {(details?.taxExempt === "exempt" || details?.taxExempt === "reverse") && (
598 <p className="mt-3 text-xs text-muted">
599 {details.taxExempt === "exempt" ? "Tax exempt: no tax is added." : "Reverse charge: you account for the tax yourself, and invoices say so."}
600 </p>
601 )}
602 <Form method="post" className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
603 <input type="hidden" name="intent" value="details" />
604 <fieldset disabled={disabled} className="contents">
605 <label className="block">
606 <span className="text-xs text-muted">Invoice email</span>
607 <input name="email" type="email" defaultValue={details?.email ?? ""} className={`mt-1 ${FIELD}`} />
608 </label>
609 <label className="block">
610 <span className="text-xs text-muted">Company name</span>
611 <input name="name" defaultValue={details?.name ?? ""} className={`mt-1 ${FIELD}`} />
612 </label>
613 <label className="block sm:col-span-2">
614 <span className="text-xs text-muted">Address</span>
615 <input name="line1" placeholder="Street" defaultValue={a?.line1 ?? ""} className={`mt-1 ${FIELD}`} />
616 <input name="line2" placeholder="Suite, floor (optional)" aria-label="Address line 2" defaultValue={a?.line2 ?? ""} className={`mt-2 ${FIELD}`} />
617 </label>
618 <div className="grid grid-cols-2 gap-3 sm:col-span-2 sm:grid-cols-4">
619 <input name="city" placeholder="City" aria-label="City" defaultValue={a?.city ?? ""} className={FIELD} />
620 <input name="state" placeholder="State or region" aria-label="State or region" defaultValue={a?.state ?? ""} className={FIELD} />
621 <input name="postalCode" placeholder="Postal code" aria-label="Postal code" defaultValue={a?.postalCode ?? ""} className={FIELD} />
622 <input name="country" placeholder="Country (US)" aria-label="Country, two letters" maxLength={2} defaultValue={a?.country ?? ""} className={`${FIELD} uppercase`} />
623 </div>
624 <label className="block">
625 <span className="text-xs text-muted">Tax ID</span>
626 <span className="mt-1 flex gap-2">
627 <SelectField
628 name="taxIdType"
629 defaultValue={details?.taxIdType ?? ""}
630 aria-label="Kind of tax ID"
631 className="h-auto w-auto shrink-0 px-2.5 py-1.5"
632 options={TAX_IDS.map(([value, label]) => ({ value, label }))}
633 />
634 <input name="taxId" defaultValue={details?.taxId ?? ""} aria-label="Tax ID" className={FIELD} />
635 </span>
636 {details?.taxId && details.taxIdStatus && (
637 <span className="mt-1 block text-xs text-faint">{TAX_ID_STATUS[details.taxIdStatus] ?? `Stripe's check: ${details.taxIdStatus}`}</span>
638 )}
639 </label>
640 <label className="block">
641 <span className="text-xs text-muted">Purchase order</span>
642 <input name="poNumber" defaultValue={details?.poNumber ?? ""} className={`mt-1 ${FIELD}`} />
643 </label>
644 <label className="block">
645 <span className="text-xs text-muted">Invoice language</span>
646 <SelectField
647 name="language"
648 defaultValue={details?.language ?? ""}
649 aria-label="Invoice language"
650 className="mt-1 h-auto px-2.5 py-1.5"
651 options={LANGUAGES.map(([value, label]) => ({ value, label }))}
652 />
653 </label>
654 {!disabled && (
655 <div className="flex items-end">
656 <SubmitButton variant="quiet" match={{ intent: "details" }} pending="Saving…">
657 Save invoice details
658 </SubmitButton>
659 </div>
660 )}
661 </fieldset>
662 </Form>
663 {!owner && <p className="mt-3 text-xs text-faint">An owner changes these.</p>}
664 <ErrorText>{error}</ErrorText>
665 </Card>
666 );
667}
668
669// --- Invoices ---------------------------------------------------------------------------------------
670
671const STATUS: Record<string, string> = { paid: "Paid", open: "Open", void: "Void", uncollectible: "Uncollectible", draft: "Draft" };
672
673export function InvoicesCard({ details }: { details: BillingDetails | null }) {
674 const invoices = details?.invoices ?? [];
675 return (
676 <section id="invoices" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
677 <div className="px-5 pt-5">
678 <h2 className="font-medium">Invoices</h2>
679 <p className="mt-1 text-sm text-muted">The plan, add-ons, AI credit and each month's usage, as Stripe sent them.</p>
680 </div>
681 {invoices.length === 0 ? (
682 <p className="px-5 py-5 text-sm text-faint">No invoices yet.</p>
683 ) : (
684 <ul className="mt-3 divide-y divide-line border-t border-line">
685 {invoices.map((invoice) => (
686 <li key={invoice.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-5 py-2.5 text-sm">
687 <span className="w-24 shrink-0 text-muted tabular-nums">{new Date(invoice.createdAt).toLocaleDateString("en-US", { timeZone: "UTC" })}</span>
688 <span className="min-w-0 flex-1 truncate">{invoice.description ?? invoice.number ?? invoice.id}</span>
689 <span className={`rounded-full px-2 py-0.5 text-xs ${invoice.status === "paid" ? "bg-success/15 text-success" : invoice.status === "open" ? "bg-warn/15 text-warn" : "border border-line text-muted"}`}>
690 {STATUS[invoice.status] ?? invoice.status}
691 </span>
692 <span className="w-20 text-right tabular-nums">{money(invoice.totalCents * 10_000)}</span>
693 <span className="flex gap-3 text-xs">
694 {invoice.hostedUrl && (
695 <a href={invoice.hostedUrl} className="text-muted hover:text-fg">
696 View
697 </a>
698 )}
699 {invoice.pdfUrl && (
700 <a href={invoice.pdfUrl} className="text-muted hover:text-fg">
701 PDF
702 </a>
703 )}
704 </span>
705 </li>
706 ))}
707 </ul>
708 )}
709 </section>
710 );
711}
712
713/** Stripe's sections while Stripe is asked: the same shapes. */
714export function StripeSkeleton() {
715 return (
716 <div aria-busy="true">
717 <span role="status" className="sr-only">
718 Loading from Stripe…
719 </span>
720 {[5.5, 14, 8].map((rem, i) => (
721 <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}>
722 <Skeleton className="h-4 w-36" />
723 <Skeleton className="mt-3 h-3 w-72 max-w-full" />
724 <Skeleton className="mt-4 h-8 w-full" />
725 </div>
726 ))}
727 </div>
728 );
729}