Skip to content

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

786 lines33,201 bytesCodeBlame
1/**
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,
4 * Raise my limit, credits from g1t, Prepay, the run and issue caps, and "Spent more than you
5 * meant to?". Each posts to the billing route's action with an `intent`.
6 */
7import { ArrowUpRight, CreditCard, Gauge, Gift, Landmark, ShieldCheck, Sparkles } from "lucide-react";
8import type { ReactNode } from "react";
9import { Form } from "react-router";
10
11import type { Credits, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, UsageAlert } from "@g1t/contracts";
12
13import {
14 CAPS,
15 CREDIT_KIND,
16 PREPAY,
17 type PlanStatus,
18 alertText,
19 alertTone,
20 creditLine,
21 dollars,
22 gigabytes,
23 requestStatus,
24 share,
25 shortDay,
26 shownMeters,
27 spendRange,
28 wholeDollars,
29} from "../lib/billing";
30import { ErrorText, SubmitButton } from "./ui";
31import { 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. */
105export 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}) {
119 const part = share(used, of);
120 const bar = calm ? "bg-success" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-success";
121 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
140// In its own module, so the app shell carries it without the billing page.
141export { SpikeBanner } from "./spike-banner";
142
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,
187 meters,
188 error,
189}: {
190 state: FeatureState | null;
191 status: PlanStatus;
192 entitlements: Entitlements | null;
193 owner: boolean;
194 enabled: boolean;
195 live: boolean;
196 /** This month's usage by meter, from billing's `usage_meters`. */
197 meters: MeterUsage[] | null;
198 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-success/15 text-success"
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-success">✓</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}
255 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."
257 />
258 ) : null}
259 {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
260
261 {status.kind === "comped" || status.kind === "enterprise" ? (
262 <p className="mt-4 text-sm text-muted">
263 {status.kind === "comped"
264 ? "This workspace has a 100% discount from g1t: the plan and its usage are shown at their price, and nothing is charged."
265 : "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" ? (
275 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
276 <CreditCard size={14} />
277 Start the g1t plan
278 </SubmitButton>
279 ) : status.kind === "canceling" ? (
280 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
281 Keep the plan
282 </SubmitButton>
283 ) : null}
284 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
285 <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…">
286 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
287 <ArrowUpRight size={14} />
288 </SubmitButton>
289 )}
290 {status.kind === "paid" && subscription && (
291 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
292 End at the end of the period
293 </SubmitButton>
294 )}
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
311/**
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);
330 return (
331 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
332 <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>
346 </li>
347 ))}
348 </ul>
349 <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm">
350 {/* 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>
353 <span className="font-medium tabular-nums">{dollars(total)}</span>
354 </div>
355 <p className="mt-2 text-xs text-faint">
356 {comped
357 ? "What this workspace's usage comes to at price. Its 100% discount takes all of it off."
358 : 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.`
360 : `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.`}
361 </p>
362 </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">
399 <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…">
400 <ShieldCheck size={14} />
401 Check a card
402 </SubmitButton>
403 <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
414export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
415 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">
515 <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…">
516 Save limit
517 </SubmitButton>
518 <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-success/15 text-success"
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
561export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
562 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>
594 <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…">
595 Send the request
596 </SubmitButton>
597 <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
609/**
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
647export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) {
648 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) => (
666 <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…">
667 {wholeDollars(amount * 1_000_000)}
668 </SubmitButton>
669 ))}
670 <span className="text-sm text-muted">or</span>
671 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
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…">
674 <CreditCard size={14} />
675 Prepay
676 </SubmitButton>
677 </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
700export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) {
701 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>
738 <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…">
739 Save caps
740 </SubmitButton>
741 </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
754export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
755 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 />
775 <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…">
776 Tell g1t
777 </SubmitButton>
778 <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}