Skip to content

g1t/apps/web/app/components/billing-settings.tsx

678 lines33,572 bytesCodeBlame

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