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

575 lines25,030 bytesCodeBlame
1import { CreditCard, Rocket } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
4import {
5 DEPLOYMENTS_ALLOWANCE,
6 MICROS_PER_DOLLAR,
7 type DeployUsage,
8 type Feature,
9 type FeatureState,
10 type Limit,
11} from "@g1t/contracts";
12
13import type { Route } from "./+types/billing";
14import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
15import { billing, deployments } from "../../lib/services.server";
16import {
17 assertSameOrigin,
18 getViewer,
19 requireUser,
20 roleIn,
21 unwrap,
22} from "../../lib/session.server";
23
24/** What can be added in one payment, in dollars. */
25const AMOUNTS = [10, 25, 50, 100];
26
27export function meta({ params }: Route.MetaArgs) {
28 return [{ title: `Billing · ${params.owner} · g1t` }];
29}
30
31export async function loader({ params, context, request }: Route.LoaderArgs) {
32 const viewer = getViewer(context);
33 const role = roleIn(viewer, params.owner);
34 // Members only; to anyone else the page does not exist.
35 if (!role) throw data(null, { status: 404 });
36 const slug = params.owner.toLowerCase();
37
38 // Back from the payment page: credit it, then drop the id from the address.
39 const url = new URL(request.url);
40 const session = url.searchParams.get("session");
41 if (session) {
42 // The same page returns from both a credit payment and a plan.
43 if (url.searchParams.get("plan")) {
44 await billing.confirmSubscription(slug, viewer, session);
45 throw redirect(`/${slug}/-/billing?subscribed=1`);
46 }
47 await billing.confirm(slug, viewer, session);
48 throw redirect(`/${slug}/-/billing?added=1`);
49 }
50 const [account, ledger, features, deployUsage, limit] = await Promise.all([
51 billing.account(slug, viewer),
52 billing.ledger(slug, viewer),
53 billing.features(slug, viewer),
54 deployments.usage(slug, viewer),
55 billing.limit(slug, viewer),
56 ]);
57 return {
58 slug,
59 role,
60 account: unwrap(account),
61 ledger: unwrap(ledger),
62 features: unwrap(features),
63 deployUsage: deployUsage.ok ? deployUsage.value : null,
64 limit: limit.ok ? limit.value : null,
65 added: url.searchParams.has("added"),
66 subscribed: url.searchParams.has("subscribed"),
67 };
68}
69
70export async function action({ request, params, context }: Route.ActionArgs) {
71 assertSameOrigin(request);
72 const user = requireUser(context, request);
73 const form = await request.formData();
74 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
75 const intent = form.get("intent");
76 if (intent === "portal") {
77 // Card, invoices and billing details live on Stripe's own page.
78 const started = await billing.billingPortal(user, params.owner, page);
79 if (!started.ok) return { error: started.error.message };
80 throw redirect(started.value.url);
81 }
82 if (intent === "spend-limit" || intent === "no-spend-limit") {
83 const amount = Number(form.get("limit"));
84 if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) {
85 return { error: "A spend limit is a dollar amount." };
86 }
87 const set = await billing.setSpendLimit(
88 user,
89 params.owner,
90 intent === "spend-limit" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
91 );
92 return set.ok ? null : { error: set.error.message };
93 }
94 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
95 const feature = String(form.get("feature")) as Feature;
96 if (intent !== "subscribe") {
97 const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume");
98 return changed.ok ? null : { error: changed.error.message };
99 }
100 const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`);
101 if (!started.ok) return { error: started.error.message };
102 throw redirect(started.value.url);
103 }
104 const dollars = Math.trunc(Number(form.get("amount")));
105 const started = await billing.checkout(
106 user,
107 params.owner,
108 Number.isFinite(dollars) ? dollars * 100 : 0,
109 // Spelled out: a form post arrives at a data address, not the page's.
110 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
111 );
112 if (!started.ok) return { error: started.error.message };
113 throw redirect(started.value.url);
114}
115
116/** Millionths of a dollar as dollars, to the cent or finer. */
117function dollars(micros: number, digits = 2): string {
118 const sign = micros < 0 ? "−" : "";
119 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
120}
121
122export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
123 const { slug, role, account, ledger, features, deployUsage, limit, added, subscribed } = loaderData;
124 const { status } = account;
125 const paying = useNavigation().state === "submitting";
126 const empty = account.balanceMicros <= 0;
127 return (
128 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
129 <div className="min-w-0">
130 {status.free && (
131 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
132 <h2 className="font-medium">Free while g1t is being built out</h2>
133 <p className="mt-1.5 max-w-2xl text-sm text-muted">
134 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
135 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
136 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
137 will come later, and we will say so well before anything is charged.
138 </p>
139 </div>
140 )}
141 {limit && account.status.enabled && (
142 <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
143 )}
144
145 {account.status.enabled && limit?.trust !== "internal" && (
146 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
147 <div className="flex flex-wrap items-center justify-between gap-3">
148 <div>
149 <h2 className="font-medium">Card and invoices</h2>
150 {account.card ? (
151 <p className="mt-1 text-sm">
152 <span className="capitalize">{account.card.brand}</span> ending{" "}
153 <span className="font-mono">{account.card.last4}</span>
154 <span className="text-muted">
155 {" "}
156 · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}
157 </span>
158 </p>
159 ) : (
160 <p className="mt-1 max-w-xl text-sm text-muted">
161 No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each
162 month closes, so work never stops for a payment. Saving it charges nothing.
163 </p>
164 )}
165 <p className="mt-2 max-w-xl text-xs text-faint">
166 Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t
167 never sees card numbers.
168 </p>
169 </div>
170 {role === "owner" && (
171 <Form method="post">
172 <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}>
173 <CreditCard size={14} />
174 {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"}
175 </Button>
176 </Form>
177 )}
178 </div>
179 {account.card && (
180 <p className="mt-3 text-xs text-faint">
181 Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card
182 stops work until it is paid.
183 </p>
184 )}
185 {!account.status.live && (
186 <p className="mt-3 text-xs text-faint">
187 Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged
188 automatically.
189 </p>
190 )}
191 </section>
192 )}
193
194 <h2 className="font-medium">Plans</h2>
195 <p className="mt-1 max-w-2xl text-sm text-muted">
196 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
197 g1t is.
198 </p>
199 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
200 <div className="mt-5 space-y-4">
201 {features.map((state) => (
202 <PlanCard
203 key={state.plan.feature}
204 state={state}
205 owner={role === "owner"}
206 enabled={account.status.enabled}
207 live={account.status.live}
208 busy={paying}
209 usage={state.plan.feature === "deployments" ? deployUsage : null}
210 />
211 ))}
212 </div>
213 <div className="mt-2">
214 <ErrorText>{actionData?.error}</ErrorText>
215 </div>
216
217 <h2 className="mt-12 font-medium">Agent credit</h2>
218 <p className="mt-1 max-w-2xl text-sm text-muted">
219 g1t agents that work on this workspace's repositories are paid for from
220 its account: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
221 queue and workflows, is metered by the second past 500 free minutes a month. Credit added here pays usage in
222 advance; the usage limit above decides whether work starts.
223 </p>
224
225 <div
226 className={`mt-5 rounded-xl border p-5 ${
227 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
228 }`}
229 >
230 <p className="text-xs text-muted">Balance</p>
231 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
232 {dollars(account.balanceMicros)}
233 </p>
234 {added && (
235 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
236 )}
237 {status.free && !features.some((state) => state.on && state.subscription) ? (
238 <p className="mt-3 text-sm text-muted">
239 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
240 pricing starts.
241 </p>
242 ) : !status.enabled ? (
243 <p className="mt-3 text-sm text-muted">
244 Payments are not set up on this g1t yet, so nothing is charged and
245 agents are limited to selected accounts.
246 </p>
247 ) : role === "owner" ? (
248 <Form method="post" className="mt-4">
249 <p className="text-sm text-muted">
250 {status.free
251 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
252 : "Add credit by card:"}
253 </p>
254 <div className="mt-2 flex flex-wrap gap-2">
255 {AMOUNTS.map((amount) => (
256 <Button
257 key={amount}
258 variant={amount === 25 ? "accent" : "quiet"}
259 type="submit"
260 name="amount"
261 value={amount}
262 disabled={paying}
263 >
264 <CreditCard size={14} />${amount}
265 </Button>
266 ))}
267 </div>
268 {!status.live && (
269 <p className="mt-3 text-xs text-faint">
270 Payments are in test mode. No real card is charged; use Stripe's
271 test card 4242 4242 4242 4242 with any future date and any code.
272 </p>
273 )}
274 </Form>
275 ) : (
276 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
277 )}
278 </div>
279
280 <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
281 <div className="mt-3">
282 {ledger.length === 0 ? (
283 <EmptyState title="Nothing yet">
284 Each agent run and each payment appears here.
285 </EmptyState>
286 ) : (
287 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
288 {ledger.map((entry) => (
289 <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
290 <div className="min-w-0 grow">
291 {entry.repo && entry.number ? (
292 <Link
293 to={`/${entry.repo}/pull/${entry.number}`}
294 className="block truncate font-medium hover:underline"
295 >
296 {entry.description}
297 </Link>
298 ) : (
299 <p className="truncate font-medium">{entry.description}</p>
300 )}
301 <p className="mt-0.5 text-xs text-faint">
302 <TimeAgo at={entry.createdAt} />
303 {entry.model && ` · ${entry.model}`}
304 {entry.createdBy && ` · ${entry.createdBy}`}
305 </p>
306 </div>
307 <span
308 className={`shrink-0 font-mono text-sm tabular-nums ${
309 entry.amountMicros > 0 ? "text-accent" : "text-muted"
310 }`}
311 >
312 {entry.amountMicros > 0 && "+"}
313 {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
314 </span>
315 </li>
316 ))}
317 </ul>
318 )}
319 </div>
320 </div>
321
322 <aside className="space-y-5 text-sm">
323 <section className="rounded-xl border border-line bg-surface p-5">
324 <h3 className="font-medium">How it is charged</h3>
325 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
326 <li>
327 Each run is charged when it finishes, to the workspace that owns the
328 repository, whoever assigned the issue.
329 </li>
330 <li>
331 A change, a review, a revision and a catch-up are each a run. The
332 statement links each to its pull request.
333 </li>
334 <li>
335 Every pull request's session ends with what that run cost before
336 the margin.
337 </li>
338 <li>
339 With your own model provider, connected under{" "}
340 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
341 Integrations
342 </Link>
343 , the provider bills you for the model and each run here is a flat{" "}
344 {dollars(account.orchestrationFeeMicros)}
345 {status.free && " once pricing starts; nothing while g1t is being built out"}.
346 </li>
347 <li>
348 Only members of <span className="font-mono text-fg">{slug}</span> can
349 put agents to work on its repositories.
350 </li>
351 </ul>
352 </section>
353 </aside>
354 </div>
355 );
356}
357
358/** A paid feature: what it costs and includes, and its plan. */
359function PlanCard({
360 state,
361 owner,
362 enabled,
363 live,
364 busy,
365 usage,
366}: {
367 state: FeatureState;
368 owner: boolean;
369 enabled: boolean;
370 live: boolean;
371 busy: boolean;
372 usage: DeployUsage | null;
373}) {
374 const { plan, subscription } = state;
375 const ending = subscription?.status === "canceling";
376 const owed = subscription?.status === "past_due";
377 return (
378 <section
379 className={`rounded-xl border p-5 ${
380 state.on && subscription
381 ? "border-accent/40 bg-accent/5"
382 : owed
383 ? "border-warn/40 bg-warn/5"
384 : "border-line bg-surface"
385 }`}
386 >
387 <div className="flex flex-wrap items-start justify-between gap-4">
388 <div className="min-w-0">
389 <h3 className="flex flex-wrap items-center gap-2 font-medium">
390 <Rocket size={15} className="text-accent" />
391 {plan.title}
392 {state.on && subscription && (
393 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
394 {ending ? "Ends " : "On"}
395 {subscription.periodEnd && (
396 <>
397 {ending ? "" : " · renews "}
398 {new Date(subscription.periodEnd).toLocaleDateString()}
399 </>
400 )}
401 </span>
402 )}
403 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
404 </h3>
405 <p className="mt-1 text-sm text-muted">
406 Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge.
407 </p>
408 </div>
409 <p className="shrink-0 text-right">
410 <span className="text-2xl font-semibold tabular-nums tracking-tight">
411 ${(plan.monthlyCents / 100).toFixed(0)}
412 </span>
413 <span className="text-sm text-muted"> / month</span>
414 </p>
415 </div>
416 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
417 {plan.includes.map((line) => (
418 <li key={line} className="flex gap-2">
419 <span className="text-accent">✓</span>
420 {line}
421 </li>
422 ))}
423 </ul>
424 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
425 {state.on && usage && <DeployMeter usage={usage} />}
426 {!enabled ? (
427 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
428 ) : !owner ? (
429 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
430 ) : (
431 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
432 <input type="hidden" name="feature" value={plan.feature} />
433 {!state.on || !subscription ? (
434 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
435 <CreditCard size={14} />
436 Turn on {plan.title}
437 </Button>
438 ) : ending ? (
439 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
440 Keep {plan.title}
441 </Button>
442 ) : (
443 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
444 Turn off at the end of the period
445 </Button>
446 )}
447 {!live && !state.on && (
448 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
449 )}
450 </Form>
451 )}
452 </section>
453 );
454}
455
456/** This month's use of the Deployments plan against what it includes. */
457function DeployMeter({ usage }: { usage: DeployUsage }) {
458 const a = DEPLOYMENTS_ALLOWANCE;
459 const rows: [string, number, number, (n: number) => string][] = [
460 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
461 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
462 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
463 ];
464 return (
465 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
466 <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
467 <ul className="mt-2 space-y-2.5">
468 {rows.map(([label, used, included, show]) => (
469 <li key={label} className="text-sm">
470 <div className="flex justify-between gap-4">
471 <span className="text-muted">{label}</span>
472 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
473 {show(used)} <span className="text-faint">of {show(included)}</span>
474 </span>
475 </div>
476 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
477 <div
478 className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
479 style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
480 />
481 </div>
482 </li>
483 ))}
484 </ul>
485 <p className="mt-3 text-xs text-faint">
486 Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost.
487 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
488 </p>
489 </div>
490 );
491}
492
493const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
494 new: {
495 label: "New",
496 detail: "No payment to g1t yet, so the limit is small: the free allowances and a little more. It grows once the workspace pays.",
497 },
498 paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." },
499 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
500 internal: { label: "Comped", detail: "g1t covers this workspace's usage: nothing is charged, and there is no limit." },
501};
502
503/**
504 * How far this month's unpaid usage has gone, and where work stops: g1t's
505 * ceiling for the workspace, or the owners' own spend limit if lower.
506 */
507function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
508 const ceiling = limit.ceilingMicros;
509 const share = ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
510 const tone =
511 limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
512 const bar = limit.state === "stopped" ? "bg-danger" : limit.state === "warning" ? "bg-warn" : "bg-accent";
513 const trust = TRUST[limit.trust];
514 return (
515 <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
516 <div className="flex flex-wrap items-baseline justify-between gap-2">
517 <h2 className="font-medium">Usage limit</h2>
518 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
519 </div>
520 <p className="mt-1 max-w-2xl text-sm text-muted">
521 What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. With a card on
522 file, g1t charges it as the workspace nears the limit, so its work does not stop. Without one, at the limit new
523 sandboxes and builds stop and apps pause until it pays or the month turns. Work already running finishes.
524 </p>
525 <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight">
526 {dollars(limit.exposureMicros)}
527 <span className="text-base font-normal text-muted"> {ceiling == null ? "· no limit" : `of ${dollars(ceiling)}`}</span>
528 </p>
529 {ceiling != null && (
530 <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
531 <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
532 </div>
533 )}
534 {limit.account.startsWith("ent_") && (
535 <p className="mt-3 text-sm text-muted">
536 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
537 for all of its workspaces together.
538 </p>
539 )}
540 {limit.message && <p className="mt-3 text-sm">{limit.message}</p>}
541 <p className="mt-3 text-xs text-faint">
542 {trust.detail}
543 {limit.trustCeilingMicros != null && limit.spendLimitMicros != null && ` g1t's limit is ${dollars(limit.trustCeilingMicros)}.`}
544 </p>
545 {owner && limit.trust !== "internal" && (
546 <Form method="post" className="mt-4 flex flex-wrap items-end gap-2">
547 <label className="text-sm">
548 <span className="block text-xs text-muted">Your own monthly spend limit</span>
549 <span className="mt-1 flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
550 <span className="text-muted">$</span>
551 <input
552 name="limit"
553 type="number"
554 min={0}
555 step={1}
556 defaultValue={limit.spendLimitMicros != null ? limit.spendLimitMicros / MICROS_PER_DOLLAR : ""}
557 placeholder="None"
558 className="w-24 bg-transparent px-1 py-1.5 tabular-nums outline-none"
559 />
560 </span>
561 </label>
562 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
563 Set
564 </Button>
565 {limit.spendLimitMicros != null && (
566 <Button variant="quiet" type="submit" name="intent" value="no-spend-limit" disabled={busy}>
567 Remove
568 </Button>
569 )}
570 <ErrorText>{error}</ErrorText>
571 </Form>
572 )}
573 </section>
574 );
575}