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

416 lines17,042 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Paid features: a workspace turns on Deployments with a monthly plan1import { CreditCard, Rocket } from "lucide-react";
Agents as a team: lifecycle, merge queue, billing and a new shell2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
Deployments: a preview for every pull request, production on g1t.page4import {
5 DEPLOYMENTS_ALLOWANCE,
6 MICROS_PER_DOLLAR,
7 type DeployUsage,
8 type Feature,
9 type FeatureState,
10} from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell11
12import type { Route } from "./+types/billing";
13import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
Deployments: a preview for every pull request, production on g1t.page14import { billing, deployments } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell15import {
16 assertSameOrigin,
17 getViewer,
18 requireUser,
19 roleIn,
20 unwrap,
21} from "../../lib/session.server";
22
23/** What can be added in one payment, in dollars. */
24const AMOUNTS = [10, 25, 50, 100];
25
26export function meta({ params }: Route.MetaArgs) {
27 return [{ title: `Billing · ${params.owner} · g1t` }];
28}
29
30export async function loader({ params, context, request }: Route.LoaderArgs) {
31 const viewer = getViewer(context);
32 const role = roleIn(viewer, params.owner);
33 // Members only; to anyone else the page does not exist.
34 if (!role) throw data(null, { status: 404 });
35 const slug = params.owner.toLowerCase();
36
37 // Back from the payment page: credit it, then drop the id from the address.
38 const url = new URL(request.url);
39 const session = url.searchParams.get("session");
40 if (session) {
Paid features: a workspace turns on Deployments with a monthly plan41 // The same page returns from both a credit payment and a plan.
42 if (url.searchParams.get("plan")) {
43 await billing.confirmSubscription(slug, viewer, session);
44 throw redirect(`/${slug}/-/billing?subscribed=1`);
45 }
Agents as a team: lifecycle, merge queue, billing and a new shell46 await billing.confirm(slug, viewer, session);
47 throw redirect(`/${slug}/-/billing?added=1`);
48 }
Deployments: a preview for every pull request, production on g1t.page49 const [account, ledger, features, deployUsage] = await Promise.all([
Agents as a team: lifecycle, merge queue, billing and a new shell50 billing.account(slug, viewer),
51 billing.ledger(slug, viewer),
Paid features: a workspace turns on Deployments with a monthly plan52 billing.features(slug, viewer),
Deployments: a preview for every pull request, production on g1t.page53 deployments.usage(slug, viewer),
Agents as a team: lifecycle, merge queue, billing and a new shell54 ]);
55 return {
56 slug,
57 role,
58 account: unwrap(account),
59 ledger: unwrap(ledger),
Paid features: a workspace turns on Deployments with a monthly plan60 features: unwrap(features),
Deployments: a preview for every pull request, production on g1t.page61 deployUsage: deployUsage.ok ? deployUsage.value : null,
Agents as a team: lifecycle, merge queue, billing and a new shell62 added: url.searchParams.has("added"),
Paid features: a workspace turns on Deployments with a monthly plan63 subscribed: url.searchParams.has("subscribed"),
Agents as a team: lifecycle, merge queue, billing and a new shell64 };
65}
66
67export async function action({ request, params, context }: Route.ActionArgs) {
68 assertSameOrigin(request);
69 const user = requireUser(context, request);
70 const form = await request.formData();
Paid features: a workspace turns on Deployments with a monthly plan71 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
72 const intent = form.get("intent");
73 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
74 const feature = String(form.get("feature")) as Feature;
75 if (intent !== "subscribe") {
76 const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume");
77 return changed.ok ? null : { error: changed.error.message };
78 }
79 const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`);
80 if (!started.ok) return { error: started.error.message };
81 throw redirect(started.value.url);
82 }
Agents as a team: lifecycle, merge queue, billing and a new shell83 const dollars = Math.trunc(Number(form.get("amount")));
84 const started = await billing.checkout(
85 user,
86 params.owner,
87 Number.isFinite(dollars) ? dollars * 100 : 0,
88 // Spelled out: a form post arrives at a data address, not the page's.
89 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
90 );
91 if (!started.ok) return { error: started.error.message };
92 throw redirect(started.value.url);
93}
94
95/** Millionths of a dollar as dollars, to the cent or finer. */
96function dollars(micros: number, digits = 2): string {
97 const sign = micros < 0 ? "−" : "";
98 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
99}
100
101export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
Deployments: a preview for every pull request, production on g1t.page102 const { slug, role, account, ledger, features, deployUsage, added, subscribed } = loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell103 const { status } = account;
104 const paying = useNavigation().state === "submitting";
105 const empty = account.balanceMicros <= 0;
106 return (
107 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
108 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks109 {status.free && (
110 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
111 <h2 className="font-medium">Free while g1t is being built out</h2>
112 <p className="mt-1.5 max-w-2xl text-sm text-muted">
113 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
114 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
115 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
116 will come later, and we will say so well before anything is charged.
117 </p>
118 </div>
119 )}
Paid features: a workspace turns on Deployments with a monthly plan120 <h2 className="font-medium">Plans</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell121 <p className="mt-1 max-w-2xl text-sm text-muted">
Paid features: a workspace turns on Deployments with a monthly plan122 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
123 g1t is.
124 </p>
125 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
126 <div className="mt-5 space-y-4">
127 {features.map((state) => (
128 <PlanCard
129 key={state.plan.feature}
130 state={state}
131 owner={role === "owner"}
132 enabled={account.status.enabled}
133 live={account.status.live}
134 busy={paying}
Deployments: a preview for every pull request, production on g1t.page135 usage={state.plan.feature === "deployments" ? deployUsage : null}
Paid features: a workspace turns on Deployments with a monthly plan136 />
137 ))}
138 </div>
139 <div className="mt-2">
140 <ErrorText>{actionData?.error}</ErrorText>
141 </div>
142
143 <h2 className="mt-12 font-medium">Agent credit</h2>
144 <p className="mt-1 max-w-2xl text-sm text-muted">
Agents as a team: lifecycle, merge queue, billing and a new shell145 g1t agents that work on this workspace's repositories are paid for from
146 its credit: what the model cost, plus {account.marginPercent}%. Checks run
147 free. With no credit, agents do not start.
148 </p>
149
150 <div
151 className={`mt-5 rounded-xl border p-5 ${
152 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
153 }`}
154 >
155 <p className="text-xs text-muted">Balance</p>
156 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
157 {dollars(account.balanceMicros)}
158 </p>
159 {added && (
160 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
161 )}
Paid features: a workspace turns on Deployments with a monthly plan162 {status.free && !features.some((state) => state.on && state.subscription) ? (
Billing: nothing to add while it is free163 <p className="mt-3 text-sm text-muted">
164 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
165 pricing starts.
166 </p>
167 ) : !status.enabled ? (
Agents as a team: lifecycle, merge queue, billing and a new shell168 <p className="mt-3 text-sm text-muted">
169 Payments are not set up on this g1t yet, so nothing is charged and
170 agents are limited to selected accounts.
171 </p>
172 ) : role === "owner" ? (
173 <Form method="post" className="mt-4">
Paid features: a workspace turns on Deployments with a monthly plan174 <p className="text-sm text-muted">
175 {status.free
176 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
177 : "Add credit by card:"}
178 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell179 <div className="mt-2 flex flex-wrap gap-2">
180 {AMOUNTS.map((amount) => (
181 <Button
182 key={amount}
183 variant={amount === 25 ? "accent" : "quiet"}
184 type="submit"
185 name="amount"
186 value={amount}
187 disabled={paying}
188 >
189 <CreditCard size={14} />${amount}
190 </Button>
191 ))}
192 </div>
193 {!status.live && (
194 <p className="mt-3 text-xs text-faint">
195 Payments are in test mode. No real card is charged; use Stripe's
196 test card 4242 4242 4242 4242 with any future date and any code.
197 </p>
198 )}
199 </Form>
200 ) : (
201 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
202 )}
203 </div>
204
205 <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
206 <div className="mt-3">
207 {ledger.length === 0 ? (
208 <EmptyState title="Nothing yet">
209 Each agent run and each payment appears here.
210 </EmptyState>
211 ) : (
212 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
213 {ledger.map((entry) => (
214 <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
215 <div className="min-w-0 grow">
216 {entry.repo && entry.number ? (
217 <Link
218 to={`/${entry.repo}/pull/${entry.number}`}
219 className="block truncate font-medium hover:underline"
220 >
221 {entry.description}
222 </Link>
223 ) : (
224 <p className="truncate font-medium">{entry.description}</p>
225 )}
226 <p className="mt-0.5 text-xs text-faint">
227 <TimeAgo at={entry.createdAt} />
228 {entry.model && ` · ${entry.model}`}
229 {entry.createdBy && ` · ${entry.createdBy}`}
230 </p>
231 </div>
232 <span
233 className={`shrink-0 font-mono text-sm tabular-nums ${
234 entry.amountMicros > 0 ? "text-accent" : "text-muted"
235 }`}
236 >
237 {entry.amountMicros > 0 && "+"}
238 {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
239 </span>
240 </li>
241 ))}
242 </ul>
243 )}
244 </div>
245 </div>
246
247 <aside className="space-y-5 text-sm">
248 <section className="rounded-xl border border-line bg-surface p-5">
249 <h3 className="font-medium">How it is charged</h3>
250 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
251 <li>
252 Each run is charged when it finishes, to the workspace that owns the
253 repository, whoever assigned the issue.
254 </li>
255 <li>
256 A change, a review, a revision and a catch-up are each a run. The
257 statement links each to its pull request.
258 </li>
259 <li>
260 Every pull request's session ends with what that run cost before
261 the margin.
262 </li>
263 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read264 With your own model provider, connected under{" "}
265 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
266 Integrations
267 </Link>
268 , the provider bills you for the model and each run here is a flat{" "}
New workspaces are told to connect a model, before anything fails269 {dollars(account.orchestrationFeeMicros)}
270 {status.free && " once pricing starts; nothing while g1t is being built out"}.
Integrations: your own model provider, alerts that open issues, tickets agents read271 </li>
272 <li>
Agents as a team: lifecycle, merge queue, billing and a new shell273 Only members of <span className="font-mono text-fg">{slug}</span> can
274 put agents to work on its repositories.
275 </li>
276 </ul>
277 </section>
278 </aside>
279 </div>
280 );
281}
Paid features: a workspace turns on Deployments with a monthly plan282
283/** A paid feature: what it costs and includes, and its plan. */
284function PlanCard({
285 state,
286 owner,
287 enabled,
288 live,
289 busy,
Deployments: a preview for every pull request, production on g1t.page290 usage,
Paid features: a workspace turns on Deployments with a monthly plan291}: {
292 state: FeatureState;
293 owner: boolean;
294 enabled: boolean;
295 live: boolean;
296 busy: boolean;
Deployments: a preview for every pull request, production on g1t.page297 usage: DeployUsage | null;
Paid features: a workspace turns on Deployments with a monthly plan298}) {
299 const { plan, subscription } = state;
300 const ending = subscription?.status === "canceling";
301 const owed = subscription?.status === "past_due";
302 return (
303 <section
304 className={`rounded-xl border p-5 ${
305 state.on && subscription
306 ? "border-accent/40 bg-accent/5"
307 : owed
308 ? "border-warn/40 bg-warn/5"
309 : "border-line bg-surface"
310 }`}
311 >
312 <div className="flex flex-wrap items-start justify-between gap-4">
313 <div className="min-w-0">
314 <h3 className="flex flex-wrap items-center gap-2 font-medium">
315 <Rocket size={15} className="text-accent" />
316 {plan.title}
317 {state.on && subscription && (
318 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
319 {ending ? "Ends " : "On"}
320 {subscription.periodEnd && (
321 <>
322 {ending ? "" : " · renews "}
323 {new Date(subscription.periodEnd).toLocaleDateString()}
324 </>
325 )}
326 </span>
327 )}
328 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
329 </h3>
330 <p className="mt-1 text-sm text-muted">
331 Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge.
332 </p>
333 </div>
334 <p className="shrink-0 text-right">
335 <span className="text-2xl font-semibold tabular-nums tracking-tight">
336 ${(plan.monthlyCents / 100).toFixed(0)}
337 </span>
338 <span className="text-sm text-muted"> / month</span>
339 </p>
340 </div>
341 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
342 {plan.includes.map((line) => (
343 <li key={line} className="flex gap-2">
344 <span className="text-accent">✓</span>
345 {line}
346 </li>
347 ))}
348 </ul>
349 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
Deployments: a preview for every pull request, production on g1t.page350 {state.on && usage && <DeployMeter usage={usage} />}
Paid features: a workspace turns on Deployments with a monthly plan351 {!enabled ? (
352 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
353 ) : !owner ? (
354 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
355 ) : (
356 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
357 <input type="hidden" name="feature" value={plan.feature} />
358 {!state.on || !subscription ? (
359 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
360 <CreditCard size={14} />
361 Turn on {plan.title}
362 </Button>
363 ) : ending ? (
364 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
365 Keep {plan.title}
366 </Button>
367 ) : (
368 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
369 Turn off at the end of the period
370 </Button>
371 )}
372 {!live && !state.on && (
373 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
374 )}
375 </Form>
376 )}
377 </section>
378 );
379}
Deployments: a preview for every pull request, production on g1t.page380
381/** This month's use of the Deployments plan against what it includes. */
382function DeployMeter({ usage }: { usage: DeployUsage }) {
383 const a = DEPLOYMENTS_ALLOWANCE;
384 const rows: [string, number, number, (n: number) => string][] = [
385 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
386 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
387 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
388 ];
389 return (
390 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
391 <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
392 <ul className="mt-2 space-y-2.5">
393 {rows.map(([label, used, included, show]) => (
394 <li key={label} className="text-sm">
395 <div className="flex justify-between gap-4">
396 <span className="text-muted">{label}</span>
397 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
398 {show(used)} <span className="text-faint">of {show(included)}</span>
399 </span>
400 </div>
401 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
402 <div
403 className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
404 style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
405 />
406 </div>
407 </li>
408 ))}
409 </ul>
410 <p className="mt-3 text-xs text-faint">
411 Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost.
412 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
413 </p>
414 </div>
415 );
416}