Skip to content

g1t/apps/web/app/components/billing.tsx

786 lines33,194 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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1/**
2 * The billing page's sections: the spend-spike banner (also in the app
3 * shell), alerts, the g1t plan, the trial, the spend limit and its range,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging4 * Raise my limit, credits from g1t, Prepay, the run and issue caps, and "Spent more than you
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look5 * meant to?". Each posts to the billing route's action with an `intent`.
6 */
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging7import { ArrowUpRight, CreditCard, Gauge, Gift, Landmark, ShieldCheck, Sparkles } from "lucide-react";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look8import type { ReactNode } from "react";
9import { Form } from "react-router";
10
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging11import type { Credits, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look12
13import {
14 CAPS,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging15 CREDIT_KIND,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16 PREPAY,
17 type PlanStatus,
18 alertText,
19 alertTone,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging20 creditLine,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21 dollars,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas22 gigabytes,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23 requestStatus,
24 share,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging25 shortDay,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas26 shownMeters,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look27 spendRange,
28 wholeDollars,
29} from "../lib/billing";
Merge branch 'worktree-agent-a985c01f43538af01'30import { ErrorText, SubmitButton } from "./ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look31import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
32
33/** An error the action returned, for the section it belongs to. */
34export type SectionError = { section: string; error: string } | null | undefined;
35
36function errorFor(error: SectionError, section: string): string | undefined {
37 return error && error.section === section ? error.error : undefined;
38}
39
40const CONTROL =
41 "rounded-md border border-line bg-bg px-2.5 py-1.5 text-sm tabular-nums outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
42
43/** A dollar amount field: a `$` and a number. */
44function DollarInput({ name, defaultValue, placeholder, label }: { name: string; defaultValue?: string; placeholder?: string; label: string }) {
45 return (
46 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim">
47 <span className="text-sm text-muted">$</span>
48 <input
49 name={name}
50 inputMode="decimal"
51 aria-label={label}
52 defaultValue={defaultValue}
53 placeholder={placeholder}
54 autoComplete="off"
55 data-1p-ignore
56 data-lpignore="true"
57 className="w-24 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none placeholder:text-faint"
58 />
59 </span>
60 );
61}
62
63/** A bordered card with a heading, a line about it, and a tone. */
64export function Card({
65 id,
66 title,
67 icon,
68 aside,
69 about,
70 tone = "plain",
71 children,
72}: {
73 id?: string;
74 title: ReactNode;
75 icon?: ReactNode;
76 aside?: ReactNode;
77 about?: ReactNode;
78 tone?: "plain" | "accent" | "warn" | "danger";
79 children?: ReactNode;
80}) {
81 const tones = {
82 plain: "border-line bg-surface",
83 accent: "border-accent/40 bg-accent/5",
84 warn: "border-warn/40 bg-warn/5",
85 danger: "border-danger/40 bg-danger/5",
86 };
87 return (
88 <section id={id} className={`mb-6 scroll-mt-20 rounded-xl border p-5 ${tones[tone]}`}>
89 <div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
90 <div className="min-w-0 basis-full sm:basis-0 sm:flex-1">
91 <h2 className="flex flex-wrap items-center gap-2 font-medium">
92 {icon && <span className="text-accent">{icon}</span>}
93 {title}
94 </h2>
95 {about && <div className="mt-1 max-w-2xl text-sm text-muted">{about}</div>}
96 </div>
97 {aside}
98 </div>
99 {children}
100 </section>
101 );
102}
103
104/** One meter: how much of something is used. */
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas105export function Meter({
106 used,
107 of,
108 label,
109 detail,
110 calm = false,
111}: {
112 used: number;
113 of: number | null;
114 label: string;
115 detail?: ReactNode;
116 /** Reaching the end is ordinary, not a warning: the plan's included usage, past which usage simply goes on demand. */
117 calm?: boolean;
118}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look119 const part = share(used, of);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas120 const bar = calm ? "bg-accent" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-accent";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look121 return (
122 <div className="mt-4">
123 <div className="flex flex-wrap justify-between gap-x-4 text-sm">
124 <span className="text-muted">{label}</span>
125 <span className="tabular-nums">
126 {dollars(used)}
127 {of != null && <span className="text-faint"> of {dollars(of)}</span>}
128 </span>
129 </div>
130 <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
131 <div className={`h-full ${bar}`} style={{ width: `${Math.max(part * 100, part > 0 ? 2 : 0)}%` }} />
132 </div>
133 {detail && <p className="mt-1.5 text-xs text-faint">{detail}</p>}
134 </div>
135 );
136}
137
138// --- Spike ------------------------------------------------------------------------
139
Fast pages, required checks on the branch, self-hosted runners, honest incidents140// In its own module, so the app shell carries it without the billing page.
141export { SpikeBanner } from "./spike-banner";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look142
143// --- Alerts -----------------------------------------------------------------------
144
145export function Alerts({ alerts }: { alerts: UsageAlert[] }) {
146 const shown = alerts.filter((alert) => alert.level >= 50).sort((a, b) => b.level - a.level);
147 if (shown.length === 0) return null;
148 return (
149 <ul className="mb-6 space-y-2">
150 {shown.map((alert) => {
151 const tone = alertTone(alert.level);
152 return (
153 <li
154 key={`${alert.meter}-${alert.level}`}
155 className={`flex gap-2.5 rounded-lg border px-4 py-2.5 text-sm ${
156 tone === "stopped" ? "border-danger/40 bg-danger/5" : tone === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
157 }`}
158 >
159 <Gauge size={15} className={`mt-0.5 shrink-0 ${tone === "stopped" ? "text-danger" : tone === "warning" ? "text-warn" : "text-muted"}`} />
160 <span>{alert.message || alertText(alert)}</span>
161 </li>
162 );
163 })}
164 </ul>
165 );
166}
167
168// --- The plan -----------------------------------------------------------------------
169
170const STATUS_TONE: Record<PlanStatus["kind"], "plain" | "accent" | "warn"> = {
171 free: "plain",
172 trial: "plain",
173 paid: "accent",
174 canceling: "warn",
175 past_due: "warn",
176 comped: "accent",
177 enterprise: "accent",
178};
179
180export function PlanCard({
181 state,
182 status,
183 entitlements,
184 owner,
185 enabled,
186 live,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas187 meters,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look188 error,
189}: {
190 state: FeatureState | null;
191 status: PlanStatus;
192 entitlements: Entitlements | null;
193 owner: boolean;
194 enabled: boolean;
195 live: boolean;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas196 /** This month's usage by meter, from billing's `usage_meters`. */
197 meters: MeterUsage[] | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look198 error?: string;
199}) {
200 const plan = state?.plan;
201 const subscription = state?.subscription ?? null;
202 const on = ["paid", "canceling", "comped", "enterprise"].includes(status.kind);
203 return (
204 <Card
205 id="plan"
206 tone={STATUS_TONE[status.kind]}
207 icon={<Sparkles size={16} />}
208 title={
209 <>
210 {plan?.title ?? "g1t"}
211 <span
212 className={`rounded-full px-2 py-0.5 text-xs ${
213 status.kind === "past_due" || status.kind === "canceling"
214 ? "bg-warn/15 text-warn"
215 : on
216 ? "bg-accent/15 text-accent"
217 : "border border-line text-muted"
218 }`}
219 >
220 {status.label}
221 {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""}
222 </span>
223 </>
224 }
225 about={
226 on
227 ? "Everything on g1t for the workspace: agents, checks, workflows, the merge queue, deployments and semantic search."
228 : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Anything that runs compute needs the plan, or the trial."
229 }
230 aside={
231 <p className="shrink-0 sm:text-right">
232 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
233 <span className="text-sm text-muted"> / month</span>
234 <span className="block text-xs text-faint">per workspace, never per seat</span>
235 </p>
236 }
237 >
238 {plan && (
239 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
240 {plan.includes.map((line) => (
241 <li key={line} className="flex gap-2">
242 <span className="text-accent">✓</span>
243 {line}
244 </li>
245 ))}
246 </ul>
247 )}
248 {plan && <p className="mt-3 text-xs text-faint">{plan.overage}</p>}
249
250 {on && entitlements?.includedMicros ? (
251 <Meter
252 label="Included usage this month"
253 used={entitlements.includedUsedMicros ?? 0}
254 of={entitlements.includedMicros}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas255 calm
256 detail="Used first, at cost plus 20%. Past it, usage goes on at the same prices. It starts again on the 1st; what is unused does not carry over."
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look257 />
258 ) : null}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas259 {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look260
261 {status.kind === "comped" || status.kind === "enterprise" ? (
262 <p className="mt-4 text-sm text-muted">
263 {status.kind === "comped"
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging264 ? "This workspace has a 100% discount from g1t: the plan and its usage are shown at their price, and nothing is charged."
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look265 : "An enterprise pays for this workspace, on its own invoice."}
266 </p>
267 ) : !enabled ? (
268 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so the plan cannot be started here.</p>
269 ) : !owner ? (
270 <p className="mt-4 text-sm text-muted">An owner manages the plan.</p>
271 ) : (
272 <Form method="post" className="mt-5 flex flex-wrap items-center gap-3">
273 <input type="hidden" name="feature" value={plan?.feature ?? "plan"} />
274 {status.kind === "free" || status.kind === "trial" ? (
Merge branch 'worktree-agent-a985c01f43538af01'275 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276 <CreditCard size={14} />
277 Start the g1t plan
Merge branch 'worktree-agent-a985c01f43538af01'278 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look279 ) : status.kind === "canceling" ? (
Merge branch 'worktree-agent-a985c01f43538af01'280 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look281 Keep the plan
Merge branch 'worktree-agent-a985c01f43538af01'282 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look283 ) : null}
284 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
Merge branch 'worktree-agent-a985c01f43538af01'285 <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look286 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
287 <ArrowUpRight size={14} />
Merge branch 'worktree-agent-a985c01f43538af01'288 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look289 )}
290 {status.kind === "paid" && subscription && (
Merge branch 'worktree-agent-a985c01f43538af01'291 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look292 End at the end of the period
Merge branch 'worktree-agent-a985c01f43538af01'293 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 )}
295 {!live && (status.kind === "free" || status.kind === "trial") && (
296 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
297 )}
298 </Form>
299 )}
300 <ErrorText>{error}</ErrorText>
301 {(status.kind === "free" || status.kind === "trial") && (
302 <p className="mt-4 text-xs text-faint">
303 Your first month starts at a {wholeDollars(100_000_000)} limit on usage past what is included, so a stolen card cannot run
304 up a bill. It rises as payments clear, and you can prepay or ask to raise it at once.
305 </p>
306 )}
307 </Card>
308 );
309}
310
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas311/**
312 * This month's usage, one line per meter, in dollars at cost plus 20% and
313 * in what was used. No quotas: every line is metered from the first unit.
314 */
315function MonthUsage({
316 meters,
317 on,
318 comped,
319 entitlements,
320}: {
321 meters: MeterUsage[];
322 on: boolean;
323 comped: boolean;
324 entitlements: Entitlements | null;
325}) {
326 const freeStorage = gigabytes(entitlements?.freePrivateStorageBytes ?? 1_000_000_000);
327 const freeGit = (entitlements?.gitOperationsIncluded ?? 50_000).toLocaleString("en-US");
328 const rows = shownMeters(meters, on);
329 const total = rows.reduce((sum, row) => sum + row.micros, 0);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look330 return (
331 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas332 <div className="flex items-baseline justify-between gap-4">
333 <p className="text-xs font-medium text-muted">This month's usage</p>
334 <p className="text-xs text-faint">At cost plus 20%</p>
335 </div>
336 <ul className="mt-2 divide-y divide-line/60">
337 {rows.map((row) => (
338 <li key={row.key} className="flex items-baseline justify-between gap-4 py-2 text-sm">
339 <span className="min-w-0">
340 <span>{row.label}</span>
341 {(row.quantity || row.micros === 0) && (
342 <span className="block truncate text-xs text-faint">{row.quantity ?? "None yet"}</span>
343 )}
344 </span>
345 <span className={`shrink-0 tabular-nums ${row.micros > 0 ? "" : "text-faint"}`}>{dollars(row.micros)}</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look346 </li>
347 ))}
348 </ul>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas349 <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm">
Billing's usage total is labeled at price, and Usage says what part of it was paid for350 {/* At price, before the plan's included usage, the trial and the pools paid
351 their part: Usage shows what was charged. */}
352 <span className="font-medium">Total at price</span>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas353 <span className="font-medium tabular-nums">{dollars(total)}</span>
354 </div>
355 <p className="mt-2 text-xs text-faint">
356 {comped
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging357 ? "What this workspace's usage comes to at price. Its 100% discount takes all of it off."
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas358 : on
359 ? `Drawn from the included usage first, then charged up to your spend limit. Projects, previews and repositories are never charged, and the first ${freeStorage} of private storage and ${freeGit} git operations a month are free. App traffic, custom domains, storage and git operations are counted through the month and charged when it closes.`
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging360 : `The forge is free: ${freeStorage} of private storage and ${freeGit} git operations a month. Agents run from the trial, and checks, workflows and the merge queue on public repositories from the open-source pool; together they pay part of this total: Usage shows what was charged.`}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas361 </p>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 </div>
363 );
364}
365
366// --- The trial -----------------------------------------------------------------------
367
368export function TrialCard({
369 entitlements,
370 trialMicros,
371 owner,
372 enabled,
373 error,
374}: {
375 entitlements: Entitlements | null;
376 trialMicros: number;
377 owner: boolean;
378 enabled: boolean;
379 error?: string;
380}) {
381 const verified = entitlements?.trialVerified ?? false;
382 const left = entitlements?.trialMicrosLeft ?? 0;
383 return (
384 <Card
385 id="trial"
386 icon={<ShieldCheck size={16} />}
387 title={`Try it with ${wholeDollars(trialMicros)} of usage`}
388 about={
389 verified
390 ? left > 0
391 ? "Your trial pays for agents, checks and workflows until it is used. It is given once per workspace."
392 : "This workspace has no trial left. Start the plan to keep going."
393 : "Once per workspace, after a card check: Stripe saves and verifies the card with 3-D Secure and charges nothing. The trial needs a credit or debit card; prepaid cards can still pay for the plan."
394 }
395 >
396 {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />}
397 {!verified && enabled && owner && (
398 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
Merge branch 'worktree-agent-a985c01f43538af01'399 <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look400 <ShieldCheck size={14} />
401 Check a card
Merge branch 'worktree-agent-a985c01f43538af01'402 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look403 <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span>
404 </Form>
405 )}
406 {!verified && !owner && <p className="mt-3 text-sm text-muted">An owner can do the card check.</p>}
407 <ErrorText>{error}</ErrorText>
408 </Card>
409 );
410}
411
412// --- Spend limit -----------------------------------------------------------------------
413
Merge branch 'worktree-agent-a985c01f43538af01'414export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look415 const range = spendRange(limit);
416 const spent = limit.spentMicros ?? 0;
417 const spendLimit = limit.spendLimitMicros;
418 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "full" : "fixed";
419 const tone = limit.state === "stopped" ? "danger" : limit.state === "warning" ? "warn" : "plain";
420 return (
421 <Card
422 id="limit"
423 tone={tone}
424 icon={<Gauge size={16} />}
425 title="Spend limit"
426 about="What on-demand usage may reach this month, past what the plan includes. At the limit new work waits; runs already going finish."
427 aside={limit.firstMonth ? <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">First month</span> : null}
428 >
429 {limit.account.startsWith("ent_") && (
430 <p className="mt-2 text-sm text-muted">
431 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are for all of its
432 workspaces together.
433 </p>
434 )}
435 <div className="grid gap-x-8 sm:grid-cols-2">
436 <Meter
437 label="Charged this month"
438 used={spent}
439 of={spendLimit}
440 detail={
441 mode === "automatic"
442 ? "Automatic: $200, or twice last month's spend, within what is available."
443 : mode === "fixed"
444 ? "A limit you set."
445 : "As high as is available."
446 }
447 />
448 <Meter
449 label="Not yet paid"
450 used={limit.exposureMicros}
451 of={limit.ceilingMicros}
452 detail={limit.growth ?? "Charged to the card as it nears the limit, so work keeps going."}
453 />
454 </div>
455 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
456
457 <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4 text-sm">
458 <p>
459 {range.selfServeMicros == null ? (
460 "No ceiling: set any limit."
461 ) : (
462 <>
463 You can set up to <span className="font-medium tabular-nums">{wholeDollars(range.selfServeMicros)}</span> yourself: the
464 highest limit this workspace has had{(limit.prepaidMicros ?? 0) > 0 ? ", plus what is prepaid" : ""}.
465 </>
466 )}
467 </p>
468 <p className="mt-1 text-muted">
469 {range.raiseOnceMicros != null ? (
470 <>
471 Once, you can raise it yourself to <span className="tabular-nums text-fg">{wholeDollars(range.raiseOnceMicros)}</span>.
472 Past that, prepay or use Raise my limit.
473 </>
474 ) : range.raisedAt ? (
475 <>The one-time raise was used on {new Date(range.raisedAt).toLocaleDateString()}. Prepay or use Raise my limit for more.</>
476 ) : range.selfServeMicros != null ? (
477 "For more, prepay or use Raise my limit."
478 ) : null}
479 </p>
480 </div>
481
482 {owner && (
483 <Form method="post" className="mt-4">
484 <input type="hidden" name="intent" value="spend-limit" />
485 <fieldset>
486 <legend className="text-xs text-muted">Your monthly spend limit</legend>
487 <RadioGroup name="mode" defaultValue={mode} className="mt-2 gap-2.5 text-sm">
488 <label className="flex cursor-pointer items-center gap-2">
489 <RadioGroupItem value="automatic" />
490 Automatic
491 </label>
492 <label className="flex cursor-pointer flex-wrap items-center gap-2">
493 <RadioGroupItem value="fixed" />
494 Fixed at
495 <DollarInput
496 name="limit"
497 label="Spend limit in dollars"
498 defaultValue={mode === "fixed" && spendLimit != null ? String(spendLimit / 1_000_000) : ""}
499 placeholder={range.selfServeMicros != null ? String(range.selfServeMicros / 1_000_000) : "500"}
500 />
501 </label>
502 <label className="flex cursor-pointer items-center gap-2">
503 <RadioGroupItem value="full" />
504 Everything available{range.selfServeMicros != null ? ` (${wholeDollars(range.selfServeMicros)})` : ""}
505 </label>
506 {range.raiseOnceMicros != null && (
507 <label className="flex cursor-pointer items-center gap-2">
508 <RadioGroupItem value="raise" />
509 Use my one-time raise, to the amount above or up to {wholeDollars(range.raiseOnceMicros)}
510 </label>
511 )}
512 </RadioGroup>
513 </fieldset>
514 <div className="mt-3 flex flex-wrap items-center gap-3">
Merge branch 'worktree-agent-a985c01f43538af01'515 <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look516 Save limit
Merge branch 'worktree-agent-a985c01f43538af01'517 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look518 <a href="#raise" className="text-sm text-muted hover:text-fg">
519 Need more? Raise my limit
520 </a>
521 </div>
522 <ErrorText>{error}</ErrorText>
523 </Form>
524 )}
525 </Card>
526 );
527}
528
529// --- Raise my limit -----------------------------------------------------------------------
530
531function RequestList({ requests }: { requests: LimitRequest[] }) {
532 if (requests.length === 0) return null;
533 return (
534 <ul className="mt-4 divide-y divide-line rounded-lg border border-line text-sm">
535 {requests.map((request) => (
536 <li key={request.id} className="px-4 py-2.5">
537 <p className="flex flex-wrap items-center justify-between gap-2">
538 <span>
539 {request.kind === "limit" ? `Asked for ${wholeDollars(request.amountMicros)}` : "Told g1t about this month"}
540 <span className="text-faint"> · {new Date(request.createdAt).toLocaleDateString()}</span>
541 </span>
542 <span
543 className={`rounded-full px-2 py-0.5 text-xs ${
544 request.status === "approved"
545 ? "bg-accent/15 text-accent"
546 : request.status === "declined"
547 ? "bg-danger/15 text-danger"
548 : "border border-line text-muted"
549 }`}
550 >
551 {requestStatus(request)}
552 </span>
553 </p>
554 {request.answer && <p className="mt-1 text-muted">{request.answer}</p>}
555 </li>
556 ))}
557 </ul>
558 );
559}
560
Merge branch 'worktree-agent-a985c01f43538af01'561export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look562 const mine = requests.filter((request) => request.kind === "limit");
563 const waiting = mine.some((request) => request.status === "open");
564 return (
565 <Card
566 id="raise"
567 title="Raise my limit"
568 about="Ask g1t for a higher limit than you can set yourself. A person reads it and answers within one business day, in the app and by email. Nothing about price is negotiated."
569 >
570 {owner && !waiting && (
571 <Form method="post" className="mt-4 space-y-3">
572 <input type="hidden" name="intent" value="request" />
573 <input type="hidden" name="kind" value="limit" />
574 <div className="flex flex-wrap gap-4">
575 <label className="text-sm">
576 <span className="mb-1 block text-xs text-muted">Limit you need</span>
577 <DollarInput name="amount" label="Limit you need in dollars" placeholder="2000" />
578 </label>
579 <label className="text-sm">
580 <span className="mb-1 block text-xs text-muted">What you expect to spend a month</span>
581 <DollarInput name="expected" label="Expected monthly spend in dollars" placeholder="1500" />
582 </label>
583 </div>
584 <label className="block text-sm">
585 <span className="mb-1 block text-xs text-muted">What it is for</span>
586 <textarea
587 name="reason"
588 rows={2}
589 maxLength={2000}
590 placeholder="A launch next week: agents on 40 issues across three projects."
591 className={`${CONTROL} w-full`}
592 />
593 </label>
Merge branch 'worktree-agent-a985c01f43538af01'594 <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look595 Send the request
Merge branch 'worktree-agent-a985c01f43538af01'596 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look597 <ErrorText>{error}</ErrorText>
598 </Form>
599 )}
600 {owner && waiting && <p className="mt-3 text-sm text-muted">Your request is with g1t. You can send another once it is answered.</p>}
601 {!owner && <p className="mt-3 text-sm text-muted">An owner can ask.</p>}
602 <RequestList requests={mine} />
603 </Card>
604 );
605}
606
607// --- Prepay -----------------------------------------------------------------------
608
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging609/**
610 * Credit g1t gave the workspace: what is left, each grant in a line with
611 * its expiry, and what each was for. Spent before anything prepaid.
612 */
613export function CreditsCard({ credits }: { credits: Credits }) {
614 const open = credits.grants.filter((grant) => grant.state === "open");
615 const past = credits.grants.filter((grant) => grant.state !== "open");
616 return (
617 <Card
618 id="credits"
619 icon={<Gift size={16} />}
620 title="Credits from g1t"
621 about="Credit g1t gave this workspace. It pays for usage before anything prepaid, the soonest-expiring first. Unused credit stops counting when it expires."
622 aside={
623 <p className="shrink-0 sm:text-right">
624 <span className="block text-xs text-muted">Credit left</span>
625 <span className="text-xl font-semibold tabular-nums">{dollars(credits.leftMicros)}</span>
626 </p>
627 }
628 >
629 <ul className="mt-4 divide-y divide-line rounded-lg border border-line">
630 {[...open, ...past].map((grant) => (
631 <li key={grant.id} className={`px-3.5 py-2.5 text-sm ${grant.state === "open" ? "" : "text-muted"}`}>
632 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
633 <span className={`tabular-nums ${grant.state === "open" ? "font-medium" : ""}`}>{creditLine(grant)}</span>
634 <span className="rounded-full border border-line px-2 py-px text-xs text-muted">{CREDIT_KIND[grant.kind]}</span>
635 </p>
636 <p className="mt-0.5 text-xs text-faint">
637 {grant.kind === "refund" && grant.refundFor ? `For ${grant.refundFor}. ` : ""}
638 {grant.note} · given {shortDay(grant.createdAt)}
639 </p>
640 </li>
641 ))}
642 </ul>
643 </Card>
644 );
645}
646
Merge branch 'worktree-agent-a985c01f43538af01'647export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look648 return (
649 <Card
650 id="prepay"
651 icon={<Landmark size={16} />}
652 title="Prepay"
653 about="Pay for usage in advance. It raises what you can use before work stops by the same amount, at once, and usage draws it down."
654 aside={
655 <p className="shrink-0 sm:text-right">
656 <span className="block text-xs text-muted">Prepaid balance</span>
657 <span className="text-xl font-semibold tabular-nums">{dollars(prepaidMicros)}</span>
658 </p>
659 }
660 >
661 {owner ? (
662 <Form method="post" className="mt-4 space-y-3">
663 <input type="hidden" name="intent" value="prepay" />
664 <div className="flex flex-wrap items-center gap-2">
665 {PREPAY.presets.map((amount) => (
Merge branch 'worktree-agent-a985c01f43538af01'666 <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look667 {wholeDollars(amount * 1_000_000)}
Merge branch 'worktree-agent-a985c01f43538af01'668 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look669 ))}
670 <span className="text-sm text-muted">or</span>
671 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
Merge branch 'worktree-agent-a985c01f43538af01'672 {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */}
673 <SubmitButton variant="accent" name="amount" value="" match={{ intent: "prepay" }} pending="Opening Stripe…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look674 <CreditCard size={14} />
675 Prepay
Merge branch 'worktree-agent-a985c01f43538af01'676 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look677 </div>
678 <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
679 <label className="flex cursor-pointer items-center gap-2">
680 <RadioGroupItem value="card" />
681 By card, with 3-D Secure ({wholeDollars(PREPAY.min * 1_000_000)} to {wholeDollars(PREPAY.maxCard * 1_000_000)})
682 </label>
683 <label className="flex cursor-pointer items-center gap-2">
684 <RadioGroupItem value="bank_transfer" />
685 By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives)
686 </label>
687 </RadioGroup>
688 {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
689 <ErrorText>{error}</ErrorText>
690 </Form>
691 ) : (
692 <p className="mt-3 text-sm text-muted">An owner can prepay.</p>
693 )}
694 </Card>
695 );
696}
697
698// --- Caps -----------------------------------------------------------------------
699
Merge branch 'worktree-agent-a985c01f43538af01'700export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look701 return (
702 <Card
703 id="caps"
704 title="Caps on agents"
705 about={
706 <>
707 The most one agent run may spend, and the most the agents on one issue may spend in all, so an agent stuck in a loop stops on
708 its own. Blank goes back to {wholeDollars(CAPS.run.default)} and {wholeDollars(CAPS.issue.default)}. A cap g1t set for this
709 workspace wins.
710 </>
711 }
712 >
713 <dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:max-w-md">
714 <div>
715 <dt className="text-xs text-muted">Per run</dt>
716 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.runCapMicros)}</dd>
717 </div>
718 <div>
719 <dt className="text-xs text-muted">Per issue</dt>
720 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.issueCapMicros)}</dd>
721 </div>
722 </dl>
723 {owner && (
724 <Form method="post" className="mt-4 flex flex-wrap items-end gap-4">
725 <input type="hidden" name="intent" value="caps" />
726 <label className="text-sm">
727 <span className="mb-1 block text-xs text-muted">
728 Per run ({wholeDollars(CAPS.run.min)} to {wholeDollars(CAPS.run.max)})
729 </span>
730 <DollarInput name="run" label="Run cap in dollars" defaultValue={String(entitlements.runCapMicros / 1_000_000)} />
731 </label>
732 <label className="text-sm">
733 <span className="mb-1 block text-xs text-muted">
734 Per issue ({wholeDollars(CAPS.issue.min)} to {wholeDollars(CAPS.issue.max)})
735 </span>
736 <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} />
737 </label>
Merge branch 'worktree-agent-a985c01f43538af01'738 <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look739 Save caps
Merge branch 'worktree-agent-a985c01f43538af01'740 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look741 </Form>
742 )}
743 <ErrorText>{error}</ErrorText>
744 <p className="mt-3 text-xs text-faint">
745 {entitlements.maxConcurrentAgents} agents at a time{entitlements.maxRunMinutes ? `, ${entitlements.maxRunMinutes} minutes a run` : ""}
746 {entitlements.firstMonth ? " in the first month" : ""}.
747 </p>
748 </Card>
749 );
750}
751
752// --- Overage -----------------------------------------------------------------------
753
Merge branch 'worktree-agent-a985c01f43538af01'754export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look755 const mine = requests.filter((request) => request.kind === "overage");
756 const waiting = mine.some((request) => request.status === "open");
757 return (
758 <Card
759 id="overage"
760 title="Spent more than you meant to? Tell us."
761 about="A loop that ran all night, a workflow on every push: say what happened. g1t can credit usage you did not mean, once in 12 months; larger credits, or a second one within 12 months, are reviewed by a person."
762 >
763 {owner && !waiting && (
764 <Form method="post" className="mt-4 space-y-3">
765 <input type="hidden" name="intent" value="request" />
766 <input type="hidden" name="kind" value="overage" />
767 <textarea
768 name="reason"
769 rows={2}
770 maxLength={2000}
771 aria-label="What happened"
772 placeholder="An agent kept retrying a failing check on Tuesday night."
773 className={`${CONTROL} w-full`}
774 />
Merge branch 'worktree-agent-a985c01f43538af01'775 <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look776 Tell g1t
Merge branch 'worktree-agent-a985c01f43538af01'777 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look778 <ErrorText>{error}</ErrorText>
779 </Form>
780 )}
781 {owner && waiting && <p className="mt-3 text-sm text-muted">g1t has your note and answers within one business day.</p>}
782 {!owner && <p className="mt-3 text-sm text-muted">An owner can tell g1t.</p>}
783 <RequestList requests={mine} />
784 </Card>
785 );
786}

This file's history is long; its oldest lines are credited to the oldest commit read.