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

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