Skip to content
412 linesCodeBlameRaw
1import { ArrowUpRight, CreditCard, FileText } from "lucide-react";
2import { Form, Link, data, redirect } from "react-router";
3
4import { MICROS_PER_DOLLAR, type WorkspaceInvoice } from "@g1t/contracts";
5
6import type { Route } from "./+types/billing";
7import {
8 Alerts,
9 CapsCard,
10 CreditsCard,
11 OverageCard,
12 PlanCard,
13 PrepayCard,
14 RaiseCard,
15 type SectionError,
16 SpendLimitCard,
17 SpikeBanner,
18 TrialCard,
19} from "../../components/billing";
20import { StatementView } from "../../components/statement";
21import { SecurityActivationCard } from "../../components/security-activation";
22import { SubmitButton } from "../../components/ui";
23import {
24 cardCheckResult,
25 dollars,
26 parseCaps,
27 parseLimitRequest,
28 parsePrepay,
29 parseSpendLimit,
30 planStatus,
31 wholeDollars,
32} from "../../lib/billing";
33import { page } from "../../lib/meta";
34import { billing } from "../../lib/services.server";
35import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
36
37/** The trial and pools as published, when the price book cannot be read. */
38const DEFAULT_TRIAL_MICROS = 5_000_000;
39
40/** What a change says once it is done (`?done=`). */
41const DONE: Record<string, string> = {
42 subscribed: "The g1t plan is on.",
43 prepaid: "Payment received. Your prepaid balance and what you can use went up by as much.",
44 limit: "Spend limit saved.",
45 caps: "Caps saved. They apply to runs that start from now on.",
46 requested: "Sent. g1t answers within one business day, here and by email.",
47 told: "Thanks. g1t looks at it and answers within one business day, here and by email.",
48 keep: "Compute is running again for 24 hours, unless spending doubles again first.",
49 stop: "New compute stays paused until an owner chooses Keep going.",
50 canceled: "The plan ends at the end of the period. Nothing more is charged for it.",
51 resumed: "The plan continues.",
52 security_on: "Security and quality is on for every private repository in the workspace.",
53 security_canceled: "Security and quality ends at the end of the period. Nothing more is charged for it.",
54 security_resumed: "Security and quality continues.",
55};
56
57export function meta({ params, ...args }: Route.MetaArgs) {
58 return page(args, { title: `Billing · ${params.owner} · g1t` });
59}
60
61export async function loader({ params, context, request }: Route.LoaderArgs) {
62 const viewer = getViewer(context);
63 const role = roleIn(viewer, params.owner);
64 // Members only; to anyone else the page does not exist.
65 if (!role) throw data(null, { status: 404 });
66 const slug = params.owner.toLowerCase();
67 const url = new URL(request.url);
68 const here = `/${slug}/-/billing`;
69
70 // Back from Stripe: record what happened, then drop the ids from the address.
71 const cardCheck = url.searchParams.get("card_check");
72 if (cardCheck) {
73 const checked = await billing.confirmCardCheck(slug, viewer, cardCheck);
74 throw redirect(checked.ok ? `${here}?checked=1#trial` : `${here}?problem=${encodeURIComponent(checked.error.message)}#trial`);
75 }
76 const session = url.searchParams.get("session");
77 if (session) {
78 if (url.searchParams.get("plan")) {
79 const started = await billing.confirmSubscription(slug, viewer, session);
80 const doneKey = url.searchParams.get("plan") === "security" ? "security_on" : "subscribed";
81 throw redirect(started.ok ? `${here}?done=${doneKey}` : `${here}?problem=${encodeURIComponent(started.error.message)}`);
82 }
83 const paid = await billing.confirm(slug, viewer, session);
84 throw redirect(paid.ok ? `${here}?done=prepaid#prepay` : `${here}?problem=${encodeURIComponent(paid.error.message)}#prepay`);
85 }
86 // A plan started on the card already checked comes straight back.
87 const plans = url.searchParams.getAll("plan");
88 if (plans.includes("started")) throw redirect(`${here}?done=${plans.includes("security") ? "security_on" : "subscribed"}`);
89
90 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
91 const [account, statement, features, meters, limit, invoices, entitlements, requests, book, credits] = await Promise.all([
92 billing.account(slug, viewer),
93 billing.statement(slug, viewer, url.searchParams.get("month"), group),
94 billing.features(slug, viewer),
95 billing.usageMeters(slug, viewer).catch(() => null),
96 billing.limit(slug, viewer).catch(() => null),
97 billing.invoices(slug, viewer).catch(() => null),
98 billing.entitlements(slug).catch(() => null),
99 billing.limitRequests(slug, viewer).catch(() => null),
100 billing.prices().catch(() => null),
101 billing.credits(slug, viewer).catch(() => null),
102 ]);
103 const featureStates = unwrap(features);
104 const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS;
105 const done = url.searchParams.get("done");
106 return {
107 slug,
108 role,
109 account: unwrap(account),
110 statement: unwrap(statement),
111 group,
112 plan: featureStates.find((state) => state.plan.feature === "plan") ?? null,
113 securityPlan: featureStates.find((state) => state.plan.feature === "security") ?? null,
114 meters: meters?.ok ? meters.value : null,
115 limit: limit?.ok ? limit.value : null,
116 invoices: invoices?.ok ? invoices.value : [],
117 entitlements,
118 requests: requests?.ok ? requests.value : [],
119 credits: credits?.ok && credits.value.grants.length > 0 ? credits.value : null,
120 trialMicros,
121 notice:
122 url.searchParams.has("checked") && entitlements
123 ? cardCheckResult(entitlements, trialMicros)
124 : done && DONE[done]
125 ? DONE[done]
126 : null,
127 problem: url.searchParams.get("problem"),
128 };
129}
130
131export async function action({ request, params, context }: Route.ActionArgs) {
132 assertSameOrigin(request);
133 const user = requireUser(context, request);
134 const form = await request.formData();
135 const slug = params.owner.toLowerCase();
136 // Spelled out: a form post arrives at a data address, not the page's.
137 const here = `${new URL(request.url).origin}/${slug}/-/billing`;
138 const intent = String(form.get("intent") ?? "");
139 const fail = (section: string, error: string) => data<SectionError>({ section, error }, { status: 422 });
140 const done = (key: string, anchor = "") => redirect(`/${slug}/-/billing?done=${key}${anchor}`);
141
142 switch (intent) {
143 case "portal": {
144 // Card, invoices and billing details live on Stripe's own page.
145 const started = await billing.billingPortal(user, slug, here);
146 if (!started.ok) return fail("plan", started.error.message);
147 throw redirect(started.value.url);
148 }
149 case "subscribe": {
150 // The plan, or the Security and quality activation.
151 const feature = form.get("feature") === "security" ? "security" : "plan";
152 const started = await billing.subscribe(user, slug, feature, `${here}?plan=${feature}`);
153 if (!started.ok) return fail(feature, started.error.message);
154 throw redirect(started.value.url);
155 }
156 case "cancel":
157 case "resume": {
158 const feature = form.get("feature") === "security" ? "security" : "plan";
159 const changed = await billing.cancelSubscription(user, slug, feature, intent === "resume");
160 if (!changed.ok) return fail(feature, changed.error.message);
161 const prefix = feature === "security" ? "security_" : "";
162 throw done(`${prefix}${intent === "resume" ? "resumed" : "canceled"}`);
163 }
164 case "card-check": {
165 const started = await billing.cardCheck(user, slug, here);
166 if (!started.ok) return fail("trial", started.error.message);
167 throw redirect(started.value.url);
168 }
169 case "spend-limit": {
170 const parsed = parseSpendLimit({ mode: form.get("mode"), limit: form.get("limit") });
171 if (!parsed.ok) return fail("limit", parsed.error);
172 let micros = parsed.value.micros;
173 if (parsed.value.raiseOnce && micros == null) {
174 // The one-time raise with no amount: as high as it goes.
175 const limit = await billing.limit(slug, user);
176 micros = limit.ok ? (limit.value.raiseOnceMicros ?? null) : null;
177 if (micros == null) return fail("limit", "The one-time raise is not available. Prepay, or use Raise my limit.");
178 }
179 const set = await billing.setSpendLimit(user, slug, micros, parsed.value.useFull, parsed.value.raiseOnce);
180 if (!set.ok) return fail("limit", set.error.message);
181 throw done("limit", "#limit");
182 }
183 case "request": {
184 const parsed = parseLimitRequest({
185 kind: form.get("kind"),
186 amount: form.get("amount"),
187 reason: form.get("reason"),
188 expected: form.get("expected"),
189 });
190 const section = String(form.get("kind")) === "overage" ? "overage" : "raise";
191 if (!parsed.ok) return fail(section, parsed.error);
192 const sent = await billing.requestLimit(user, slug, parsed.value);
193 if (!sent.ok) return fail(section, sent.error.message);
194 throw done(section === "overage" ? "told" : "requested", `#${section}`);
195 }
196 case "caps": {
197 const parsed = parseCaps({ run: form.get("run"), issue: form.get("issue") });
198 if (!parsed.ok) return fail("caps", parsed.error);
199 const set = await billing.setCaps(user, slug, parsed.value);
200 if (!set.ok) return fail("caps", set.error.message);
201 throw done("caps", "#caps");
202 }
203 case "spike": {
204 const keep = String(form.get("decision")) === "keep";
205 const answered = await billing.confirmSpike(user, slug, keep);
206 if (!answered.ok) return fail("spike", answered.error.message);
207 throw done(keep ? "keep" : "stop");
208 }
209 case "prepay": {
210 const parsed = parsePrepay({ amount: form.get("amount"), custom: form.get("custom"), method: form.get("method") });
211 if (!parsed.ok) return fail("prepay", parsed.error);
212 const started = await billing.checkout(user, slug, parsed.value.amountCents, here, parsed.value.method);
213 if (!started.ok) return fail("prepay", started.error.message);
214 throw redirect(started.value.url);
215 }
216 default:
217 return fail("plan", "Unknown action.");
218 }
219}
220
221export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
222 const { slug, role, account, statement, group, plan, securityPlan, meters, limit, invoices, entitlements, requests, credits, trialMicros, notice, problem } =
223 loaderData;
224 const { status } = account;
225 const owner = role === "owner";
226 const error = actionData as SectionError;
227 const err = (section: string) => (error && error.section === section ? error.error : undefined);
228 const standing = planStatus(plan, entitlements);
229 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
230 const free = standing.kind === "free" || standing.kind === "trial";
231 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
232
233 return (
234 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
235 <div className="min-w-0">
236 {status.free && (
237 <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5">
238 <h2 className="font-medium">Free while g1t is being built out</h2>
239 <p className="mt-1.5 max-w-2xl text-sm text-muted">
240 Runs are recorded with what they cost, so Usage shows what you use, but nothing is charged for now. We will say so well
241 before anything is.
242 </p>
243 </div>
244 )}
245 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
246 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
247
248 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
249 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
250 <Alerts alerts={entitlements?.alerts ?? []} />
251
252 <PlanCard
253 state={plan}
254 status={standing}
255 entitlements={entitlements}
256 owner={owner}
257 enabled={status.enabled}
258 live={status.live}
259 meters={meters}
260 error={err("plan")}
261 />
262
263 <SecurityActivationCard state={securityPlan} owner={owner} enabled={status.enabled} error={err("security")} />
264
265 {free && status.enabled && (
266 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
267 )}
268
269 {paying && limit && limit.trust !== "internal" && (
270 <>
271 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
272 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
273 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} error={err("prepay")} />
274 </>
275 )}
276
277 {credits && <CreditsCard credits={credits} />}
278
279 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
280 <CapsCard entitlements={entitlements} owner={owner} error={err("caps")} />
281 )}
282
283 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
284
285 {status.enabled && (
286 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
287 <div className="flex flex-wrap items-center justify-between gap-3">
288 <div className="min-w-0">
289 <h2 className="font-medium">Card and invoices</h2>
290 {account.card ? (
291 <p className="mt-1 text-sm">
292 <span className="capitalize">{account.card.brand}</span> ending <span className="font-mono">{account.card.last4}</span>
293 <span className="text-muted"> · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}</span>
294 </p>
295 ) : (
296 <p className="mt-1 max-w-xl text-sm text-muted">No card yet. The plan, the trial and prepaying each ask for one on Stripe.</p>
297 )}
298 <p className="mt-2 max-w-xl text-xs text-faint">
299 Cards, invoices, receipts and the billing email, address and tax ID are on Stripe's billing page. g1t never sees card
300 numbers.
301 </p>
302 </div>
303 {owner && (
304 <Form method="post">
305 <SubmitButton variant="quiet" name="intent" value="portal" pending="Opening Stripe…">
306 <CreditCard size={14} />
307 Open Stripe billing
308 </SubmitButton>
309 </Form>
310 )}
311 </div>
312 {!status.live && (
313 <p className="mt-3 text-xs text-faint">Test mode: use card 4242 4242 4242 4242, any future date and code.</p>
314 )}
315 </section>
316 )}
317
318 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
319
320 <StatementView slug={slug} statement={statement} group={group} />
321 </div>
322
323 <aside className="space-y-5 text-sm">
324 <section className="rounded-xl border border-line bg-surface p-5">
325 <h3 className="font-medium">How it is charged</h3>
326 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
327 <li>
328 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace. Usage is charged at what it
329 costs g1t plus {account.marginPercent}%, from the first second, after what is included.
330 </li>
331 <li>What paid first is on each statement line: the plan's included usage, the trial, the open-source pool, or g1t.</li>
332 <li>Credit from g1t comes off what you owe, before anything prepaid, the soonest-expiring first.</li>
333 <li>
334 With your own model provider, connected under{" "}
335 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
336 Integrations
337 </Link>
338 , the provider bills you for the model, and a run here is charged only its sandbox time.
339 </li>
340 <li>
341 Each month closes with an itemised invoice. No card is charged less than{" "}
342 {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over.
343 </li>
344 <li>Alerts at 50, 75, 90 and 100% of the included usage and your limits, here and by email.</li>
345 <li>No seats: add as many people and agents as you like.</li>
346 </ul>
347 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
348 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
349 <FileText size={14} /> Usage, run by run
350 </Link>
351 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
352 <CreditCard size={14} /> Pricing and today's prices
353 </Link>
354 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
355 <ArrowUpRight size={14} /> How usage and billing work
356 </a>
357 </div>
358 </section>
359 </aside>
360 </div>
361 );
362}
363
364/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
365function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
366 return (
367 <section className="mb-10">
368 <h2 className="font-medium">Invoices</h2>
369 <p className="mt-1 text-sm text-muted">
370 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also on Stripe's
371 billing page.
372 </p>
373 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
374 {invoices.map((invoice) => (
375 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
376 <div className="flex flex-wrap items-center gap-3">
377 <span className="font-medium">
378 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
379 </span>
380 <span
381 className={`rounded-full border px-2 py-0.5 text-xs ${
382 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
383 }`}
384 >
385 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
386 </span>
387 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
388 {invoice.hostedUrl && (
389 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
390 View
391 </a>
392 )}
393 {invoice.pdfUrl && (
394 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
395 PDF
396 </a>
397 )}
398 </div>
399 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
400 {invoice.lines.map((line) => (
401 <li key={line.description} className="flex justify-between gap-4">
402 <span>{line.description}</span>
403 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
404 </li>
405 ))}
406 </ul>
407 </li>
408 ))}
409 </ul>
410 </section>
411 );
412}