Skip to content

g1t/apps/web/app/routes/workspace/billing.tsx

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