Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

520 linesCodeBlameRaw
1/**
2 * Billing settings' newer sections: the plan at a glance with the upcoming
3 * invoice, the budget's alerts, add-ons, and Billing on Stripe. The card,
4 * billing address, tax IDs, invoices and receipts, and ending the plan, are
5 * all managed on Stripe's own billing page (the customer portal): g1t has
6 * no form for any of them. Each form posts to the billing route's action
7 * with an `intent`.
8 */
9import { ArrowUpRight, Bell, CreditCard, FileText, Mail, Sparkles } from "lucide-react";
10import type { ReactNode } from "react";
11import { Form, Link } from "react-router";
12
13import type { BillingDetails, ComplimentaryMonth, FeatureState, Limit, UsageReport } from "@g1t/contracts";
14
15import type { PlanStatus } from "../lib/billing";
16import { money, wholeDollars } from "../lib/money";
17import { Card } from "./billing";
18import { ErrorText, SubmitButton } from "./ui";
19import { Checkbox } from "./ui/checkbox";
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
25// --- Complimentary ------------------------------------------------------------------------------
26
27/**
28 * A complimentary enrollment's month: the allowance g1t gives, what is used,
29 * metered and held against it, what is left and when it starts again. Past
30 * it, new work that costs money stops: nothing is charged, card or not. An
31 * owner can start the plan on the same billing from here.
32 */
33export function ComplimentaryCard({ month, owner, enabled, error }: { month: ComplimentaryMonth; owner: boolean; enabled: boolean; error?: string }) {
34 const taken = month.usedMicros + (month.pendingMicros ?? 0) + (month.heldMicros ?? 0);
35 const resets = new Date(month.resetsAt).toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" });
36 const charged = Boolean(month.chargePastAllowance);
37 const used = month.unlimited ? 0 : Math.min(100, (taken / Math.max(1, month.allowanceMicros)) * 100);
38 return (
39 <Card
40 id="complimentary"
41 tone={!month.unlimited && month.remainingMicros <= 0 ? "warn" : "accent"}
42 icon={<Sparkles size={16} />}
43 title="Complimentary allowance"
44 about={
45 charged
46 ? `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, usage is charged to this workspace's card at the same prices, up to your spend limit. Unused allowance does not carry over.`
47 : `Usage at its ordinary price, paid by g1t up to the allowance each calendar month (UTC). Past it, new work that costs money stops until ${resets}; nothing is ever charged for it, and unused allowance does not carry over.`
48 }
49 aside={
50 <p className="shrink-0 sm:text-right">
51 <span className="text-2xl font-semibold tabular-nums tracking-tight">{month.unlimited ? "Unlimited" : money(month.allowanceMicros)}</span>
52 {!month.unlimited && <span className="text-sm text-muted"> / month</span>}
53 <span className="block text-xs text-faint">{month.feeCents ? `Fee ${money(month.feeCents * 10_000)} a month, invoiced` : "No monthly fee"}</span>
54 </p>
55 }
56 >
57 {!month.unlimited && (
58 <div className="mt-4">
59 <div className="h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
60 <div className={`h-full rounded-full ${month.remainingMicros <= 0 ? "bg-warn" : "bg-accent"}`} style={{ width: `${used}%` }} />
61 </div>
62 </div>
63 )}
64 <dl className="mt-3 grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
65 <div>
66 <dt className="text-xs text-faint">Used</dt>
67 <dd className="tabular-nums">{money(month.usedMicros)}</dd>
68 </div>
69 <div>
70 <dt className="text-xs text-faint">Metered, not billed yet</dt>
71 <dd className="tabular-nums">{money((month.pendingMicros ?? 0) + (month.heldMicros ?? 0))}</dd>
72 </div>
73 <div>
74 <dt className="text-xs text-faint">Remaining</dt>
75 <dd className="tabular-nums">{month.unlimited ? "Unlimited" : money(month.remainingMicros)}</dd>
76 </div>
77 <div>
78 <dt className="text-xs text-faint">Starts again</dt>
79 <dd>{resets}</dd>
80 </div>
81 </dl>
82 {charged && !month.hasCard && (
83 <p className="mt-4 text-sm text-muted">
84 No card yet: past the allowance, work stops until one is added.{" "}
85 {owner && enabled && (
86 <Form method="post" className="inline">
87 <SubmitButton variant="outline" name="intent" value="card-check" pending="Opening Stripe…">
88 <CreditCard size={14} />
89 Add a card
90 </SubmitButton>
91 </Form>
92 )}
93 </p>
94 )}
95 {owner && enabled && !month.unlimited && (
96 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
97 <SubmitButton variant="outline" name="intent" value="convert" pending="Starting the plan…">
98 <CreditCard size={14} />
99 Start the g1t plan instead
100 </SubmitButton>
101 <span className="text-xs text-faint">
102 $21 a month with usage past what is included charged, on this workspace's billing. Nothing changes until the first payment goes through.
103 </span>
104 </Form>
105 )}
106 {error && (
107 <div className="mt-2">
108 <ErrorText>{error}</ErrorText>
109 </div>
110 )}
111 </Card>
112 );
113}
114
115// --- The plan ---------------------------------------------------------------------------------
116
117/**
118 * The plan at a glance: its name and standing, the billing period, the
119 * included usage, and the upcoming invoice, from g1t's own ledger.
120 */
121export function PlanSummary({
122 slug,
123 state,
124 status,
125 details,
126 report,
127 owner,
128 enabled,
129 live,
130 staff,
131 firstMonthMicros,
132 error,
133}: {
134 slug: string;
135 state: FeatureState | null;
136 status: PlanStatus;
137 details: BillingDetails | null;
138 report: UsageReport | null;
139 owner: boolean;
140 enabled: boolean;
141 /** False while Stripe is in test mode, where cards are not real. */
142 live: boolean;
143 staff: boolean;
144 /** The first month's limit on usage not yet paid for, from the price book. */
145 firstMonthMicros?: number | null;
146 error?: string;
147}) {
148 const plan = state?.plan;
149 const subscription = state?.subscription ?? null;
150 const on = ["paid", "canceling", "comped", "enterprise", "past_due", "complimentary"].includes(status.kind);
151 const now = new Date();
152 const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
153 const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0));
154 const period = `${start.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`;
155 const upcoming = details?.upcoming;
156 return (
157 <Card
158 id="plan"
159 tone={status.kind === "past_due" || status.kind === "canceling" ? "warn" : on ? "accent" : "plain"}
160 icon={<Sparkles size={16} />}
161 title={
162 <>
163 {plan?.title ?? "g1t"}
164 <span
165 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"}`}
166 >
167 {status.kind === "canceling" && subscription?.periodEnd ? `Ends on ${endsOn(subscription.periodEnd)}` : status.label}
168 </span>
169 </>
170 }
171 about={
172 on
173 ? `Usage month ${period}${subscription?.periodEnd ? `; the plan renews ${endsOn(subscription.periodEnd)}` : ""}. One price for the workspace, never per seat.`
174 : "The forge is free: repositories, work items, pull requests, reviews, search and the audit log. Checks, workflows and deployments need the plan, or the trial."
175 }
176 aside={
177 <p className="shrink-0 sm:text-right">
178 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2100) / 100).toFixed(0)}</span>
179 <span className="text-sm text-muted"> / month</span>
180 <span className="block text-xs text-faint">with {wholeDollars(report?.included?.of ?? 10_000_000)} of usage included</span>
181 {status.kind !== "comped" && status.kind !== "enterprise" && status.kind !== "complimentary" && (
182 <span className="block text-xs text-faint">Plus tax where it applies</span>
183 )}
184 </p>
185 }
186 >
187 <div className="mt-4 grid gap-4 sm:grid-cols-2">
188 {report?.included ? (
189 <div className="rounded-lg border border-line bg-bg/40 p-3">
190 <p className="flex justify-between text-sm">
191 <span className="text-muted">Included usage</span>
192 <span className="tabular-nums">
193 {money(report.included.used)} <span className="text-faint">/ {money(report.included.of)}</span>
194 </span>
195 </p>
196 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
197 <div
198 className="h-full rounded-full bg-accent"
199 style={{ width: `${Math.min(100, (report.included.used / Math.max(1, report.included.of)) * 100)}%` }}
200 />
201 </div>
202 <p className="mt-1.5 text-xs text-faint">Used first; starts again on the 1st. Unused usage does not carry over.</p>
203 </div>
204 ) : (
205 <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm">
206 <p className="text-muted">Usage at price this month</p>
207 <p className="mt-1 text-lg font-semibold tabular-nums">{report ? money(report.totals.priceMicros) : "—"}</p>
208 {(report?.discountPercent ?? 0) > 0 && report && (
209 <p className="text-xs text-faint">
210 Discount ({report.discountPercent}%) −{money(report.totals.discountMicros)} · charged {money(report.totals.chargedMicros)}
211 </p>
212 )}
213 </div>
214 )}
215 <div className="rounded-lg border border-line bg-bg/40 p-3 text-sm">
216 <p className="flex justify-between">
217 <span className="text-muted">Upcoming invoice</span>
218 <span className="text-lg font-semibold tabular-nums">{upcoming ? money(upcoming.totalMicros) : "—"}</span>
219 </p>
220 <p className="text-xs text-faint">
221 Including add-ons and usage, from g1t's ledger
222 {upcoming
223 ? `; the month closes ${new Date(upcoming.closesAt).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}`
224 : ""}
225 .
226 </p>
227 {upcoming && (
228 <details className="mt-1.5">
229 <summary className="cursor-pointer text-xs text-muted hover:text-fg">View upcoming invoice</summary>
230 <dl className="mt-1.5 space-y-1 text-xs">
231 <div className="flex justify-between">
232 <dt className="text-muted">Plan and add-ons</dt>
233 <dd className="tabular-nums">{money(upcoming.subscriptionsMicros)}</dd>
234 </div>
235 <div className="flex justify-between">
236 <dt className="text-muted">Usage owed, after included usage, credit and discount</dt>
237 <dd className="tabular-nums">{money(upcoming.usageMicros)}</dd>
238 </div>
239 </dl>
240 </details>
241 )}
242 </div>
243 </div>
244 <div className="mt-4 flex flex-wrap items-center gap-2">
245 <Link
246 to={`/${slug}/-/usage`}
247 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"
248 >
249 <FileText size={14} /> Usage
250 </Link>
251 {owner && enabled && (status.kind === "free" || status.kind === "trial") && (
252 <Form method="post" className="contents">
253 <input type="hidden" name="feature" value="plan" />
254 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
255 <CreditCard size={14} />
256 Start the g1t plan
257 </SubmitButton>
258 </Form>
259 )}
260 {owner && enabled && subscription && (status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
261 // Ending the plan, keeping it after all, and the card it renews on
262 // are all on Stripe's billing page.
263 <Form method="post" className="contents">
264 <input type="hidden" name="from" value="plan" />
265 <SubmitButton variant={status.kind === "paid" ? "outline" : "accent"} name="intent" value="portal" pending="Opening Stripe…">
266 {status.kind === "past_due" ? <CreditCard size={14} /> : null}
267 {status.kind === "past_due" ? "Update payment method" : "Manage billing"}
268 <ArrowUpRight size={14} />
269 </SubmitButton>
270 </Form>
271 )}
272 <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">
273 <Mail size={14} /> Custom needs? Contact us
274 </a>
275 </div>
276 {owner && enabled && subscription && status.kind === "paid" && (
277 <p className="mt-3 text-xs text-faint">To end the plan at the end of the period, use Manage billing. Nothing more is charged for it after that.</p>
278 )}
279 {status.kind === "canceling" && (
280 <p className="mt-3 text-sm text-muted">
281 The plan ends at the end of the period and nothing more is charged for it.{" "}
282 {owner ? "To keep it, use Manage billing." : "An owner or billing manager can keep it from Manage billing."}
283 </p>
284 )}
285 {status.kind === "past_due" && (
286 <p className="mt-3 text-sm text-warn">
287 The last renewal payment failed, so the plan's features are stopped until it is paid.{" "}
288 {owner ? "Update the payment method, and Stripe tries the payment again." : "An owner or billing manager can update the payment method."}
289 </p>
290 )}
291 {staff && enabled && !live && (status.kind === "free" || status.kind === "trial") && (
292 <p className="mt-3 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>
293 )}
294 {(status.kind === "free" || status.kind === "trial") && (
295 <p className="mt-3 text-xs text-faint">
296 In its first month on the plan, a workspace can owe at most {wholeDollars(firstMonthMicros ?? 100_000_000)} of usage not yet paid for, past what is
297 included, so a stolen card cannot run up a bill. Work waits at whichever comes first: that limit or your spend limit. After the first month, the limit
298 grows as payments clear.
299 </p>
300 )}
301 <ErrorText>{error}</ErrorText>
302 </Card>
303 );
304}
305
306/** A plan's last day as people read it: "Nov 12, 2026". */
307export function endsOn(periodEnd: string): string {
308 return new Date(periodEnd).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
309}
310
311// --- Budget alerts --------------------------------------------------------------------------------
312
313/** The budget's alerts, whether it pauses usage, and its webhook: beside the spend limit. */
314export function BudgetAlerts({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
315 const levels = limit.alertLevels ?? [100, 90, 75, 50];
316 const pause = limit.pauseAtLimit ?? true;
317 const spent = limit.spentMicros ?? 0;
318 const of = limit.spendLimitMicros;
319 return (
320 <Card
321 id="budget"
322 icon={<Bell size={16} />}
323 title="Budget alerts"
324 about="On usage past what the plan includes, measured against the spend limit above. Each alert is emailed to the owners once a month."
325 >
326 {of != null && (
327 <div className="mt-3">
328 <div className="flex justify-between text-sm">
329 <span className="text-muted">Spent this month</span>
330 <span className="tabular-nums">
331 {money(spent)} <span className="text-faint">of {money(of)}</span>
332 </span>
333 </div>
334 <div className="relative mt-1.5 h-1.5 rounded-full bg-line" role="presentation">
335 <div
336 className={`h-full rounded-full ${spent >= of ? "bg-danger" : spent * 4 >= of * 3 ? "bg-warn" : "bg-success"}`}
337 style={{ width: `${Math.min(100, (spent / Math.max(1, of)) * 100)}%` }}
338 />
339 {levels
340 .filter((l) => l < 100)
341 .map((l) => (
342 <span key={l} className="absolute -top-0.5 h-2.5 w-px bg-fg/40" style={{ left: `${l}%` }} />
343 ))}
344 </div>
345 </div>
346 )}
347 {owner ? (
348 <Form method="post" className="mt-4 space-y-3 text-sm">
349 <input type="hidden" name="intent" value="budget" />
350 <fieldset className="flex flex-wrap items-center gap-3">
351 <legend className="mb-1.5 text-xs text-muted">Alert at</legend>
352 {[50, 75, 90, 100].map((level) => (
353 <label key={level} className="flex cursor-pointer items-center gap-1.5 tabular-nums">
354 <Checkbox name="alert" value={String(level)} defaultChecked={levels.includes(level)} />
355 {level}%
356 </label>
357 ))}
358 </fieldset>
359 <label className="flex cursor-pointer flex-wrap items-center gap-2">
360 <Checkbox name="pause" defaultChecked={pause} />
361 Pause usage at 100%
362 <span className="text-xs text-faint">Off, the budget only alerts; g1t's own limit still applies.</span>
363 </label>
364 <label className="block">
365 <span className="block text-xs text-muted">Webhook, told of each alert (optional)</span>
366 <input
367 name="webhook"
368 type="url"
369 placeholder="https://hooks.example.com/g1t"
370 defaultValue={limit.budgetWebhook ?? ""}
371 className={`mt-1 ${FIELD} max-w-md`}
372 />
373 </label>
374 <SubmitButton variant="outline" match={{ intent: "budget" }} pending="Saving…">
375 Save alerts
376 </SubmitButton>
377 </Form>
378 ) : (
379 <p className="mt-3 text-sm text-muted">
380 Alerts at {levels.map((l) => `${l}%`).join(", ") || "none"}; usage {pause ? "pauses" : "does not pause"} at 100%.
381 </p>
382 )}
383 <ErrorText>{error}</ErrorText>
384 </Card>
385 );
386}
387
388// --- Billing on Stripe ---------------------------------------------------------------------------
389
390/**
391 * Billing on Stripe: the saved card, read-only, and one way to Stripe's
392 * billing page, where the card, the billing address, tax IDs, invoices and
393 * receipts are managed. Owners and billing managers only.
394 */
395export function BillingOnStripeCard({
396 details,
397 enabled,
398 live,
399 staff,
400 error,
401}: {
402 details: BillingDetails | null;
403 enabled: boolean;
404 /** False while Stripe is in test mode, where cards are not real. */
405 live: boolean;
406 staff: boolean;
407 error?: string;
408}) {
409 const method = details?.paymentMethod;
410 return (
411 <Card
412 id="stripe"
413 icon={<CreditCard size={16} />}
414 title="Billing on Stripe"
415 about="Your card, billing address, tax IDs, invoices and receipts are kept on Stripe's billing page. g1t never sees card numbers."
416 aside={
417 enabled ? (
418 <Form method="post">
419 <SubmitButton variant="outline" name="intent" value="portal" pending="Opening Stripe…">
420 Manage billing
421 <ArrowUpRight size={14} />
422 </SubmitButton>
423 </Form>
424 ) : null
425 }
426 >
427 <div className="mt-3 text-sm">
428 {method ? (
429 <p className="flex flex-wrap items-center gap-2">
430 <span className="rounded border border-line px-1.5 py-0.5 text-xs uppercase tracking-wide text-muted">{method.brand ?? method.kind}</span>
431 {method.last4 && (
432 <span>
433 ending <span className="font-mono">{method.last4}</span>
434 </span>
435 )}
436 {method.expMonth && method.expYear && (
437 <span className="text-muted">
438 · expires {String(method.expMonth).padStart(2, "0")}/{method.expYear}
439 </span>
440 )}
441 </p>
442 ) : (
443 <p className="text-muted">No card yet. Starting the plan saves the card you pay with.</p>
444 )}
445 {details?.unavailable && <p className="mt-2 text-xs text-warn">{details.unavailable}</p>}
446 {staff && !live && <p className="mt-2 text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
447 </div>
448 <ErrorText>{error}</ErrorText>
449 </Card>
450 );
451}
452
453// --- Add-ons ------------------------------------------------------------------------------------
454
455export function AddOns({ plan, security, error }: { plan: FeatureState | null; security: FeatureState | null; error?: string }) {
456 const planOn = !!plan?.on;
457 const rows: { key: string; name: string; about: string; price: string; on: boolean; label: string; control: ReactNode }[] = [];
458 if (security) {
459 rows.push({
460 key: "security",
461 name: security.plan.title,
462 about:
463 "Custom patterns, validity checks, code scanning and dependency review on private repositories. No price of its own: scans are charged at cost plus 20%.",
464 price: "With the plan",
465 on: security.on,
466 label: security.on ? "On" : "Needs the plan",
467 control: null,
468 });
469 }
470 rows.push({
471 key: "deployments",
472 name: "Production deployments",
473 about: "Previews per pull request and production on g1t.page, metered at cost plus 20%.",
474 price: "With the plan",
475 on: planOn,
476 label: planOn ? "On" : "Needs the plan",
477 control: null,
478 });
479 return (
480 <section id="add-ons" className="mb-6 scroll-mt-20 overflow-hidden rounded-xl border border-line bg-surface">
481 <h2 className="px-5 pt-5 font-medium">Add-ons</h2>
482 <ul className="mt-3 divide-y divide-line border-t border-line">
483 {rows.map((row) => (
484 <li key={row.key} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 text-sm">
485 <div className="min-w-0 flex-1 basis-60">
486 <p className="font-medium">{row.name}</p>
487 <p className="text-xs text-muted">{row.about}</p>
488 </div>
489 <span className="tabular-nums text-muted">{row.price}</span>
490 <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>
491 {row.control}
492 </li>
493 ))}
494 </ul>
495 {error && (
496 <div className="px-5 pb-4">
497 <ErrorText>{error}</ErrorText>
498 </div>
499 )}
500 </section>
501 );
502}
503
504/** Billing on Stripe while Stripe is asked: the same shape. */
505export function StripeSkeleton() {
506 return (
507 <div aria-busy="true">
508 <span role="status" className="sr-only">
509 Loading from Stripe…
510 </span>
511 {[5.5].map((rem, i) => (
512 <div key={i} className="mb-6 rounded-xl border border-line bg-surface p-5" aria-hidden="true" style={{ minHeight: `${rem}rem` }}>
513 <Skeleton className="h-4 w-36" />
514 <Skeleton className="mt-3 h-3 w-72 max-w-full" />
515 <Skeleton className="mt-4 h-8 w-full" />
516 </div>
517 ))}
518 </div>
519 );
520}