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

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