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

229 lines9,305 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.

Agents as a team: lifecycle, merge queue, billing and a new shell1import { CreditCard } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
4import { MICROS_PER_DOLLAR } from "@g1t/contracts";
5
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) {
35 await billing.confirm(slug, viewer, session);
36 throw redirect(`/${slug}/-/billing?added=1`);
37 }
38 const [account, ledger] = await Promise.all([
39 billing.account(slug, viewer),
40 billing.ledger(slug, viewer),
41 ]);
42 return {
43 slug,
44 role,
45 account: unwrap(account),
46 ledger: unwrap(ledger),
47 added: url.searchParams.has("added"),
48 };
49}
50
51export async function action({ request, params, context }: Route.ActionArgs) {
52 assertSameOrigin(request);
53 const user = requireUser(context, request);
54 const form = await request.formData();
55 const dollars = Math.trunc(Number(form.get("amount")));
56 const started = await billing.checkout(
57 user,
58 params.owner,
59 Number.isFinite(dollars) ? dollars * 100 : 0,
60 // Spelled out: a form post arrives at a data address, not the page's.
61 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
62 );
63 if (!started.ok) return { error: started.error.message };
64 throw redirect(started.value.url);
65}
66
67/** Millionths of a dollar as dollars, to the cent or finer. */
68function dollars(micros: number, digits = 2): string {
69 const sign = micros < 0 ? "−" : "";
70 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
71}
72
73export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
74 const { slug, role, account, ledger, added } = loaderData;
75 const { status } = account;
76 const paying = useNavigation().state === "submitting";
77 const empty = account.balanceMicros <= 0;
78 return (
79 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
80 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks81 {status.free && (
82 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
83 <h2 className="font-medium">Free while g1t is being built out</h2>
84 <p className="mt-1.5 max-w-2xl text-sm text-muted">
85 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
86 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
87 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
88 will come later, and we will say so well before anything is charged.
89 </p>
90 </div>
91 )}
Agents as a team: lifecycle, merge queue, billing and a new shell92 <h2 className="font-medium">Agent credit</h2>
93 <p className="mt-1 max-w-2xl text-sm text-muted">
94 g1t agents that work on this workspace's repositories are paid for from
95 its credit: what the model cost, plus {account.marginPercent}%. Checks run
96 free. With no credit, agents do not start.
97 </p>
98
99 <div
100 className={`mt-5 rounded-xl border p-5 ${
101 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
102 }`}
103 >
104 <p className="text-xs text-muted">Balance</p>
105 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
106 {dollars(account.balanceMicros)}
107 </p>
108 {added && (
109 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
110 )}
Billing: nothing to add while it is free111 {status.free ? (
112 <p className="mt-3 text-sm text-muted">
113 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
114 pricing starts.
115 </p>
116 ) : !status.enabled ? (
Agents as a team: lifecycle, merge queue, billing and a new shell117 <p className="mt-3 text-sm text-muted">
118 Payments are not set up on this g1t yet, so nothing is charged and
119 agents are limited to selected accounts.
120 </p>
121 ) : role === "owner" ? (
122 <Form method="post" className="mt-4">
123 <p className="text-sm text-muted">Add credit by card:</p>
124 <div className="mt-2 flex flex-wrap gap-2">
125 {AMOUNTS.map((amount) => (
126 <Button
127 key={amount}
128 variant={amount === 25 ? "accent" : "quiet"}
129 type="submit"
130 name="amount"
131 value={amount}
132 disabled={paying}
133 >
134 <CreditCard size={14} />${amount}
135 </Button>
136 ))}
137 </div>
138 {!status.live && (
139 <p className="mt-3 text-xs text-faint">
140 Payments are in test mode. No real card is charged; use Stripe's
141 test card 4242 4242 4242 4242 with any future date and any code.
142 </p>
143 )}
144 <div className="mt-2">
145 <ErrorText>{actionData?.error}</ErrorText>
146 </div>
147 </Form>
148 ) : (
149 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
150 )}
151 </div>
152
153 <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
154 <div className="mt-3">
155 {ledger.length === 0 ? (
156 <EmptyState title="Nothing yet">
157 Each agent run and each payment appears here.
158 </EmptyState>
159 ) : (
160 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
161 {ledger.map((entry) => (
162 <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
163 <div className="min-w-0 grow">
164 {entry.repo && entry.number ? (
165 <Link
166 to={`/${entry.repo}/pull/${entry.number}`}
167 className="block truncate font-medium hover:underline"
168 >
169 {entry.description}
170 </Link>
171 ) : (
172 <p className="truncate font-medium">{entry.description}</p>
173 )}
174 <p className="mt-0.5 text-xs text-faint">
175 <TimeAgo at={entry.createdAt} />
176 {entry.model && ` · ${entry.model}`}
177 {entry.createdBy && ` · ${entry.createdBy}`}
178 </p>
179 </div>
180 <span
181 className={`shrink-0 font-mono text-sm tabular-nums ${
182 entry.amountMicros > 0 ? "text-accent" : "text-muted"
183 }`}
184 >
185 {entry.amountMicros > 0 && "+"}
186 {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
187 </span>
188 </li>
189 ))}
190 </ul>
191 )}
192 </div>
193 </div>
194
195 <aside className="space-y-5 text-sm">
196 <section className="rounded-xl border border-line bg-surface p-5">
197 <h3 className="font-medium">How it is charged</h3>
198 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
199 <li>
200 Each run is charged when it finishes, to the workspace that owns the
201 repository, whoever assigned the issue.
202 </li>
203 <li>
204 A change, a review, a revision and a catch-up are each a run. The
205 statement links each to its pull request.
206 </li>
207 <li>
208 Every pull request's session ends with what that run cost before
209 the margin.
210 </li>
211 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read212 With your own model provider, connected under{" "}
213 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
214 Integrations
215 </Link>
216 , the provider bills you for the model and each run here is a flat{" "}
New workspaces are told to connect a model, before anything fails217 {dollars(account.orchestrationFeeMicros)}
218 {status.free && " once pricing starts; nothing while g1t is being built out"}.
Integrations: your own model provider, alerts that open issues, tickets agents read219 </li>
220 <li>
Agents as a team: lifecycle, merge queue, billing and a new shell221 Only members of <span className="font-mono text-fg">{slug}</span> can
222 put agents to work on its repositories.
223 </li>
224 </ul>
225 </section>
226 </aside>
227 </div>
228 );
229}