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

367 lines14,904 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
Paid features: a workspace turns on Deployments with a monthly plan4import { MICROS_PER_DOLLAR, type Feature, type FeatureState } from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell5
6import type { Route } from "./+types/billing";
7import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8import { billing } from "../../lib/services.server";
9import {
10 assertSameOrigin,
11 getViewer,
12 requireUser,
13 roleIn,
14 unwrap,
15} from "../../lib/session.server";
16
17/** What can be added in one payment, in dollars. */
18const AMOUNTS = [10, 25, 50, 100];
19
20export function meta({ params }: Route.MetaArgs) {
21 return [{ title: `Billing · ${params.owner} · g1t` }];
22}
23
24export async function loader({ params, context, request }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 const role = roleIn(viewer, params.owner);
27 // Members only; to anyone else the page does not exist.
28 if (!role) throw data(null, { status: 404 });
29 const slug = params.owner.toLowerCase();
30
31 // Back from the payment page: credit it, then drop the id from the address.
32 const url = new URL(request.url);
33 const session = url.searchParams.get("session");
34 if (session) {
Paid features: a workspace turns on Deployments with a monthly plan35 // The same page returns from both a credit payment and a plan.
36 if (url.searchParams.get("plan")) {
37 await billing.confirmSubscription(slug, viewer, session);
38 throw redirect(`/${slug}/-/billing?subscribed=1`);
39 }
Agents as a team: lifecycle, merge queue, billing and a new shell40 await billing.confirm(slug, viewer, session);
41 throw redirect(`/${slug}/-/billing?added=1`);
42 }
Paid features: a workspace turns on Deployments with a monthly plan43 const [account, ledger, features] = await Promise.all([
Agents as a team: lifecycle, merge queue, billing and a new shell44 billing.account(slug, viewer),
45 billing.ledger(slug, viewer),
Paid features: a workspace turns on Deployments with a monthly plan46 billing.features(slug, viewer),
Agents as a team: lifecycle, merge queue, billing and a new shell47 ]);
48 return {
49 slug,
50 role,
51 account: unwrap(account),
52 ledger: unwrap(ledger),
Paid features: a workspace turns on Deployments with a monthly plan53 features: unwrap(features),
Agents as a team: lifecycle, merge queue, billing and a new shell54 added: url.searchParams.has("added"),
Paid features: a workspace turns on Deployments with a monthly plan55 subscribed: url.searchParams.has("subscribed"),
Agents as a team: lifecycle, merge queue, billing and a new shell56 };
57}
58
59export async function action({ request, params, context }: Route.ActionArgs) {
60 assertSameOrigin(request);
61 const user = requireUser(context, request);
62 const form = await request.formData();
Paid features: a workspace turns on Deployments with a monthly plan63 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
64 const intent = form.get("intent");
65 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
66 const feature = String(form.get("feature")) as Feature;
67 if (intent !== "subscribe") {
68 const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume");
69 return changed.ok ? null : { error: changed.error.message };
70 }
71 const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`);
72 if (!started.ok) return { error: started.error.message };
73 throw redirect(started.value.url);
74 }
Agents as a team: lifecycle, merge queue, billing and a new shell75 const dollars = Math.trunc(Number(form.get("amount")));
76 const started = await billing.checkout(
77 user,
78 params.owner,
79 Number.isFinite(dollars) ? dollars * 100 : 0,
80 // Spelled out: a form post arrives at a data address, not the page's.
81 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
82 );
83 if (!started.ok) return { error: started.error.message };
84 throw redirect(started.value.url);
85}
86
87/** Millionths of a dollar as dollars, to the cent or finer. */
88function dollars(micros: number, digits = 2): string {
89 const sign = micros < 0 ? "−" : "";
90 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
91}
92
93export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
Paid features: a workspace turns on Deployments with a monthly plan94 const { slug, role, account, ledger, features, added, subscribed } = loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell95 const { status } = account;
96 const paying = useNavigation().state === "submitting";
97 const empty = account.balanceMicros <= 0;
98 return (
99 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
100 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks101 {status.free && (
102 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
103 <h2 className="font-medium">Free while g1t is being built out</h2>
104 <p className="mt-1.5 max-w-2xl text-sm text-muted">
105 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
106 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
107 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
108 will come later, and we will say so well before anything is charged.
109 </p>
110 </div>
111 )}
Paid features: a workspace turns on Deployments with a monthly plan112 <h2 className="font-medium">Plans</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell113 <p className="mt-1 max-w-2xl text-sm text-muted">
Paid features: a workspace turns on Deployments with a monthly plan114 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
115 g1t is.
116 </p>
117 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
118 <div className="mt-5 space-y-4">
119 {features.map((state) => (
120 <PlanCard
121 key={state.plan.feature}
122 state={state}
123 owner={role === "owner"}
124 enabled={account.status.enabled}
125 live={account.status.live}
126 busy={paying}
127 />
128 ))}
129 </div>
130 <div className="mt-2">
131 <ErrorText>{actionData?.error}</ErrorText>
132 </div>
133
134 <h2 className="mt-12 font-medium">Agent credit</h2>
135 <p className="mt-1 max-w-2xl text-sm text-muted">
Agents as a team: lifecycle, merge queue, billing and a new shell136 g1t agents that work on this workspace's repositories are paid for from
137 its credit: what the model cost, plus {account.marginPercent}%. Checks run
138 free. With no credit, agents do not start.
139 </p>
140
141 <div
142 className={`mt-5 rounded-xl border p-5 ${
143 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
144 }`}
145 >
146 <p className="text-xs text-muted">Balance</p>
147 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
148 {dollars(account.balanceMicros)}
149 </p>
150 {added && (
151 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
152 )}
Paid features: a workspace turns on Deployments with a monthly plan153 {status.free && !features.some((state) => state.on && state.subscription) ? (
Billing: nothing to add while it is free154 <p className="mt-3 text-sm text-muted">
155 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
156 pricing starts.
157 </p>
158 ) : !status.enabled ? (
Agents as a team: lifecycle, merge queue, billing and a new shell159 <p className="mt-3 text-sm text-muted">
160 Payments are not set up on this g1t yet, so nothing is charged and
161 agents are limited to selected accounts.
162 </p>
163 ) : role === "owner" ? (
164 <Form method="post" className="mt-4">
Paid features: a workspace turns on Deployments with a monthly plan165 <p className="text-sm text-muted">
166 {status.free
167 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
168 : "Add credit by card:"}
169 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell170 <div className="mt-2 flex flex-wrap gap-2">
171 {AMOUNTS.map((amount) => (
172 <Button
173 key={amount}
174 variant={amount === 25 ? "accent" : "quiet"}
175 type="submit"
176 name="amount"
177 value={amount}
178 disabled={paying}
179 >
180 <CreditCard size={14} />${amount}
181 </Button>
182 ))}
183 </div>
184 {!status.live && (
185 <p className="mt-3 text-xs text-faint">
186 Payments are in test mode. No real card is charged; use Stripe's
187 test card 4242 4242 4242 4242 with any future date and any code.
188 </p>
189 )}
190 </Form>
191 ) : (
192 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
193 )}
194 </div>
195
196 <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
197 <div className="mt-3">
198 {ledger.length === 0 ? (
199 <EmptyState title="Nothing yet">
200 Each agent run and each payment appears here.
201 </EmptyState>
202 ) : (
203 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
204 {ledger.map((entry) => (
205 <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
206 <div className="min-w-0 grow">
207 {entry.repo && entry.number ? (
208 <Link
209 to={`/${entry.repo}/pull/${entry.number}`}
210 className="block truncate font-medium hover:underline"
211 >
212 {entry.description}
213 </Link>
214 ) : (
215 <p className="truncate font-medium">{entry.description}</p>
216 )}
217 <p className="mt-0.5 text-xs text-faint">
218 <TimeAgo at={entry.createdAt} />
219 {entry.model && ` · ${entry.model}`}
220 {entry.createdBy && ` · ${entry.createdBy}`}
221 </p>
222 </div>
223 <span
224 className={`shrink-0 font-mono text-sm tabular-nums ${
225 entry.amountMicros > 0 ? "text-accent" : "text-muted"
226 }`}
227 >
228 {entry.amountMicros > 0 && "+"}
229 {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
230 </span>
231 </li>
232 ))}
233 </ul>
234 )}
235 </div>
236 </div>
237
238 <aside className="space-y-5 text-sm">
239 <section className="rounded-xl border border-line bg-surface p-5">
240 <h3 className="font-medium">How it is charged</h3>
241 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
242 <li>
243 Each run is charged when it finishes, to the workspace that owns the
244 repository, whoever assigned the issue.
245 </li>
246 <li>
247 A change, a review, a revision and a catch-up are each a run. The
248 statement links each to its pull request.
249 </li>
250 <li>
251 Every pull request's session ends with what that run cost before
252 the margin.
253 </li>
254 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read255 With your own model provider, connected under{" "}
256 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
257 Integrations
258 </Link>
259 , the provider bills you for the model and each run here is a flat{" "}
New workspaces are told to connect a model, before anything fails260 {dollars(account.orchestrationFeeMicros)}
261 {status.free && " once pricing starts; nothing while g1t is being built out"}.
Integrations: your own model provider, alerts that open issues, tickets agents read262 </li>
263 <li>
Agents as a team: lifecycle, merge queue, billing and a new shell264 Only members of <span className="font-mono text-fg">{slug}</span> can
265 put agents to work on its repositories.
266 </li>
267 </ul>
268 </section>
269 </aside>
270 </div>
271 );
272}
Paid features: a workspace turns on Deployments with a monthly plan273
274/** A paid feature: what it costs and includes, and its plan. */
275function PlanCard({
276 state,
277 owner,
278 enabled,
279 live,
280 busy,
281}: {
282 state: FeatureState;
283 owner: boolean;
284 enabled: boolean;
285 live: boolean;
286 busy: boolean;
287}) {
288 const { plan, subscription } = state;
289 const ending = subscription?.status === "canceling";
290 const owed = subscription?.status === "past_due";
291 return (
292 <section
293 className={`rounded-xl border p-5 ${
294 state.on && subscription
295 ? "border-accent/40 bg-accent/5"
296 : owed
297 ? "border-warn/40 bg-warn/5"
298 : "border-line bg-surface"
299 }`}
300 >
301 <div className="flex flex-wrap items-start justify-between gap-4">
302 <div className="min-w-0">
303 <h3 className="flex flex-wrap items-center gap-2 font-medium">
304 <Rocket size={15} className="text-accent" />
305 {plan.title}
306 {state.on && subscription && (
307 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
308 {ending ? "Ends " : "On"}
309 {subscription.periodEnd && (
310 <>
311 {ending ? "" : " · renews "}
312 {new Date(subscription.periodEnd).toLocaleDateString()}
313 </>
314 )}
315 </span>
316 )}
317 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
318 </h3>
319 <p className="mt-1 text-sm text-muted">
320 Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge.
321 </p>
322 </div>
323 <p className="shrink-0 text-right">
324 <span className="text-2xl font-semibold tabular-nums tracking-tight">
325 ${(plan.monthlyCents / 100).toFixed(0)}
326 </span>
327 <span className="text-sm text-muted"> / month</span>
328 </p>
329 </div>
330 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
331 {plan.includes.map((line) => (
332 <li key={line} className="flex gap-2">
333 <span className="text-accent">✓</span>
334 {line}
335 </li>
336 ))}
337 </ul>
338 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
339 {!enabled ? (
340 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
341 ) : !owner ? (
342 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
343 ) : (
344 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
345 <input type="hidden" name="feature" value={plan.feature} />
346 {!state.on || !subscription ? (
347 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
348 <CreditCard size={14} />
349 Turn on {plan.title}
350 </Button>
351 ) : ending ? (
352 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
353 Keep {plan.title}
354 </Button>
355 ) : (
356 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
357 Turn off at the end of the period
358 </Button>
359 )}
360 {!live && !state.on && (
361 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
362 )}
363 </Form>
364 )}
365 </section>
366 );
367}