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