Skip to content

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

509 lines24,649 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, managesBilling, 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 // Billing managers manage billing as owners do.
144 managesBilling: managesBilling(viewer, params.owner),
145 staff: isStaff(viewer),
146 account: unwrap(account),
147 statement: unwrap(statement),
148 group,
149 plan: featureStates.find((state) => state.plan.feature === "plan") ?? null,
150 securityPlan: featureStates.find((state) => state.plan.feature === "security") ?? null,
151 limit: limit?.ok ? limit.value : null,
152 invoices: invoices?.ok ? invoices.value : [],
153 entitlements,
154 requests: requests?.ok ? requests.value : [],
155 credits: general.length > 0 && credits?.ok ? { ...credits.value, grants: general } : null,
156 ai: ai?.ok ? ai.value : null,
157 report: report?.ok ? report.value : null,
158 details,
159 trialMicros,
160 notice:
161 url.searchParams.has("checked") && entitlements
162 ? cardCheckResult(entitlements, trialMicros)
163 : done && DONE[done]
164 ? DONE[done]
165 : null,
166 problem: url.searchParams.get("problem"),
167 };
168}
169
170export async function action({ request, params, context }: Route.ActionArgs) {
171 assertSameOrigin(request);
172 const user = requireUser(context, request);
173 const form = await request.formData();
174 const slug = params.owner.toLowerCase();
175 // Spelled out: a form post arrives at a data address, not the page's.
176 const here = `${new URL(request.url).origin}/${slug}/-/billing`;
177 const intent = String(form.get("intent") ?? "");
178 const fail = (section: string, error: string) => data<SectionError>({ section, error }, { status: 422 });
179 const done = (key: string, anchor = "") => redirect(`/${slug}/-/billing?done=${key}${anchor}`);
180
181 switch (intent) {
182 case "portal": {
183 // Cards, payment methods and receipts live on Stripe's own page.
184 const started = await billing.billingPortal(user, slug, here);
185 if (!started.ok) return fail("payment", started.error.message);
186 throw redirect(started.value.url);
187 }
188 case "subscribe": {
189 // The plan, or the Security and quality activation.
190 const feature = form.get("feature") === "security" ? "security" : "plan";
191 const started = await billing.subscribe(user, slug, feature, `${here}?plan=${feature}`);
192 if (!started.ok) return fail(feature, started.error.message);
193 throw redirect(started.value.url);
194 }
195 case "cancel":
196 case "resume": {
197 const feature = form.get("feature") === "security" ? "security" : "plan";
198 const changed = await billing.cancelSubscription(user, slug, feature, intent === "resume");
199 if (!changed.ok) return fail(feature, changed.error.message);
200 const prefix = feature === "security" ? "security_" : "";
201 throw done(`${prefix}${intent === "resume" ? "resumed" : "canceled"}`);
202 }
203 case "card-check": {
204 const started = await billing.cardCheck(user, slug, here);
205 if (!started.ok) return fail("trial", started.error.message);
206 throw redirect(started.value.url);
207 }
208 case "buy-ai-credit": {
209 const parsed = parseAiPurchase({ amount: form.get("amount"), custom: form.get("custom") });
210 if (!parsed.ok) return fail("ai", parsed.error);
211 const started = await billing.buyAiCredit(user, slug, parsed.value.amountCents, here);
212 if (!started.ok) return fail("ai", started.error.message);
213 throw redirect(started.value.url);
214 }
215 case "ai-reload": {
216 const parsed = parseAiReload({ enabled: form.get("enabled"), threshold: form.get("threshold"), target: form.get("target"), monthly: form.get("monthly") });
217 if (!parsed.ok) return fail("ai", parsed.error);
218 const set = await billing.setAiReload(user, slug, parsed.value);
219 if (!set.ok) return fail("ai", set.error.message);
220 throw done("ai_reload", "#ai-credit");
221 }
222 case "budget": {
223 const parsed = parseBudgetAlerts({ alerts: form.getAll("alert"), pause: form.get("pause"), webhook: form.get("webhook") });
224 if (!parsed.ok) return fail("budget", parsed.error);
225 const set = await billing.setBudget(user, slug, { amountMicros: null, keepLimit: true, ...parsed.value });
226 if (!set.ok) return fail("budget", set.error.message);
227 throw done("budget", "#budget");
228 }
229 case "details": {
230 const parsed = parseInvoiceDetails(form);
231 if (!parsed.ok) return fail("details", parsed.error);
232 const saved = await billing.setBillingDetails(user, slug, parsed.value);
233 if (!saved.ok) return fail("details", saved.error.message);
234 throw done("details", "#details");
235 }
236 case "spend-limit": {
237 const parsed = parseSpendLimit({ mode: form.get("mode"), limit: form.get("limit") });
238 if (!parsed.ok) return fail("limit", parsed.error);
239 let micros = parsed.value.micros;
240 if (parsed.value.raiseOnce && micros == null) {
241 // The one-time raise with no amount: as high as it goes.
242 const limit = await billing.limit(slug, user);
243 micros = limit.ok ? (limit.value.raiseOnceMicros ?? null) : null;
244 if (micros == null) return fail("limit", "The one-time raise is not available. Prepay, or use Raise my limit.");
245 }
246 const set = await billing.setSpendLimit(user, slug, micros, parsed.value.useFull, parsed.value.raiseOnce);
247 if (!set.ok) return fail("limit", set.error.message);
248 throw done("limit", "#limit");
249 }
250 case "request": {
251 const parsed = parseLimitRequest({
252 kind: form.get("kind"),
253 amount: form.get("amount"),
254 reason: form.get("reason"),
255 expected: form.get("expected"),
256 });
257 const section = String(form.get("kind")) === "overage" ? "overage" : "raise";
258 if (!parsed.ok) return fail(section, parsed.error);
259 const sent = await billing.requestLimit(user, slug, parsed.value);
260 if (!sent.ok) return fail(section, sent.error.message);
261 throw done(section === "overage" ? "told" : "requested", `#${section}`);
262 }
263 case "caps": {
264 const parsed = parseCaps({ run: form.get("run"), issue: form.get("issue") });
265 if (!parsed.ok) return fail("caps", parsed.error);
266 const set = await billing.setCaps(user, slug, parsed.value);
267 if (!set.ok) return fail("caps", set.error.message);
268 throw done("caps", "#caps");
269 }
270 case "spike": {
271 const keep = String(form.get("decision")) === "keep";
272 const answered = await billing.confirmSpike(user, slug, keep);
273 if (!answered.ok) return fail("spike", answered.error.message);
274 throw done(keep ? "keep" : "stop");
275 }
276 case "prepay": {
277 const parsed = parsePrepay({ amount: form.get("amount"), custom: form.get("custom"), method: form.get("method") });
278 if (!parsed.ok) return fail("prepay", parsed.error);
279 const started = await billing.checkout(user, slug, parsed.value.amountCents, here, parsed.value.method);
280 if (!started.ok) return fail("prepay", started.error.message);
281 throw redirect(started.value.url);
282 }
283 default:
284 return fail("plan", "Unknown action.");
285 }
286}
287
288export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
289 const { slug, role, staff, account, statement, group, plan, securityPlan, limit, invoices, entitlements, requests, credits, ai, report, details, trialMicros, notice, problem } =
290 loaderData;
291 const { status } = account;
292 const owner = role === "owner" || loaderData.managesBilling;
293 const error = actionData as SectionError;
294 const err = (section: string) => (error && error.section === section ? error.error : undefined);
295 const standing = planStatus(plan, entitlements);
296 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
297 const free = standing.kind === "free" || standing.kind === "trial";
298 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
299 // The spend limit and its budget are the owners' to set only where
300 // billing lets them: on the plan, never a free workspace's ceiling.
301 const selfServe = !!limit && (limit.trust === "paid" || limit.trust === "established");
302 const summary = (loaded: Awaited<typeof details>) => (
303 <PlanSummary slug={slug} state={plan} status={standing} details={loaded} report={report} owner={owner} enabled={status.enabled} staff={staff} error={err("plan")} />
304 );
305
306 return (
307 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
308 <div className="min-w-0">
309 {status.free && (
310 <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5">
311 <h2 className="font-medium">Free while g1t is being built out</h2>
312 <p className="mt-1.5 max-w-2xl text-sm text-muted">
313 Runs are recorded with what they cost, so Usage shows what you use, but nothing is charged for now. We will say so well
314 before anything is.
315 </p>
316 </div>
317 )}
318 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
319 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
320
321 <Suspense fallback={null}>
322 <Await resolve={details}>
323 {(loaded) =>
324 loaded?.taxAddressNeededAt ? (
325 <div role="alert" className="mb-6 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm">
326 <p className="font-medium">Add a billing address</p>
327 <p className="mt-1 text-muted">
328 Stripe needs it to work out tax, so g1t did not charge the card. Nothing is lost: the charge goes through once the
329 address is saved.{" "}
330 {owner ? (
331 <a href="#details" className="text-fg underline underline-offset-2">
332 Add it under Invoice details
333 </a>
334 ) : (
335 "An owner adds it under Invoice details."
336 )}
337 </p>
338 </div>
339 ) : null
340 }
341 </Await>
342 </Suspense>
343 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
344 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
345 <Alerts alerts={entitlements?.alerts ?? []} />
346
347 <Suspense fallback={summary(null)}>
348 <Await resolve={details}>{(loaded) => summary(loaded)}</Await>
349 </Suspense>
350
351 {ai && status.enabled && <AiCreditCard credit={ai} owner={owner} enabled={status.enabled} staff={staff} error={err("ai")} />}
352
353 {free && status.enabled && (
354 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
355 )}
356
357 {paying && limit && selfServe && (
358 <>
359 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
360 <BudgetAlerts limit={limit} owner={owner} error={err("budget")} />
361 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
362 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} cardFee={ai?.cardFee ?? null} error={err("prepay")} />
363 </>
364 )}
365
366 {credits && <CreditsCard credits={credits} />}
367
368 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
369 <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} />
370 )}
371
372 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
373
374 <AddOns plan={plan} security={securityPlan} owner={owner} enabled={status.enabled} error={err("security")} />
375
376 {status.enabled && (
377 <Suspense fallback={<StripeSkeleton />}>
378 <Await resolve={details}>
379 {(loaded) => (
380 <>
381 <PaymentMethodCard details={loaded} owner={owner} enabled={status.enabled} staff={staff} error={err("payment")} />
382 <InvoiceDetailsCard details={loaded} owner={owner} enabled={status.enabled} error={err("details")} />
383 <InvoicesCard details={loaded} />
384 </>
385 )}
386 </Await>
387 </Suspense>
388 )}
389
390 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
391
392 <StatementView slug={slug} statement={statement} group={group} />
393 </div>
394
395 <aside className="space-y-5 text-sm">
396 <section className="rounded-xl border border-line bg-surface p-5">
397 <h3 className="font-medium">How it is charged</h3>
398 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
399 <li>
400 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace, with $10 of usage
401 included. Usage is charged at what it costs g1t plus {account.marginPercent}%, from the first second, after what is
402 included.
403 </li>
404 <li>
405 Agent runs: the model at the provider's price{ai && ai.modelMarkupPercent ? ` plus ${ai.modelMarkupPercent}%` : ", with no markup"}, plus
406 g1t's agent rate per million tokens{ai && ai.agentRateMicros > 0 ? ` (${dollars(ai.agentRateMicros)})` : ""}. AI Gateway: the
407 provider's price, free of markup while in beta.
408 </li>
409 <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>
410 <li>Credit from g1t comes off what you owe, the soonest-expiring first.</li>
411 <li>
412 With your own model provider, connected under{" "}
413 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
414 Integrations
415 </Link>
416 , the provider bills you for the model, and a run here is charged only its sandbox time.
417 </li>
418 <li>
419 Each month closes with an itemised invoice. No card is charged less than{" "}
420 {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over.
421 </li>
422 <li>
423 Prices exclude tax. Stripe adds tax where it applies, worked out from the billing address under Invoice details, and it
424 is its own line on every receipt and invoice.
425 </li>
426 <li>
427 Card payments carry Stripe's card processing fee as their own line, shown before you pay. Bank transfers and invoiced
428 billing have none.
429 </li>
430 <li>No seats: add as many people and agents as you like, once the workspace is on the plan.</li>
431 </ul>
432 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
433 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
434 <FileText size={14} /> Usage, by product and project
435 </Link>
436 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
437 <CreditCard size={14} /> Pricing and today's prices
438 </Link>
439 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
440 <ArrowUpRight size={14} /> How usage and billing work
441 </a>
442 </div>
443 </section>
444 </aside>
445 </div>
446 );
447}
448
449/** The workspace's month-end invoices from g1t, each kept on Stripe with its PDF. */
450function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
451 return (
452 <section className="mb-10">
453 <h2 className="font-medium">Usage invoices</h2>
454 <p className="mt-1 text-sm text-muted">
455 One when each month closes, and one each time g1t charges the card near your limit. Amounts are for usage; the card fee
456 and tax are their own lines.
457 </p>
458 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
459 {invoices.map((invoice) => (
460 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
461 <div className="flex flex-wrap items-center gap-3">
462 <span className="font-medium">
463 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
464 </span>
465 <span
466 className={`rounded-full border px-2 py-0.5 text-xs ${
467 invoice.status === "paid" ? "border-success/40 text-success" : "border-danger/40 text-danger"
468 }`}
469 >
470 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
471 </span>
472 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
473 {invoice.hostedUrl && (
474 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
475 View
476 </a>
477 )}
478 {invoice.pdfUrl && (
479 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
480 PDF
481 </a>
482 )}
483 </div>
484 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
485 {invoice.lines.map((line) => (
486 <li key={line.description} className="flex justify-between gap-4">
487 <span>{line.description}</span>
488 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
489 </li>
490 ))}
491 {(invoice.feeMicros ?? 0) > 0 && (
492 <li className="flex justify-between gap-4">
493 <span>Card processing fee</span>
494 <span className="font-mono tabular-nums">{dollars(invoice.feeMicros ?? 0)}</span>
495 </li>
496 )}
497 {(invoice.taxMicros ?? 0) > 0 && (
498 <li className="flex justify-between gap-4">
499 <span>Tax</span>
500 <span className="font-mono tabular-nums">{dollars(invoice.taxMicros ?? 0)}</span>
501 </li>
502 )}
503 </ul>
504 </li>
505 ))}
506 </ul>
507 </section>
508 );
509}