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