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.

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