Skip to content

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

809 lines34,065 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 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 { 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. */
107export 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}) {
121 const part = share(used, of);
122 const bar = calm ? "bg-success" : of != null && used >= of ? "bg-danger" : part >= 0.75 ? "bg-warn" : "bg-success";
123 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
142// In its own module, so the app shell carries it without the billing page.
143export { SpikeBanner } from "./spike-banner";
144
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,
189 meters,
190 error,
191}: {
192 state: FeatureState | null;
193 status: PlanStatus;
194 entitlements: Entitlements | null;
195 owner: boolean;
196 enabled: boolean;
197 live: boolean;
198 /** This month's usage by meter, from billing's `usage_meters`. */
199 meters: MeterUsage[] | null;
200 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
218 ? "bg-success/15 text-success"
219 : "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>
237 <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>
240 </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">
247 <span className="text-success">✓</span>
248 {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}
260 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."
262 />
263 ) : null}
264 {meters && <MonthUsage meters={meters} on={on} comped={status.kind === "comped"} entitlements={entitlements} />}
265
266 {status.kind === "comped" || status.kind === "enterprise" ? (
267 <p className="mt-4 text-sm text-muted">
268 {status.kind === "comped"
269 ? "This workspace has a 100% discount from g1t: the plan and its usage are shown at their price, and nothing is charged."
270 : "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" ? (
280 <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…">
281 <CreditCard size={14} />
282 Start the g1t plan
283 </SubmitButton>
284 ) : status.kind === "canceling" ? (
285 <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…">
286 Keep the plan
287 </SubmitButton>
288 ) : null}
289 {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && (
290 <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…">
291 {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"}
292 <ArrowUpRight size={14} />
293 </SubmitButton>
294 )}
295 {status.kind === "paid" && subscription && (
296 <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…">
297 End at the end of the period
298 </SubmitButton>
299 )}
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
316/**
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);
335 return (
336 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
337 <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>
351 </li>
352 ))}
353 </ul>
354 <div className="mt-1 flex items-baseline justify-between gap-4 border-t border-line pt-2.5 text-sm">
355 {/* 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>
358 <span className="font-medium tabular-nums">{dollars(total)}</span>
359 </div>
360 <p className="mt-2 text-xs text-faint">
361 {comped
362 ? "What this workspace's usage comes to at price. Its 100% discount takes all of it off."
363 : 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.`
365 : `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.`}
366 </p>
367 </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">
404 <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…">
405 <ShieldCheck size={14} />
406 Check a card
407 </SubmitButton>
408 <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
419export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) {
420 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">
520 <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…">
521 Save limit
522 </SubmitButton>
523 <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"
550 ? "bg-success/15 text-success"
551 : 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
566export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
567 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>
599 <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…">
600 Send the request
601 </SubmitButton>
602 <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
614/**
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
652export 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)}`);
667 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) => (
685 <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…">
686 {wholeDollars(amount * 1_000_000)}
687 </SubmitButton>
688 ))}
689 <span className="text-sm text-muted">or</span>
690 <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" />
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…">
693 <CreditCard size={14} />
694 Prepay
695 </SubmitButton>
696 </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>
707 <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>
711 {!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
723export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) {
724 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>
761 <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…">
762 Save caps
763 </SubmitButton>
764 </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
777export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) {
778 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 />
798 <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…">
799 Tell g1t
800 </SubmitButton>
801 <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}