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
461 linesCodeBlameRaw
1import { ArrowUpRight, CreditCard, FileText } from "lucide-react";
2import { Suspense } from "react";
3import { Await, Link, data, redirect } from "react-router";
4
5import { MICROS_PER_DOLLAR, type WorkspaceInvoice } from "@g1t/contracts";
6
7import type { Route } from "./+types/billing";
8import {
9 Alerts,
10 CreditsCard,
11 OverageCard,
12 PrepayCard,
13 RaiseCard,
14 type SectionError,
15 SpendLimitCard,
16 SpikeBanner,
17 TrialCard,
18} from "../../components/billing";
19import {
20 AddOns,
21 BudgetAlerts,
22 InvoiceDetailsCard,
23 InvoicesCard,
24 PaymentMethodCard,
25 PlanSummary,
26 StripeSkeleton,
27} from "../../components/billing-settings";
28import { StatementView } from "../../components/statement";
29import {
30 cardCheckResult,
31 parseBudgetAlerts,
32 parseInvoiceDetails,
33 parseLimitRequest,
34 parsePrepay,
35 parseSpendLimit,
36 planStatus,
37} from "../../lib/billing";
38import { money, wholeDollars } from "../../lib/money";
39import { isStaff } from "../../lib/usage";
40import { page } from "../../lib/meta";
41import { billing } from "../../lib/services.server";
42import { assertSameOrigin, getViewer, managesBilling, requireUser, roleIn, unwrap } from "../../lib/session.server";
43import { Card } from "../../components/ui/card";
44
45/** The trial and pools as published, when the price book cannot be read. */
46const DEFAULT_TRIAL_MICROS = 5_000_000;
47
48/** What a change says once it is done (`?done=`). */
49const DONE: Record<string, string> = {
50 subscribed: "The g1t plan is on.",
51 prepaid: "Payment received. Your prepaid balance and what you can use went up by as much.",
52 budget: "Budget alerts saved.",
53 details: "Invoice details saved on Stripe.",
54 limit: "Spend limit saved.",
55 requested: "Sent. g1t answers within one business day, here and by email.",
56 told: "Thanks. g1t looks at it and answers within one business day, here and by email.",
57 keep: "Compute is running again for 24 hours, unless spending doubles again first.",
58 stop: "New compute stays paused until an owner chooses Keep going.",
59 canceled: "The plan ends at the end of the period. Nothing more is charged for it.",
60 resumed: "The plan continues.",
61 security_on: "Security and quality is on for every private repository in the workspace.",
62 security_canceled: "Security and quality ends at the end of the period. Nothing more is charged for it.",
63 security_resumed: "Security and quality continues.",
64};
65
66export function meta({ params, ...args }: Route.MetaArgs) {
67 return page(args, { title: `Billing · ${params.owner} · g1t` });
68}
69
70export async function loader({ params, context, request }: Route.LoaderArgs) {
71 const viewer = getViewer(context);
72 const role = roleIn(viewer, params.owner);
73 // Members only; to anyone else the page does not exist.
74 if (!role) throw data(null, { status: 404 });
75 const slug = params.owner.toLowerCase();
76 const url = new URL(request.url);
77 const here = `/${slug}/-/billing`;
78
79 // Back from Stripe: record what happened, then drop the ids from the address.
80 const cardCheck = url.searchParams.get("card_check");
81 if (cardCheck) {
82 const checked = await billing.confirmCardCheck(slug, viewer, cardCheck);
83 throw redirect(checked.ok ? `${here}?checked=1#trial` : `${here}?problem=${encodeURIComponent(checked.error.message)}#trial`);
84 }
85 const session = url.searchParams.get("session");
86 if (session) {
87 if (url.searchParams.get("plan")) {
88 const started = await billing.confirmSubscription(slug, viewer, session);
89 const doneKey = url.searchParams.get("plan") === "security" ? "security_on" : "subscribed";
90 throw redirect(started.ok ? `${here}?done=${doneKey}` : `${here}?problem=${encodeURIComponent(started.error.message)}`);
91 }
92 const paid = await billing.confirm(slug, viewer, session);
93 throw redirect(paid.ok ? `${here}?done=prepaid#prepay` : `${here}?problem=${encodeURIComponent(paid.error.message)}#prepay`);
94 }
95 // A plan started on the card already saved comes straight back.
96 const plans = url.searchParams.getAll("plan");
97 if (plans.includes("started")) throw redirect(`${here}?done=${plans.includes("security") ? "security_on" : "subscribed"}`);
98
99 const now = new Date();
100 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
101 const today = now.toISOString().slice(0, 10);
102 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
103 // Stripe's own (payment method, invoice details, invoices) is streamed:
104 // the page does not wait on Stripe, and its sections hold their place.
105 const details = billing
106 .billingDetails(slug, viewer)
107 .then((result) => (result.ok ? result.value : null))
108 .catch(() => null);
109 const [account, statement, features, limit, invoices, entitlements, requests, book, credits, fees, report] = await Promise.all([
110 billing.account(slug, viewer),
111 billing.statement(slug, viewer, url.searchParams.get("month"), group),
112 billing.features(slug, viewer),
113 billing.limit(slug, viewer).catch(() => null),
114 billing.invoices(slug, viewer).catch(() => null),
115 billing.entitlements(slug).catch(() => null),
116 billing.limitRequests(slug, viewer).catch(() => null),
117 billing.prices().catch(() => null),
118 billing.credits(slug, viewer).catch(() => null),
119 // Only for the card processing fee Prepay shows before you pay.
120 billing.aiCredit(slug, viewer).catch(() => null),
121 billing.usageReport(slug, viewer, { from: monthStart, until: today }).catch(() => null),
122 ]);
123 const featureStates = unwrap(features);
124 const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS;
125 const done = url.searchParams.get("done");
126 // Credit from g1t for everything; grants scoped to models belong to usage no longer offered.
127 const general = credits?.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : [];
128 return {
129 slug,
130 role,
131 // Billing managers manage billing as owners do.
132 managesBilling: managesBilling(viewer, params.owner),
133 staff: isStaff(viewer),
134 account: unwrap(account),
135 statement: unwrap(statement),
136 group,
137 plan: featureStates.find((state) => state.plan.feature === "plan") ?? null,
138 securityPlan: featureStates.find((state) => state.plan.feature === "security") ?? null,
139 limit: limit?.ok ? limit.value : null,
140 invoices: invoices?.ok ? invoices.value : [],
141 entitlements,
142 requests: requests?.ok ? requests.value : [],
143 credits: general.length > 0 && credits?.ok ? { ...credits.value, grants: general } : null,
144 cardFee: fees?.ok ? fees.value.cardFee : null,
145 report: report?.ok ? report.value : null,
146 details,
147 trialMicros,
148 notice:
149 url.searchParams.has("checked") && entitlements
150 ? cardCheckResult(entitlements, trialMicros)
151 : done && DONE[done]
152 ? DONE[done]
153 : null,
154 problem: url.searchParams.get("problem"),
155 };
156}
157
158export async function action({ request, params, context }: Route.ActionArgs) {
159 assertSameOrigin(request);
160 const user = requireUser(context, request);
161 const form = await request.formData();
162 const slug = params.owner.toLowerCase();
163 // Spelled out: a form post arrives at a data address, not the page's.
164 const here = `${new URL(request.url).origin}/${slug}/-/billing`;
165 const intent = String(form.get("intent") ?? "");
166 const fail = (section: string, error: string) => data<SectionError>({ section, error }, { status: 422 });
167 const done = (key: string, anchor = "") => redirect(`/${slug}/-/billing?done=${key}${anchor}`);
168
169 switch (intent) {
170 case "portal": {
171 // Cards, payment methods and receipts live on Stripe's own page.
172 const started = await billing.billingPortal(user, slug, here);
173 if (!started.ok) return fail("payment", started.error.message);
174 throw redirect(started.value.url);
175 }
176 case "subscribe": {
177 // The plan. Security and quality comes with it: billing refuses it on its own.
178 const feature = form.get("feature") === "security" ? "security" : "plan";
179 const started = await billing.subscribe(user, slug, feature, `${here}?plan=${feature}`);
180 if (!started.ok) return fail(feature, started.error.message);
181 throw redirect(started.value.url);
182 }
183 case "cancel":
184 case "resume": {
185 const feature = form.get("feature") === "security" ? "security" : "plan";
186 const changed = await billing.cancelSubscription(user, slug, feature, intent === "resume");
187 if (!changed.ok) return fail(feature, changed.error.message);
188 const prefix = feature === "security" ? "security_" : "";
189 throw done(`${prefix}${intent === "resume" ? "resumed" : "canceled"}`);
190 }
191 case "card-check": {
192 const started = await billing.cardCheck(user, slug, here);
193 if (!started.ok) return fail("trial", started.error.message);
194 throw redirect(started.value.url);
195 }
196 case "budget": {
197 const parsed = parseBudgetAlerts({ alerts: form.getAll("alert"), pause: form.get("pause"), webhook: form.get("webhook") });
198 if (!parsed.ok) return fail("budget", parsed.error);
199 const set = await billing.setBudget(user, slug, { amountMicros: null, keepLimit: true, ...parsed.value });
200 if (!set.ok) return fail("budget", set.error.message);
201 throw done("budget", "#budget");
202 }
203 case "details": {
204 const parsed = parseInvoiceDetails(form);
205 if (!parsed.ok) return fail("details", parsed.error);
206 const saved = await billing.setBillingDetails(user, slug, parsed.value);
207 if (!saved.ok) return fail("details", saved.error.message);
208 throw done("details", "#details");
209 }
210 case "spend-limit": {
211 const parsed = parseSpendLimit({ mode: form.get("mode"), limit: form.get("limit") });
212 if (!parsed.ok) return fail("limit", parsed.error);
213 let micros = parsed.value.micros;
214 if (parsed.value.raiseOnce && micros == null) {
215 // The one-time raise with no amount: as high as it goes.
216 const limit = await billing.limit(slug, user);
217 micros = limit.ok ? (limit.value.raiseOnceMicros ?? null) : null;
218 if (micros == null) return fail("limit", "The one-time raise is not available. Prepay, or use Raise my limit.");
219 }
220 const set = await billing.setSpendLimit(user, slug, micros, parsed.value.useFull, parsed.value.raiseOnce);
221 if (!set.ok) return fail("limit", set.error.message);
222 throw done("limit", "#limit");
223 }
224 case "request": {
225 const parsed = parseLimitRequest({
226 kind: form.get("kind"),
227 amount: form.get("amount"),
228 reason: form.get("reason"),
229 expected: form.get("expected"),
230 });
231 const section = String(form.get("kind")) === "overage" ? "overage" : "raise";
232 if (!parsed.ok) return fail(section, parsed.error);
233 const sent = await billing.requestLimit(user, slug, parsed.value);
234 if (!sent.ok) return fail(section, sent.error.message);
235 throw done(section === "overage" ? "told" : "requested", `#${section}`);
236 }
237 case "spike": {
238 const keep = String(form.get("decision")) === "keep";
239 const answered = await billing.confirmSpike(user, slug, keep);
240 if (!answered.ok) return fail("spike", answered.error.message);
241 throw done(keep ? "keep" : "stop");
242 }
243 case "prepay": {
244 const parsed = parsePrepay({ amount: form.get("amount"), custom: form.get("custom"), method: form.get("method") });
245 if (!parsed.ok) return fail("prepay", parsed.error);
246 const started = await billing.checkout(user, slug, parsed.value.amountCents, here, parsed.value.method);
247 if (!started.ok) return fail("prepay", started.error.message);
248 throw redirect(started.value.url);
249 }
250 default:
251 return fail("plan", "Unknown action.");
252 }
253}
254
255export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
256 const { slug, role, staff, account, statement, group, plan, securityPlan, limit, invoices, entitlements, requests, credits, cardFee, report, details, trialMicros, notice, problem } =
257 loaderData;
258 const { status } = account;
259 const owner = role === "owner" || loaderData.managesBilling;
260 const error = actionData as SectionError;
261 const err = (section: string) => (error && error.section === section ? error.error : undefined);
262 const standing = planStatus(plan, entitlements);
263 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
264 const free = standing.kind === "free" || standing.kind === "trial";
265 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
266 // The spend limit and its budget are the owners' to set only where
267 // billing lets them: on the plan, never a free workspace's ceiling.
268 const selfServe = !!limit && (limit.trust === "paid" || limit.trust === "established");
269 const summary = (loaded: Awaited<typeof details>) => (
270 <PlanSummary slug={slug} state={plan} status={standing} details={loaded} report={report} owner={owner} enabled={status.enabled} staff={staff} error={err("plan")} />
271 );
272
273 return (
274 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
275 <div className="min-w-0">
276 {status.free && (
277 <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5">
278 <h2 className="font-medium">Free while g1t is being built out</h2>
279 <p className="mt-1.5 max-w-2xl text-sm text-muted">
280 Usage is recorded with what it costs, so Usage shows what you use, but nothing is charged for now. We will say so well
281 before anything is.
282 </p>
283 </div>
284 )}
285 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
286 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
287
288 <Suspense fallback={null}>
289 <Await resolve={details}>
290 {(loaded) =>
291 loaded?.taxAddressNeededAt ? (
292 <div role="alert" className="mb-6 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm">
293 <p className="font-medium">Add a billing address</p>
294 <p className="mt-1 text-muted">
295 Stripe needs it to work out tax, so g1t did not charge the card. Nothing is lost: the charge goes through once the
296 address is saved.{" "}
297 {owner ? (
298 <a href="#details" className="text-fg underline underline-offset-2">
299 Add it under Invoice details
300 </a>
301 ) : (
302 "An owner adds it under Invoice details."
303 )}
304 </p>
305 </div>
306 ) : null
307 }
308 </Await>
309 </Suspense>
310 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
311 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
312 <Alerts alerts={entitlements?.alerts ?? []} />
313
314 <Suspense fallback={summary(null)}>
315 <Await resolve={details}>{(loaded) => summary(loaded)}</Await>
316 </Suspense>
317
318 {free && status.enabled && (
319 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
320 )}
321
322 {paying && limit && selfServe && (
323 <>
324 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
325 <BudgetAlerts limit={limit} owner={owner} error={err("budget")} />
326 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
327 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} cardFee={cardFee} error={err("prepay")} />
328 </>
329 )}
330
331 {credits && <CreditsCard credits={credits} />}
332
333 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
334
335 <AddOns plan={plan} security={securityPlan} error={err("security")} />
336
337 {status.enabled && (
338 <Suspense fallback={<StripeSkeleton />}>
339 <Await resolve={details}>
340 {(loaded) => (
341 <>
342 <PaymentMethodCard details={loaded} owner={owner} enabled={status.enabled} staff={staff} error={err("payment")} />
343 <InvoiceDetailsCard details={loaded} owner={owner} enabled={status.enabled} error={err("details")} />
344 <InvoicesCard details={loaded} />
345 </>
346 )}
347 </Await>
348 </Suspense>
349 )}
350
351 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
352
353 <StatementView slug={slug} statement={statement} group={group} />
354 </div>
355
356 <aside className="space-y-5 text-sm">
357 <Card asChild className="p-5">
358 <section>
359 <h3 className="font-medium">How it is charged</h3>
360 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
361 <li>
362 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace, with $10 of usage
363 included. Usage is charged at what it costs g1t plus {account.marginPercent}%, from the first second, after what is
364 included.
365 </li>
366 <li>Credit from g1t comes off what you owe, the soonest-expiring first.</li>
367 <li>
368 Each month closes with an itemised invoice. No card is charged less than{" "}
369 {wholeDollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR)}; less carries over.
370 </li>
371 <li>
372 Prices exclude tax. Stripe adds tax where it applies, worked out from the billing address under Invoice details, and it
373 is its own line on every receipt and invoice.
374 </li>
375 <li>
376 Card payments carry Stripe's card processing fee as their own line, shown before you pay. Bank transfers and invoiced
377 billing have none.
378 </li>
379 <li>No seats: add as many people as you like, once the workspace is on the plan.</li>
380 </ul>
381 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
382 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
383 <FileText size={14} /> Usage, by product and project
384 </Link>
385 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
386 <CreditCard size={14} /> Pricing and today's prices
387 </Link>
388 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
389 <ArrowUpRight size={14} /> How usage and billing work
390 </a>
391 </div>
392 </section>
393 </Card>
394 </aside>
395 </div>
396 );
397}
398
399/** The workspace's month-end invoices from g1t, each kept on Stripe with its PDF. */
400function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
401 return (
402 <section className="mb-10">
403 <h2 className="font-medium">Usage invoices</h2>
404 <p className="mt-1 text-sm text-muted">
405 One when each month closes, and one each time g1t charges the card near your limit. Amounts are for usage; the card fee
406 and tax are their own lines.
407 </p>
408 <Card asChild tone="plain" divided className="mt-4 overflow-hidden">
409 <ul>
410 {invoices.map((invoice) => (
411 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
412 <div className="flex flex-wrap items-center gap-3">
413 <span className="font-medium">
414 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
415 </span>
416 <span
417 className={`rounded-full border px-2 py-0.5 text-xs ${
418 invoice.status === "paid" ? "border-success/40 text-success" : "border-danger/40 text-danger"
419 }`}
420 >
421 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
422 </span>
423 <span className="ml-auto font-mono tabular-nums">{money(invoice.amountMicros)}</span>
424 {invoice.hostedUrl && (
425 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
426 View
427 </a>
428 )}
429 {invoice.pdfUrl && (
430 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
431 PDF
432 </a>
433 )}
434 </div>
435 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
436 {invoice.lines.map((line) => (
437 <li key={line.description} className="flex justify-between gap-4">
438 <span>{line.description}</span>
439 <span className="font-mono tabular-nums">{money(line.amountMicros)}</span>
440 </li>
441 ))}
442 {(invoice.feeMicros ?? 0) > 0 && (
443 <li className="flex justify-between gap-4">
444 <span>Card processing fee</span>
445 <span className="font-mono tabular-nums">{money(invoice.feeMicros ?? 0)}</span>
446 </li>
447 )}
448 {(invoice.taxMicros ?? 0) > 0 && (
449 <li className="flex justify-between gap-4">
450 <span>Tax</span>
451 <span className="font-mono tabular-nums">{money(invoice.taxMicros ?? 0)}</span>
452 </li>
453 )}
454 </ul>
455 </li>
456 ))}
457 </ul>
458 </Card>
459 </section>
460 );
461}