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

395 lines18,362 bytesCodeBlame
1import { ArrowUpRight, CreditCard, FileText } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } 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 { Button } 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, deployments } 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, deployUsage, 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 deployments.usage(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 deployUsage: deployUsage?.ok ? deployUsage.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, deployUsage, limit, invoices, entitlements, requests, trialMicros, notice, problem } =
209 loaderData;
210 const { status } = account;
211 const owner = role === "owner";
212 const busy = useNavigation().state === "submitting";
213 const error = actionData as SectionError;
214 const err = (section: string) => (error && error.section === section ? error.error : undefined);
215 const standing = planStatus(plan, entitlements);
216 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
217 const free = standing.kind === "free" || standing.kind === "trial";
218 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
219
220 return (
221 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
222 <div className="min-w-0">
223 {status.free && (
224 <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5">
225 <h2 className="font-medium">Free while g1t is being built out</h2>
226 <p className="mt-1.5 max-w-2xl text-sm text-muted">
227 Runs are recorded with what they cost, so Usage shows what you use, but nothing is charged for now. We will say so well
228 before anything is.
229 </p>
230 </div>
231 )}
232 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
233 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
234
235 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
236 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
237 <Alerts alerts={entitlements?.alerts ?? []} />
238
239 <PlanCard
240 state={plan}
241 status={standing}
242 entitlements={entitlements}
243 owner={owner}
244 enabled={status.enabled}
245 live={status.live}
246 busy={busy}
247 deployUsage={deployUsage}
248 error={err("plan")}
249 />
250
251 {free && status.enabled && (
252 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} busy={busy} error={err("trial")} />
253 )}
254
255 {paying && limit && limit.trust !== "internal" && (
256 <>
257 <SpendLimitCard limit={limit} owner={owner} busy={busy} error={err("limit")} />
258 <RaiseCard requests={requests} owner={owner} busy={busy} error={err("raise")} />
259 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live} busy={busy} error={err("prepay")} />
260 </>
261 )}
262
263 {entitlements && (paying || standing.kind === "trial" || standing.kind === "comped" || standing.kind === "enterprise") && (
264 <CapsCard entitlements={entitlements} owner={owner} busy={busy} error={err("caps")} />
265 )}
266
267 {paying && <OverageCard requests={requests} owner={owner} busy={busy} error={err("overage")} />}
268
269 {status.enabled && (
270 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
271 <div className="flex flex-wrap items-center justify-between gap-3">
272 <div className="min-w-0">
273 <h2 className="font-medium">Card and invoices</h2>
274 {account.card ? (
275 <p className="mt-1 text-sm">
276 <span className="capitalize">{account.card.brand}</span> ending <span className="font-mono">{account.card.last4}</span>
277 <span className="text-muted"> · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}</span>
278 </p>
279 ) : (
280 <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>
281 )}
282 <p className="mt-2 max-w-xl text-xs text-faint">
283 Cards, invoices, receipts and the billing email, address and tax ID are on Stripe's billing page. g1t never sees card
284 numbers.
285 </p>
286 </div>
287 {owner && (
288 <Form method="post">
289 <Button variant="quiet" type="submit" name="intent" value="portal" disabled={busy}>
290 <CreditCard size={14} />
291 Open Stripe billing
292 </Button>
293 </Form>
294 )}
295 </div>
296 {!status.live && (
297 <p className="mt-3 text-xs text-faint">Test mode: use card 4242 4242 4242 4242, any future date and code.</p>
298 )}
299 </section>
300 )}
301
302 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
303
304 <StatementView slug={slug} statement={statement} group={group} />
305 </div>
306
307 <aside className="space-y-5 text-sm">
308 <section className="rounded-xl border border-line bg-surface p-5">
309 <h3 className="font-medium">How it is charged</h3>
310 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
311 <li>
312 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2000) * 10_000)} a month for the workspace. Usage is charged at what it
313 costs g1t plus {account.marginPercent}%, from the first second, after what is included.
314 </li>
315 <li>What paid first is on each statement line: the plan's included usage, the trial, the open-source pool, or g1t.</li>
316 <li>
317 With your own model provider, connected under{" "}
318 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
319 Integrations
320 </Link>
321 , the provider bills you for the model, and a run here is charged only its sandbox time.
322 </li>
323 <li>
324 Each month closes with an itemised invoice. No card is charged less than{" "}
325 {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}; less carries over.
326 </li>
327 <li>Alerts at 50, 75, 90 and 100% of the included usage and your limits, here and by email.</li>
328 <li>No seats: add as many people and agents as you like.</li>
329 </ul>
330 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
331 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
332 <FileText size={14} /> Usage, run by run
333 </Link>
334 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
335 <CreditCard size={14} /> Pricing and today's prices
336 </Link>
337 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
338 <ArrowUpRight size={14} /> How usage and billing work
339 </a>
340 </div>
341 </section>
342 </aside>
343 </div>
344 );
345}
346
347/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
348function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
349 return (
350 <section className="mb-10">
351 <h2 className="font-medium">Invoices</h2>
352 <p className="mt-1 text-sm text-muted">
353 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also on Stripe's
354 billing page.
355 </p>
356 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
357 {invoices.map((invoice) => (
358 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
359 <div className="flex flex-wrap items-center gap-3">
360 <span className="font-medium">
361 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
362 </span>
363 <span
364 className={`rounded-full border px-2 py-0.5 text-xs ${
365 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
366 }`}
367 >
368 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
369 </span>
370 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
371 {invoice.hostedUrl && (
372 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
373 View
374 </a>
375 )}
376 {invoice.pdfUrl && (
377 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
378 PDF
379 </a>
380 )}
381 </div>
382 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
383 {invoice.lines.map((line) => (
384 <li key={line.description} className="flex justify-between gap-4">
385 <span>{line.description}</span>
386 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
387 </li>
388 ))}
389 </ul>
390 </li>
391 ))}
392 </ul>
393 </section>
394 );
395}