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