Skip to content

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

809 lines34,065 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,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person16 PLUS_TAX,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17 PREPAY,
18 type PlanStatus,
19 alertText,
20 alertTone,
Merge Stripe Tax, the card fee on card payments, and one free workspace per person21 cardFeeCents,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging22 creditLine,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23 dollars,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas24 gigabytes,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look25 requestStatus,
26 share,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging27 shortDay,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas28 shownMeters,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29 spendRange,
30 wholeDollars,
31} from "../lib/billing";
Merge branch 'worktree-agent-a985c01f43538af01'32import { ErrorText, SubmitButton } from "./ui";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look33import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
34
35/** An error the action returned, for the section it belongs to. */
36export type SectionError = { section: string; error: string } | null | undefined;
37
38function errorFor(error: SectionError, section: string): string | undefined {
39 return error && error.section === section ? error.error : undefined;
40}
41
42const CONTROL =
43 "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";
44
45/** A dollar amount field: a `$` and a number. */
46function DollarInput({ name, defaultValue, placeholder, label }: { name: string; defaultValue?: string; placeholder?: string; label: string }) {
47 return (
48 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent-dim">
49 <span className="text-sm text-muted">$</span>
50 <input
51 name={name}
52 inputMode="decimal"
53 aria-label={label}
54 defaultValue={defaultValue}
55 placeholder={placeholder}
56 autoComplete="off"
57 data-1p-ignore
58 data-lpignore="true"
59 className="w-24 bg-transparent px-1 py-1.5 text-sm tabular-nums outline-none placeholder:text-faint"
60 />
61 </span>
62 );
63}
64
65/** A bordered card with a heading, a line about it, and a tone. */
66export function Card({
67 id,
68 title,
69 icon,
70 aside,
71 about,
72 tone = "plain",
73 children,
74}: {
75 id?: string;
76 title: ReactNode;
77 icon?: ReactNode;
78 aside?: ReactNode;
79 about?: ReactNode;
80 tone?: "plain" | "accent" | "warn" | "danger";
81 children?: ReactNode;
82}) {
83 const tones = {
84 plain: "border-line bg-surface",
85 accent: "border-accent/40 bg-accent/5",
86 warn: "border-warn/40 bg-warn/5",
87 danger: "border-danger/40 bg-danger/5",
88 };
89 return (
90 <section id={id} className={`mb-6 scroll-mt-20 rounded-xl border p-5 ${tones[tone]}`}>
91 <div className="flex flex-wrap items-start justify-between gap-3 sm:flex-nowrap">
92 <div className="min-w-0 basis-full sm:basis-0 sm:flex-1">
93 <h2 className="flex flex-wrap items-center gap-2 font-medium">
94 {icon && <span className="text-accent">{icon}</span>}
95 {title}
96 </h2>
97 {about && <div className="mt-1 max-w-2xl text-sm text-muted">{about}</div>}
98 </div>
99 {aside}
100 </div>
101 {children}
102 </section>
103 );
104}
105
106/** 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 quotas107export function Meter({
108 used,
109 of,
110 label,
111 detail,
112 calm = false,
113}: {
114 used: number;
115 of: number | null;
116 label: string;
117 detail?: ReactNode;
118 /** Reaching the end is ordinary, not a warning: the plan's included usage, past which usage simply goes on demand. */
119 calm?: boolean;
120}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look121 const part = share(used, of);
Merge branch 'worktree-agent-ad7c6d88d93adc817'122 const bar = calm ? "bg-success" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-success";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look123 return (
124 <div className="mt-4">
125 <div className="flex flex-wrap justify-between gap-x-4 text-sm">
126 <span className="text-muted">{label}</span>
127 <span className="tabular-nums">
128 {dollars(used)}
129 {of != null && <span className="text-faint"> of {dollars(of)}</span>}
130 </span>
131 </div>
132 <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
133 <div className={`h-full ${bar}`} style={{ width: `${Math.max(part * 100, part > 0 ? 2 : 0)}%` }} />
134 </div>
135 {detail && <p className="mt-1.5 text-xs text-faint">{detail}</p>}
136 </div>
137 );
138}
139
140// --- Spike ------------------------------------------------------------------------
141
Fast pages, required checks on the branch, self-hosted runners, honest incidents142// In its own module, so the app shell carries it without the billing page.
143export { SpikeBanner } from "./spike-banner";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look144
145// --- Alerts -----------------------------------------------------------------------
146
147export function Alerts({ alerts }: { alerts: UsageAlert[] }) {
148 const shown = alerts.filter((alert) => alert.level >= 50).sort((a, b) => b.level - a.level);
149 if (shown.length === 0) return null;
150 return (
151 <ul className="mb-6 space-y-2">
152 {shown.map((alert) => {
153 const tone = alertTone(alert.level);
154 return (
155 <li
156 key={`${alert.meter}-${alert.level}`}
157 className={`flex gap-2.5 rounded-lg border px-4 py-2.5 text-sm ${
158 tone === "stopped" ? "border-danger/40 bg-danger/5" : tone === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
159 }`}
160 >
161 <Gauge size={15} className={`mt-0.5 shrink-0 ${tone === "stopped" ? "text-danger" : tone === "warning" ? "text-warn" : "text-muted"}`} />
162 <span>{alert.message || alertText(alert)}</span>
163 </li>
164 );
165 })}
166 </ul>
167 );
168}
169
170// --- The plan -----------------------------------------------------------------------
171
172const STATUS_TONE: Record<PlanStatus["kind"], "plain" | "accent" | "warn"> = {
173 free: "plain",
174 trial: "plain",
175 paid: "accent",
176 canceling: "warn",
177 past_due: "warn",
178 comped: "accent",
179 enterprise: "accent",
180};
181
182export function PlanCard({
183 state,
184 status,
185 entitlements,
186 owner,
187 enabled,
188 live,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas189 meters,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look190 error,
191}: {
192 state: FeatureState | null;
193 status: PlanStatus;
194 entitlements: Entitlements | null;
195 owner: boolean;
196 enabled: boolean;
197 live: boolean;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas198 /** This month's usage by meter, from billing's `usage_meters`. */
199 meters: MeterUsage[] | null;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look200 error?: string;
201}) {
202 const plan = state?.plan;
203 const subscription = state?.subscription ?? null;
204 const on = ["paid", "canceling", "comped", "enterprise"].includes(status.kind);
205 return (
206 <Card
207 id="plan"
208 tone={STATUS_TONE[status.kind]}
209 icon={<Sparkles size={16} />}
210 title={
211 <>
212 {plan?.title ?? "g1t"}
213 <span
214 className={`rounded-full px-2 py-0.5 text-xs ${
215 status.kind === "past_due" || status.kind === "canceling"
216 ? "bg-warn/15 text-warn"
217 : on
Merge branch 'worktree-agent-ad7c6d88d93adc817'218 ? "bg-success/15 text-success"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look219 : "border border-line text-muted"
220 }`}
221 >
222 {status.label}
223 {status.kind === "canceling" && subscription?.periodEnd ? `, ${new Date(subscription.periodEnd).toLocaleDateString()}` : ""}
224 </span>
225 </>
226 }
227 about={
228 on
229 ? "Everything on g1t for the workspace: agents, checks, workflows, the merge queue, deployments and semantic search."
230 : "The forge is free: repositories, issues, pull requests, reviews, search and the audit log. Anything that runs compute needs the plan, or the trial."
231 }
232 aside={
233 <p className="shrink-0 sm:text-right">
234 <span className="text-2xl font-semibold tabular-nums tracking-tight">${((plan?.monthlyCents ?? 2000) / 100).toFixed(0)}</span>
235 <span className="text-sm text-muted"> / month</span>
236 <span className="block text-xs text-faint">per workspace, never per seat</span>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person237 <span className="block text-xs text-faint">
238 {plan?.cardFeeCents ? `+ ${dollars(plan.cardFeeCents * 10_000)} card processing fee, ${PLUS_TAX}` : "Plus tax where it applies"}
239 </span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look240 </p>
241 }
242 >
243 {plan && (
244 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
245 {plan.includes.map((line) => (
246 <li key={line} className="flex gap-2">
Merge branch 'worktree-agent-ad7c6d88d93adc817'247 <span className="text-success">✓</span>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look248 {line}
249 </li>
250 ))}
251 </ul>
252 )}
253 {plan && <p className="mt-3 text-xs text-faint">{plan.overage}</p>}
254
255 {on && entitlements?.includedMicros ? (
256 <Meter
257 label="Included usage this month"
258 used={entitlements.includedUsedMicros ?? 0}
259 of={entitlements.includedMicros}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas260 calm
261 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 look262 />
263 ) : null}
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas264 {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 look265
266 {status.kind === "comped" || status.kind === "enterprise" ? (
267 <p className="mt-4 text-sm text-muted">
268 {status.kind === "comped"
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging269 ? "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 look270 : "An enterprise pays for this workspace, on its own invoice."}
271 </p>
272 ) : !enabled ? (
273 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so the plan cannot be started here.</p>
274 ) : !owner ? (
275 <p className="mt-4 text-sm text-muted">An owner manages the plan.</p>
276 ) : (
277 <Form method="post" className="mt-5 flex flex-wrap items-center gap-3">
278 <input type="hidden" name="feature" value={plan?.feature ?? "plan"} />
279 {status.kind === "free" || status.kind === "trial" ? (
Merge branch 'worktree-agent-a985c01f43538af01'280 <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 look281 <CreditCard size={14} />
282 Start the g1t plan
Merge branch 'worktree-agent-a985c01f43538af01'283 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look284 ) : status.kind === "canceling" ? (
Merge branch 'worktree-agent-a985c01f43538af01'285 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look286 Keep the plan
Merge branch 'worktree-agent-a985c01f43538af01'287 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look288 ) : null}
289 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
Merge branch 'worktree-agent-a985c01f43538af01'290 <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 look291 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
292 <ArrowUpRight size={14} />
Merge branch 'worktree-agent-a985c01f43538af01'293 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 )}
295 {status.kind === "paid" && subscription && (
Merge branch 'worktree-agent-a985c01f43538af01'296 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look297 End at the end of the period
Merge branch 'worktree-agent-a985c01f43538af01'298 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look299 )}
300 {!live && (status.kind === "free" || status.kind === "trial") && (
301 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
302 )}
303 </Form>
304 )}
305 <ErrorText>{error}</ErrorText>
306 {(status.kind === "free" || status.kind === "trial") && (
307 <p className="mt-4 text-xs text-faint">
308 Your first month starts at a {wholeDollars(100_000_000)} limit on usage past what is included, so a stolen card cannot run
309 up a bill. It rises as payments clear, and you can prepay or ask to raise it at once.
310 </p>
311 )}
312 </Card>
313 );
314}
315
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas316/**
317 * This month's usage, one line per meter, in dollars at cost plus 20% and
318 * in what was used. No quotas: every line is metered from the first unit.
319 */
320function MonthUsage({
321 meters,
322 on,
323 comped,
324 entitlements,
325}: {
326 meters: MeterUsage[];
327 on: boolean;
328 comped: boolean;
329 entitlements: Entitlements | null;
330}) {
331 const freeStorage = gigabytes(entitlements?.freePrivateStorageBytes ?? 1_000_000_000);
332 const freeGit = (entitlements?.gitOperationsIncluded ?? 50_000).toLocaleString("en-US");
333 const rows = shownMeters(meters, on);
334 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 look335 return (
336 <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 quotas337 <div className="flex items-baseline justify-between gap-4">
338 <p className="text-xs font-medium text-muted">This month's usage</p>
339 <p className="text-xs text-faint">At cost plus 20%</p>
340 </div>
341 <ul className="mt-2 divide-y divide-line/60">
342 {rows.map((row) => (
343 <li key={row.key} className="flex items-baseline justify-between gap-4 py-2 text-sm">
344 <span className="min-w-0">
345 <span>{row.label}</span>
346 {(row.quantity || row.micros === 0) && (
347 <span className="block truncate text-xs text-faint">{row.quantity ?? "None yet"}</span>
348 )}
349 </span>
350 <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 look351 </li>
352 ))}
353 </ul>
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas354 <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 for355 {/* At price, before the plan's included usage, the trial and the pools paid
356 their part: Usage shows what was charged. */}
357 <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 quotas358 <span className="font-medium tabular-nums">{dollars(total)}</span>
359 </div>
360 <p className="mt-2 text-xs text-faint">
361 {comped
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging362 ? "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 quotas363 : on
364 ? `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 charging365 : `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 quotas366 </p>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look367 </div>
368 );
369}
370
371// --- The trial -----------------------------------------------------------------------
372
373export function TrialCard({
374 entitlements,
375 trialMicros,
376 owner,
377 enabled,
378 error,
379}: {
380 entitlements: Entitlements | null;
381 trialMicros: number;
382 owner: boolean;
383 enabled: boolean;
384 error?: string;
385}) {
386 const verified = entitlements?.trialVerified ?? false;
387 const left = entitlements?.trialMicrosLeft ?? 0;
388 return (
389 <Card
390 id="trial"
391 icon={<ShieldCheck size={16} />}
392 title={`Try it with ${wholeDollars(trialMicros)} of usage`}
393 about={
394 verified
395 ? left > 0
396 ? "Your trial pays for agents, checks and workflows until it is used. It is given once per workspace."
397 : "This workspace has no trial left. Start the plan to keep going."
398 : "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."
399 }
400 >
401 {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />}
402 {!verified && enabled && owner && (
403 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
Merge branch 'worktree-agent-a985c01f43538af01'404 <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 look405 <ShieldCheck size={14} />
406 Check a card
Merge branch 'worktree-agent-a985c01f43538af01'407 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look408 <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span>
409 </Form>
410 )}
411 {!verified && !owner && <p className="mt-3 text-sm text-muted">An owner can do the card check.</p>}
412 <ErrorText>{error}</ErrorText>
413 </Card>
414 );
415}
416
417// --- Spend limit -----------------------------------------------------------------------
418
Merge branch 'worktree-agent-a985c01f43538af01'419export 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 look420 const range = spendRange(limit);
421 const spent = limit.spentMicros ?? 0;
422 const spendLimit = limit.spendLimitMicros;
423 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "full" : "fixed";
424 const tone = limit.state === "stopped" ? "danger" : limit.state === "warning" ? "warn" : "plain";
425 return (
426 <Card
427 id="limit"
428 tone={tone}
429 icon={<Gauge size={16} />}
430 title="Spend limit"
431 about="What on-demand usage may reach this month, past what the plan includes. At the limit new work waits; runs already going finish."
432 aside={limit.firstMonth ? <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">First month</span> : null}
433 >
434 {limit.account.startsWith("ent_") && (
435 <p className="mt-2 text-sm text-muted">
436 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are for all of its
437 workspaces together.
438 </p>
439 )}
440 <div className="grid gap-x-8 sm:grid-cols-2">
441 <Meter
442 label="Charged this month"
443 used={spent}
444 of={spendLimit}
445 detail={
446 mode === "automatic"
447 ? "Automatic: $200, or twice last month's spend, within what is available."
448 : mode === "fixed"
449 ? "A limit you set."
450 : "As high as is available."
451 }
452 />
453 <Meter
454 label="Not yet paid"
455 used={limit.exposureMicros}
456 of={limit.ceilingMicros}
457 detail={limit.growth ?? "Charged to the card as it nears the limit, so work keeps going."}
458 />
459 </div>
460 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
461
462 <div className="mt-5 rounded-lg border border-line bg-bg/40 p-4 text-sm">
463 <p>
464 {range.selfServeMicros == null ? (
465 "No ceiling: set any limit."
466 ) : (
467 <>
468 You can set up to <span className="font-medium tabular-nums">{wholeDollars(range.selfServeMicros)}</span> yourself: the
469 highest limit this workspace has had{(limit.prepaidMicros ?? 0) > 0 ? ", plus what is prepaid" : ""}.
470 </>
471 )}
472 </p>
473 <p className="mt-1 text-muted">
474 {range.raiseOnceMicros != null ? (
475 <>
476 Once, you can raise it yourself to <span className="tabular-nums text-fg">{wholeDollars(range.raiseOnceMicros)}</span>.
477 Past that, prepay or use Raise my limit.
478 </>
479 ) : range.raisedAt ? (
480 <>The one-time raise was used on {new Date(range.raisedAt).toLocaleDateString()}. Prepay or use Raise my limit for more.</>
481 ) : range.selfServeMicros != null ? (
482 "For more, prepay or use Raise my limit."
483 ) : null}
484 </p>
485 </div>
486
487 {owner && (
488 <Form method="post" className="mt-4">
489 <input type="hidden" name="intent" value="spend-limit" />
490 <fieldset>
491 <legend className="text-xs text-muted">Your monthly spend limit</legend>
492 <RadioGroup name="mode" defaultValue={mode} className="mt-2 gap-2.5 text-sm">
493 <label className="flex cursor-pointer items-center gap-2">
494 <RadioGroupItem value="automatic" />
495 Automatic
496 </label>
497 <label className="flex cursor-pointer flex-wrap items-center gap-2">
498 <RadioGroupItem value="fixed" />
499 Fixed at
500 <DollarInput
501 name="limit"
502 label="Spend limit in dollars"
503 defaultValue={mode === "fixed" && spendLimit != null ? String(spendLimit / 1_000_000) : ""}
504 placeholder={range.selfServeMicros != null ? String(range.selfServeMicros / 1_000_000) : "500"}
505 />
506 </label>
507 <label className="flex cursor-pointer items-center gap-2">
508 <RadioGroupItem value="full" />
509 Everything available{range.selfServeMicros != null ? ` (${wholeDollars(range.selfServeMicros)})` : ""}
510 </label>
511 {range.raiseOnceMicros != null && (
512 <label className="flex cursor-pointer items-center gap-2">
513 <RadioGroupItem value="raise" />
514 Use my one-time raise, to the amount above or up to {wholeDollars(range.raiseOnceMicros)}
515 </label>
516 )}
517 </RadioGroup>
518 </fieldset>
519 <div className="mt-3 flex flex-wrap items-center gap-3">
Merge branch 'worktree-agent-a985c01f43538af01'520 <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look521 Save limit
Merge branch 'worktree-agent-a985c01f43538af01'522 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look523 <a href="#raise" className="text-sm text-muted hover:text-fg">
524 Need more? Raise my limit
525 </a>
526 </div>
527 <ErrorText>{error}</ErrorText>
528 </Form>
529 )}
530 </Card>
531 );
532}
533
534// --- Raise my limit -----------------------------------------------------------------------
535
536function RequestList({ requests }: { requests: LimitRequest[] }) {
537 if (requests.length === 0) return null;
538 return (
539 <ul className="mt-4 divide-y divide-line rounded-lg border border-line text-sm">
540 {requests.map((request) => (
541 <li key={request.id} className="px-4 py-2.5">
542 <p className="flex flex-wrap items-center justify-between gap-2">
543 <span>
544 {request.kind === "limit" ? `Asked for ${wholeDollars(request.amountMicros)}` : "Told g1t about this month"}
545 <span className="text-faint"> · {new Date(request.createdAt).toLocaleDateString()}</span>
546 </span>
547 <span
548 className={`rounded-full px-2 py-0.5 text-xs ${
549 request.status === "approved"
Merge branch 'worktree-agent-ad7c6d88d93adc817'550 ? "bg-success/15 text-success"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look551 : request.status === "declined"
552 ? "bg-danger/15 text-danger"
553 : "border border-line text-muted"
554 }`}
555 >
556 {requestStatus(request)}
557 </span>
558 </p>
559 {request.answer && <p className="mt-1 text-muted">{request.answer}</p>}
560 </li>
561 ))}
562 </ul>
563 );
564}
565
Merge branch 'worktree-agent-a985c01f43538af01'566export 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 look567 const mine = requests.filter((request) => request.kind === "limit");
568 const waiting = mine.some((request) => request.status === "open");
569 return (
570 <Card
571 id="raise"
572 title="Raise my limit"
573 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."
574 >
575 {owner && !waiting && (
576 <Form method="post" className="mt-4 space-y-3">
577 <input type="hidden" name="intent" value="request" />
578 <input type="hidden" name="kind" value="limit" />
579 <div className="flex flex-wrap gap-4">
580 <label className="text-sm">
581 <span className="mb-1 block text-xs text-muted">Limit you need</span>
582 <DollarInput name="amount" label="Limit you need in dollars" placeholder="2000" />
583 </label>
584 <label className="text-sm">
585 <span className="mb-1 block text-xs text-muted">What you expect to spend a month</span>
586 <DollarInput name="expected" label="Expected monthly spend in dollars" placeholder="1500" />
587 </label>
588 </div>
589 <label className="block text-sm">
590 <span className="mb-1 block text-xs text-muted">What it is for</span>
591 <textarea
592 name="reason"
593 rows={2}
594 maxLength={2000}
595 placeholder="A launch next week: agents on 40 issues across three projects."
596 className={`${CONTROL} w-full`}
597 />
598 </label>
Merge branch 'worktree-agent-a985c01f43538af01'599 <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 look600 Send the request
Merge branch 'worktree-agent-a985c01f43538af01'601 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look602 <ErrorText>{error}</ErrorText>
603 </Form>
604 )}
605 {owner && waiting && <p className="mt-3 text-sm text-muted">Your request is with g1t. You can send another once it is answered.</p>}
606 {!owner && <p className="mt-3 text-sm text-muted">An owner can ask.</p>}
607 <RequestList requests={mine} />
608 </Card>
609 );
610}
611
612// --- Prepay -----------------------------------------------------------------------
613
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging614/**
615 * Credit g1t gave the workspace: what is left, each grant in a line with
616 * its expiry, and what each was for. Spent before anything prepaid.
617 */
618export function CreditsCard({ credits }: { credits: Credits }) {
619 const open = credits.grants.filter((grant) => grant.state === "open");
620 const past = credits.grants.filter((grant) => grant.state !== "open");
621 return (
622 <Card
623 id="credits"
624 icon={<Gift size={16} />}
625 title="Credits from g1t"
626 about="Credit g1t gave this workspace. It pays for usage before anything prepaid, the soonest-expiring first. Unused credit stops counting when it expires."
627 aside={
628 <p className="shrink-0 sm:text-right">
629 <span className="block text-xs text-muted">Credit left</span>
630 <span className="text-xl font-semibold tabular-nums">{dollars(credits.leftMicros)}</span>
631 </p>
632 }
633 >
634 <ul className="mt-4 divide-y divide-line rounded-lg border border-line">
635 {[...open, ...past].map((grant) => (
636 <li key={grant.id} className={`px-3.5 py-2.5 text-sm ${grant.state === "open" ? "" : "text-muted"}`}>
637 <p className="flex flex-wrap items-center gap-x-2 gap-y-1">
638 <span className={`tabular-nums ${grant.state === "open" ? "font-medium" : ""}`}>{creditLine(grant)}</span>
639 <span className="rounded-full border border-line px-2 py-px text-xs text-muted">{CREDIT_KIND[grant.kind]}</span>
640 </p>
641 <p className="mt-0.5 text-xs text-faint">
642 {grant.kind === "refund" && grant.refundFor ? `For ${grant.refundFor}. ` : ""}
643 {grant.note} · given {shortDay(grant.createdAt)}
644 </p>
645 </li>
646 ))}
647 </ul>
648 </Card>
649 );
650}
651
Merge Stripe Tax, the card fee on card payments, and one free workspace per person652export function PrepayCard({
653 prepaidMicros,
654 owner,
655 live,
656 cardFee,
657 error,
658}: {
659 prepaidMicros: number;
660 owner: boolean;
661 live: boolean;
662 /** The card fee as billing charges it, to show it before paying. */
663 cardFee?: { on: boolean; percentMicros: number; fixedCents: number } | null;
664 error?: string;
665}) {
666 const fees = PREPAY.presets.map((amount) => `${dollars(cardFeeCents(amount * 100, cardFee) * 10_000)} on ${wholeDollars(amount * 1_000_000)}`);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look667 return (
668 <Card
669 id="prepay"
670 icon={<Landmark size={16} />}
671 title="Prepay"
672 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."
673 aside={
674 <p className="shrink-0 sm:text-right">
675 <span className="block text-xs text-muted">Prepaid balance</span>
676 <span className="text-xl font-semibold tabular-nums">{dollars(prepaidMicros)}</span>
677 </p>
678 }
679 >
680 {owner ? (
681 <Form method="post" className="mt-4 space-y-3">
682 <input type="hidden" name="intent" value="prepay" />
683 <div className="flex flex-wrap items-center gap-2">
684 {PREPAY.presets.map((amount) => (
Merge branch 'worktree-agent-a985c01f43538af01'685 <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 look686 {wholeDollars(amount * 1_000_000)}
Merge branch 'worktree-agent-a985c01f43538af01'687 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look688 ))}
689 <span className="text-sm text-muted">or</span>
690 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
Merge branch 'worktree-agent-a985c01f43538af01'691 {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */}
692 <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 look693 <CreditCard size={14} />
694 Prepay
Merge branch 'worktree-agent-a985c01f43538af01'695 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look696 </div>
697 <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
698 <label className="flex cursor-pointer items-center gap-2">
699 <RadioGroupItem value="card" />
700 By card, with 3-D Secure ({wholeDollars(PREPAY.min * 1_000_000)} to {wholeDollars(PREPAY.maxCard * 1_000_000)})
701 </label>
702 <label className="flex cursor-pointer items-center gap-2">
703 <RadioGroupItem value="bank_transfer" />
704 By bank transfer (from {wholeDollars(PREPAY.bankFrom * 1_000_000)}; counted when it arrives)
705 </label>
706 </RadioGroup>
Merge Stripe Tax, the card fee on card payments, and one free workspace per person707 <p className="text-xs text-faint">
708 {cardFee?.on ? `By card, a card processing fee is its own line (${fees.join(", ")}); a bank transfer has none. ` : ""}
709 Tax is added where it applies, from your billing address; what you prepay is credited in full.
710 </p>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look711 {!live && <p className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</p>}
712 <ErrorText>{error}</ErrorText>
713 </Form>
714 ) : (
715 <p className="mt-3 text-sm text-muted">An owner can prepay.</p>
716 )}
717 </Card>
718 );
719}
720
721// --- Caps -----------------------------------------------------------------------
722
Merge branch 'worktree-agent-a985c01f43538af01'723export 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 look724 return (
725 <Card
726 id="caps"
727 title="Caps on agents"
728 about={
729 <>
730 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
731 its own. Blank goes back to {wholeDollars(CAPS.run.default)} and {wholeDollars(CAPS.issue.default)}. A cap g1t set for this
732 workspace wins.
733 </>
734 }
735 >
736 <dl className="mt-4 grid grid-cols-2 gap-4 text-sm sm:max-w-md">
737 <div>
738 <dt className="text-xs text-muted">Per run</dt>
739 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.runCapMicros)}</dd>
740 </div>
741 <div>
742 <dt className="text-xs text-muted">Per issue</dt>
743 <dd className="mt-0.5 text-lg font-semibold tabular-nums">{wholeDollars(entitlements.issueCapMicros)}</dd>
744 </div>
745 </dl>
746 {owner && (
747 <Form method="post" className="mt-4 flex flex-wrap items-end gap-4">
748 <input type="hidden" name="intent" value="caps" />
749 <label className="text-sm">
750 <span className="mb-1 block text-xs text-muted">
751 Per run ({wholeDollars(CAPS.run.min)} to {wholeDollars(CAPS.run.max)})
752 </span>
753 <DollarInput name="run" label="Run cap in dollars" defaultValue={String(entitlements.runCapMicros / 1_000_000)} />
754 </label>
755 <label className="text-sm">
756 <span className="mb-1 block text-xs text-muted">
757 Per issue ({wholeDollars(CAPS.issue.min)} to {wholeDollars(CAPS.issue.max)})
758 </span>
759 <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} />
760 </label>
Merge branch 'worktree-agent-a985c01f43538af01'761 <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…">
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look762 Save caps
Merge branch 'worktree-agent-a985c01f43538af01'763 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look764 </Form>
765 )}
766 <ErrorText>{error}</ErrorText>
767 <p className="mt-3 text-xs text-faint">
768 {entitlements.maxConcurrentAgents} agents at a time{entitlements.maxRunMinutes ? `, ${entitlements.maxRunMinutes} minutes a run` : ""}
769 {entitlements.firstMonth ? " in the first month" : ""}.
770 </p>
771 </Card>
772 );
773}
774
775// --- Overage -----------------------------------------------------------------------
776
Merge branch 'worktree-agent-a985c01f43538af01'777export 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 look778 const mine = requests.filter((request) => request.kind === "overage");
779 const waiting = mine.some((request) => request.status === "open");
780 return (
781 <Card
782 id="overage"
783 title="Spent more than you meant to? Tell us."
784 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."
785 >
786 {owner && !waiting && (
787 <Form method="post" className="mt-4 space-y-3">
788 <input type="hidden" name="intent" value="request" />
789 <input type="hidden" name="kind" value="overage" />
790 <textarea
791 name="reason"
792 rows={2}
793 maxLength={2000}
794 aria-label="What happened"
795 placeholder="An agent kept retrying a failing check on Tuesday night."
796 className={`${CONTROL} w-full`}
797 />
Merge branch 'worktree-agent-a985c01f43538af01'798 <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 look799 Tell g1t
Merge branch 'worktree-agent-a985c01f43538af01'800 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look801 <ErrorText>{error}</ErrorText>
802 </Form>
803 )}
804 {owner && waiting && <p className="mt-3 text-sm text-muted">g1t has your note and answers within one business day.</p>}
805 {!owner && <p className="mt-3 text-sm text-muted">An owner can tell g1t.</p>}
806 <RequestList requests={mine} />
807 </Card>
808 );
809}

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