Skip to content

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

462 lines22,344 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 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
320 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
321 <Alerts alerts={entitlements?.alerts ?? []} />
322
323 <Suspense fallback={summary(null)}>
324 <Await resolve={details}>{(loaded) => summary(loaded)}</Await>
325 </Suspense>
326
327 {ai && status.enabled && <AiCreditCard credit={ai} owner={owner} enabled={status.enabled} staff={staff} error={err("ai")} />}
328
329 {free && status.enabled && (
330 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
331 )}
332
333 {paying && limit && selfServe && (
334 <>
335 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
336 <BudgetAlerts limit={limit} owner={owner} error={err("budget")} />
337 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
338 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} error={err("prepay")} />
339 </>
340 )}
341
342 {credits && <CreditsCard credits={credits} />}
343
344 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
345 <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} />
346 )}
347
348 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
349
350 <AddOns plan={plan} security={securityPlan} owner={owner} enabled={status.enabled} error={err("security")} />
351
352 {status.enabled && (
353 <Suspense fallback={<StripeSkeleton />}>
354 <Await resolve={details}>
355 {(loaded) => (
356 <>
357 <PaymentMethodCard details={loaded} owner={owner} enabled={status.enabled} staff={staff} error={err("payment")} />
358 <InvoiceDetailsCard details={loaded} owner={owner} enabled={status.enabled} error={err("details")} />
359 <InvoicesCard details={loaded} />
360 </>
361 )}
362 </Await>
363 </Suspense>
364 )}
365
366 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
367
368 <StatementView slug={slug} statement={statement} group={group} />
369 </div>
370
371 <aside className="space-y-5 text-sm">
372 <section className="rounded-xl border border-line bg-surface p-5">
373 <h3 className="font-medium">How it is charged</h3>
374 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
375 <li>
376 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace, with $10 of usage
377 included. Usage is charged at what it costs g1t plus {account.marginPercent}%, from the first second, after what is
378 included.
379 </li>
380 <li>
381 Agent runs: the model at the provider's price{ai && ai.modelMarkupPercent ? ` plus ${ai.modelMarkupPercent}%` : ", with no markup"}, plus
382 g1t's agent rate per million tokens{ai && ai.agentRateMicros > 0 ? ` (${dollars(ai.agentRateMicros)})` : ""}. AI Gateway: the
383 provider's price, free of markup while in beta.
384 </li>
385 <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>
386 <li>Credit from g1t comes off what you owe, the soonest-expiring first.</li>
387 <li>
388 With your own model provider, connected under{" "}
389 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
390 Integrations
391 </Link>
392 , the provider bills you for the model, and a run here is charged only its sandbox time.
393 </li>
394 <li>
395 Each month closes with an itemised invoice. No card is charged less than{" "}
396 {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over.
397 </li>
398 <li>No seats: add as many people and agents as you like.</li>
399 </ul>
400 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
401 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
402 <FileText size={14} /> Usage, by product and project
403 </Link>
404 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
405 <CreditCard size={14} /> Pricing and today's prices
406 </Link>
407 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
408 <ArrowUpRight size={14} /> How usage and billing work
409 </a>
410 </div>
411 </section>
412 </aside>
413 </div>
414 );
415}
416
417/** The workspace's month-end invoices from g1t, each kept on Stripe with its PDF. */
418function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
419 return (
420 <section className="mb-10">
421 <h2 className="font-medium">Usage invoices</h2>
422 <p className="mt-1 text-sm text-muted">One when each month closes, and one each time g1t charges the card near your limit.</p>
423 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
424 {invoices.map((invoice) => (
425 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
426 <div className="flex flex-wrap items-center gap-3">
427 <span className="font-medium">
428 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
429 </span>
430 <span
431 className={`rounded-full border px-2 py-0.5 text-xs ${
432 invoice.status === "paid" ? "border-success/40 text-success" : "border-danger/40 text-danger"
433 }`}
434 >
435 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
436 </span>
437 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
438 {invoice.hostedUrl && (
439 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
440 View
441 </a>
442 )}
443 {invoice.pdfUrl && (
444 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
445 PDF
446 </a>
447 )}
448 </div>
449 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
450 {invoice.lines.map((line) => (
451 <li key={line.description} className="flex justify-between gap-4">
452 <span>{line.description}</span>
453 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
454 </li>
455 ))}
456 </ul>
457 </li>
458 ))}
459 </ul>
460 </section>
461 );
462}