Skip to content

pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/routes/workspace/billing.tsx

779 lines34,839 bytesCodeBlame
1import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket, Users } from "lucide-react";
2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
4import {
5 DEPLOYMENTS_ALLOWANCE,
6 MICROS_PER_DOLLAR,
7 type DeployUsage,
8 type Entitlements,
9 type Feature,
10 type FeatureState,
11 type Limit,
12 type WorkspaceInvoice,
13} from "@g1t/contracts";
14
15import type { Route } from "./+types/billing";
16import { page } from "../../lib/meta";
17import { StatementView } from "../../components/statement";
18import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
19import { Button, ErrorText } from "../../components/ui";
20import { billing, deployments } from "../../lib/services.server";
21import {
22 assertSameOrigin,
23 getViewer,
24 requireUser,
25 roleIn,
26 unwrap,
27} from "../../lib/session.server";
28
29/** What can be added in one payment, in dollars. */
30const AMOUNTS = [10, 25, 50, 100];
31
32export function meta({ params, ...args }: Route.MetaArgs) {
33 return page(args, { title: `Billing · ${params.owner} · g1t` });
34}
35
36export async function loader({ params, context, request }: Route.LoaderArgs) {
37 const viewer = getViewer(context);
38 const role = roleIn(viewer, params.owner);
39 // Members only; to anyone else the page does not exist.
40 if (!role) throw data(null, { status: 404 });
41 const slug = params.owner.toLowerCase();
42
43 // Back from the payment page: credit it, then drop the id from the address.
44 const url = new URL(request.url);
45 const session = url.searchParams.get("session");
46 if (session) {
47 // The same page returns from both a credit payment and a plan.
48 if (url.searchParams.get("plan")) {
49 await billing.confirmSubscription(slug, viewer, session);
50 throw redirect(`/${slug}/-/billing?subscribed=1`);
51 }
52 await billing.confirm(slug, viewer, session);
53 throw redirect(`/${slug}/-/billing?added=1`);
54 }
55 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
56 const now = new Date();
57 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
58 const [account, statement, features, deployUsage, limit, invoices, thisMonth, allTime, entitlements] = await Promise.all([
59 billing.account(slug, viewer),
60 billing.statement(slug, viewer, url.searchParams.get("month"), group),
61 billing.features(slug, viewer),
62 deployments.usage(slug, viewer),
63 billing.limit(slug, viewer),
64 billing.invoices(slug, viewer).catch(() => null),
65 billing.usage(slug, viewer, monthStart).catch(() => null),
66 billing.usage(slug, viewer, "1970-01-01T00:00:00.000Z").catch(() => null),
67 billing.entitlements(slug).catch(() => null),
68 ]);
69 return {
70 slug,
71 role,
72 account: unwrap(account),
73 statement: unwrap(statement),
74 group,
75 features: unwrap(features),
76 deployUsage: deployUsage.ok ? deployUsage.value : null,
77 limit: limit.ok ? limit.value : null,
78 invoices: invoices?.ok ? invoices.value : [],
79 spent: {
80 month: thisMonth?.ok ? thisMonth.value.spentMicros : null,
81 total: allTime?.ok ? allTime.value.spentMicros : null,
82 added: allTime?.ok ? allTime.value.addedMicros : null,
83 },
84 entitlements,
85 added: url.searchParams.has("added"),
86 subscribed: url.searchParams.has("subscribed"),
87 };
88}
89
90export async function action({ request, params, context }: Route.ActionArgs) {
91 assertSameOrigin(request);
92 const user = requireUser(context, request);
93 const form = await request.formData();
94 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
95 const intent = form.get("intent");
96 if (intent === "portal") {
97 // Card, invoices and billing details live on Stripe's own page.
98 const started = await billing.billingPortal(user, params.owner, page);
99 if (!started.ok) return { error: started.error.message };
100 throw redirect(started.value.url);
101 }
102 if (intent === "spend-limit") {
103 // automatic, fixed (with an amount), or none.
104 const mode = String(form.get("mode") ?? "automatic");
105 const amount = Number(form.get("limit"));
106 if (mode === "fixed" && !(Number.isFinite(amount) && amount >= 1)) {
107 return { error: "A spend limit is a dollar amount, $1 or more." };
108 }
109 const set = await billing.setSpendLimit(
110 user,
111 params.owner,
112 mode === "fixed" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
113 mode === "none",
114 );
115 return set.ok ? null : { error: set.error.message };
116 }
117 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
118 const feature = String(form.get("feature")) as Feature;
119 if (intent !== "subscribe") {
120 const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume");
121 return changed.ok ? null : { error: changed.error.message };
122 }
123 const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`);
124 if (!started.ok) return { error: started.error.message };
125 throw redirect(started.value.url);
126 }
127 const dollars = Math.trunc(Number(form.get("amount")));
128 const started = await billing.checkout(
129 user,
130 params.owner,
131 Number.isFinite(dollars) ? dollars * 100 : 0,
132 // Spelled out: a form post arrives at a data address, not the page's.
133 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
134 );
135 if (!started.ok) return { error: started.error.message };
136 throw redirect(started.value.url);
137}
138
139/** Millionths of a dollar as dollars, to the cent or finer. */
140function dollars(micros: number, digits = 2): string {
141 const sign = micros < 0 ? "−" : "";
142 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
143}
144
145export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
146 const { slug, role, account, statement, group, spent, features, deployUsage, limit, invoices, entitlements, added, subscribed } =
147 loaderData;
148 const { status } = account;
149 const paying = useNavigation().state === "submitting";
150 const empty = account.balanceMicros <= 0;
151 return (
152 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
153 <div className="min-w-0">
154 {status.free && (
155 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
156 <h2 className="font-medium">Free while g1t is being built out</h2>
157 <p className="mt-1.5 max-w-2xl text-sm text-muted">
158 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
159 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
160 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
161 will come later, and we will say so well before anything is charged.
162 </p>
163 </div>
164 )}
165 {account.status.enabled && (
166 <section className="mb-8 flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5">
167 <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent ring-1 ring-accent/30">
168 <Receipt size={18} />
169 </span>
170 <div className="min-w-0 grow">
171 <h2 className="font-medium">Billing on Stripe</h2>
172 <p className="mt-0.5 text-sm text-muted">
173 {account.card
174 ? `${account.card.brand[0].toUpperCase()}${account.card.brand.slice(1)} ending ${account.card.last4}. `
175 : "No card yet. "}
176 Your card, invoices and receipts, and the billing email, address and tax ID.
177 </p>
178 </div>
179 {role === "owner" ? (
180 <Form method="post">
181 <Button variant="accent" type="submit" name="intent" value="portal" disabled={paying}>
182 Open Stripe billing
183 <ArrowUpRight size={14} />
184 </Button>
185 </Form>
186 ) : (
187 <p className="text-xs text-faint">An owner manages billing.</p>
188 )}
189 </section>
190 )}
191
192 {limit && account.status.enabled && (
193 <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
194 )}
195
196 {account.status.enabled && (
197 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
198 <div className="flex flex-wrap items-center justify-between gap-3">
199 <div>
200 <h2 className="font-medium">Card and invoices</h2>
201 {account.card ? (
202 <p className="mt-1 text-sm">
203 <span className="capitalize">{account.card.brand}</span> ending{" "}
204 <span className="font-mono">{account.card.last4}</span>
205 <span className="text-muted">
206 {" "}
207 · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}
208 </span>
209 </p>
210 ) : (
211 <p className="mt-1 max-w-xl text-sm text-muted">
212 No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each
213 month closes, so work never stops for a payment. Saving it charges nothing.
214 </p>
215 )}
216 <p className="mt-2 max-w-xl text-xs text-faint">
217 Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t
218 never sees card numbers.
219 </p>
220 </div>
221 {role === "owner" && (
222 <Form method="post">
223 <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}>
224 <CreditCard size={14} />
225 {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"}
226 </Button>
227 </Form>
228 )}
229 </div>
230 {account.card && (
231 <p className="mt-3 text-xs text-faint">
232 Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card
233 stops work until it is paid.
234 </p>
235 )}
236 {!account.status.live && (
237 <p className="mt-3 text-xs text-faint">
238 Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged
239 automatically.
240 </p>
241 )}
242 </section>
243 )}
244
245 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
246
247 <h2 className="font-medium">Plans</h2>
248 <p className="mt-1 max-w-2xl text-sm text-muted">
249 Plans are turned on per workspace, at one flat price a month for everyone in it: never per person. They are
250 charged including while the rest of g1t is free.
251 </p>
252 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
253 <div className="mt-5 space-y-4">
254 {features.map((state) => (
255 <PlanCard
256 key={state.plan.feature}
257 state={state}
258 owner={role === "owner"}
259 enabled={account.status.enabled}
260 live={account.status.live}
261 busy={paying}
262 usage={state.plan.feature === "deployments" ? deployUsage : null}
263 entitlements={entitlements}
264 />
265 ))}
266 </div>
267 <div className="mt-2">
268 <ErrorText>{actionData?.error}</ErrorText>
269 </div>
270
271 <h2 className="mt-12 font-medium">Agent credit</h2>
272 <p className="mt-1 max-w-2xl text-sm text-muted">
273 g1t agents that work on this workspace's repositories are paid for from
274 its account: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
275 queue and workflows, is metered by the second, from the first. Credit added here pays usage in
276 advance; the usage limit above decides whether work starts.
277 </p>
278
279 <div
280 className={`mt-5 rounded-xl border p-5 ${
281 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
282 }`}
283 >
284 <dl className="flex flex-wrap items-end gap-x-10 gap-y-4">
285 <div>
286 <dt className="text-xs text-muted">Balance</dt>
287 <dd className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
288 {dollars(account.balanceMicros)}
289 </dd>
290 </div>
291 {spent.month !== null && (
292 <div>
293 <dt className="text-xs text-muted">Spent this month</dt>
294 <dd className="mt-1 text-xl font-medium tabular-nums">{dollars(spent.month)}</dd>
295 </div>
296 )}
297 {spent.total !== null && (
298 <div>
299 <dt className="text-xs text-muted">Spent in total</dt>
300 <dd className="mt-1 text-xl font-medium tabular-nums">{dollars(spent.total)}</dd>
301 </div>
302 )}
303 {spent.added !== null && spent.added !== 0 && (
304 <div>
305 <dt className="text-xs text-muted">Paid and credited in total</dt>
306 <dd className="mt-1 text-xl font-medium tabular-nums text-muted">{dollars(spent.added)}</dd>
307 </div>
308 )}
309 </dl>
310 {added && (
311 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
312 )}
313 {status.free && !features.some((state) => state.on && state.subscription) ? (
314 <p className="mt-3 text-sm text-muted">
315 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
316 pricing starts.
317 </p>
318 ) : !status.enabled ? (
319 <p className="mt-3 text-sm text-muted">
320 Payments are not set up on this g1t yet, so nothing is charged and
321 agents are limited to selected accounts.
322 </p>
323 ) : role === "owner" ? (
324 <Form method="post" className="mt-4">
325 <p className="text-sm text-muted">
326 {status.free
327 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
328 : "Add credit by card:"}
329 </p>
330 <div className="mt-2 flex flex-wrap gap-2">
331 {AMOUNTS.map((amount) => (
332 <Button
333 key={amount}
334 variant={amount === 25 ? "accent" : "quiet"}
335 type="submit"
336 name="amount"
337 value={amount}
338 disabled={paying}
339 >
340 <CreditCard size={14} />${amount}
341 </Button>
342 ))}
343 </div>
344 {!status.live && (
345 <p className="mt-3 text-xs text-faint">
346 Payments are in test mode. No real card is charged; use Stripe's
347 test card 4242 4242 4242 4242 with any future date and any code.
348 </p>
349 )}
350 </Form>
351 ) : (
352 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
353 )}
354 </div>
355
356 <StatementView slug={slug} statement={statement} group={group} />
357 </div>
358
359 <aside className="space-y-5 text-sm">
360 <section className="rounded-xl border border-line bg-surface p-5">
361 <h3 className="font-medium">How it is charged</h3>
362 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
363 <li>
364 Usage is charged after it runs, to the workspace that owns the repository: models, sandbox time, builds
365 and apps at what they cost g1t plus {account.marginPercent}%, which pays for running and building g1t.
366 Nothing is bundled in.
367 </li>
368 <li>
369 Each month closes with an itemised invoice, charged to the card on file. Near your limit, g1t sends one
370 sooner, so work keeps going.
371 </li>
372 <li>
373 With your own model provider, connected under{" "}
374 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
375 Integrations
376 </Link>
377 , the provider bills you for the model, and a run here is charged only its sandbox time.
378 </li>
379 <li>
380 Work on public repositories is paid for by g1t's open-source pool first, up to a monthly cap per
381 repository; the statement says so on each line it paid.
382 </li>
383 <li>
384 No card is charged less than {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}: smaller
385 amounts carry over to the next invoice.
386 </li>
387 <li>No seats: add as many people and agents as you like.</li>
388 </ul>
389 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
390 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
391 <FileText size={14} /> Usage, run by run
392 </Link>
393 <a href="https://g1t.sh/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
394 <CreditCard size={14} /> Today's prices
395 </a>
396 <a
397 href="https://docs.g1t.sh/guides/usage-and-billing/"
398 className="flex items-center gap-2 text-muted hover:text-fg"
399 >
400 <ArrowUpRight size={14} /> How usage and billing work
401 </a>
402 </div>
403 </section>
404 </aside>
405 </div>
406 );
407}
408
409/** A paid feature: what it costs and includes, and its plan. */
410function PlanCard({
411 state,
412 owner,
413 enabled,
414 live,
415 busy,
416 usage,
417 entitlements,
418}: {
419 state: FeatureState;
420 owner: boolean;
421 enabled: boolean;
422 live: boolean;
423 busy: boolean;
424 usage: DeployUsage | null;
425 entitlements: Entitlements | null;
426}) {
427 const { plan, subscription } = state;
428 const ending = subscription?.status === "canceling";
429 const owed = subscription?.status === "past_due";
430 const team = plan.feature === "team";
431 const Icon = team ? Users : Rocket;
432 return (
433 <section
434 className={`rounded-xl border p-5 ${
435 (state.on && subscription) || state.included
436 ? "border-accent/40 bg-accent/5"
437 : owed
438 ? "border-warn/40 bg-warn/5"
439 : "border-line bg-surface"
440 }`}
441 >
442 <div className="flex flex-wrap items-start justify-between gap-4">
443 <div className="min-w-0">
444 <h3 className="flex flex-wrap items-center gap-2 font-medium">
445 <Icon size={15} className="text-accent" />
446 {plan.title}
447 {state.included && (
448 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">Included, no charge</span>
449 )}
450 {state.on && subscription && (
451 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
452 {ending ? "Ends " : "On"}
453 {subscription.periodEnd && (
454 <>
455 {ending ? "" : " · renews "}
456 {new Date(subscription.periodEnd).toLocaleDateString()}
457 </>
458 )}
459 </span>
460 )}
461 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
462 </h3>
463 <p className="mt-1 text-sm text-muted">
464 {team
465 ? "For a workspace that works here every day: a monthly usage credit, more private storage and a longer audit log, at one price for everyone in it."
466 : "Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge."}
467 </p>
468 </div>
469 <p className="shrink-0 text-right">
470 <span className="text-2xl font-semibold tabular-nums tracking-tight">
471 ${(plan.monthlyCents / 100).toFixed(0)}
472 </span>
473 <span className="text-sm text-muted"> / month</span>
474 {team && <span className="block text-xs text-faint">per workspace</span>}
475 </p>
476 </div>
477 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
478 {plan.includes.map((line) => (
479 <li key={line} className="flex gap-2">
480 <span className="text-accent">✓</span>
481 {line}
482 </li>
483 ))}
484 </ul>
485 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
486 {state.on && usage && <DeployMeter usage={usage} entitlements={entitlements} />}
487 {team && state.on && entitlements?.team && <TeamMeter entitlements={entitlements} />}
488 {state.included ? (
489 <p className="mt-4 text-sm text-muted">
490 {plan.title} is on for this workspace at no charge, under terms g1t set with it.
491 </p>
492 ) : !enabled ? (
493 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
494 ) : !owner ? (
495 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
496 ) : (
497 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
498 <input type="hidden" name="feature" value={plan.feature} />
499 {!state.on || !subscription ? (
500 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
501 <CreditCard size={14} />
502 Turn on {plan.title}
503 </Button>
504 ) : ending ? (
505 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
506 Keep {plan.title}
507 </Button>
508 ) : (
509 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
510 Turn off at the end of the period
511 </Button>
512 )}
513 {!live && !state.on && (
514 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
515 )}
516 </Form>
517 )}
518 </section>
519 );
520}
521
522/** This month's Team credit: what of it the month's usage has drawn. */
523function TeamMeter({ entitlements }: { entitlements: Entitlements }) {
524 const used = entitlements.teamCreditUsedMicros;
525 const of = entitlements.teamCreditMicros;
526 return (
527 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
528 <div className="flex justify-between gap-4 text-sm">
529 <span className="text-muted">Credit used this month</span>
530 <span className="tabular-nums">
531 {dollars(used)} <span className="text-faint">of {dollars(of)}</span>
532 </span>
533 </div>
534 <Meter used={used} of={of} state={used >= of ? "warning" : "ok"} />
535 <p className="mt-3 text-xs text-faint">
536 Usage draws on the credit first, at cost plus 20%; past it, usage is charged as usual. It starts again on the
537 1st, and what is unused does not carry over.
538 </p>
539 </div>
540 );
541}
542
543/** This month's use of the Deployments plan against what it includes. */
544function DeployMeter({ usage, entitlements }: { usage: DeployUsage; entitlements: Entitlements | null }) {
545 const a = DEPLOYMENTS_ALLOWANCE;
546 const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60;
547 const rows: [string, number, number, (n: number) => string][] = [
548 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
549 ["Build minutes", Math.ceil(usage.buildSeconds / 60), buildMinutes, (n) => n.toLocaleString("en-US")],
550 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
551 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
552 ];
553 return (
554 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
555 <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
556 <ul className="mt-2 space-y-2.5">
557 {rows.map(([label, used, included, show]) => (
558 <li key={label} className="text-sm">
559 <div className="flex justify-between gap-4">
560 <span className="text-muted">{label}</span>
561 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
562 {show(used)} <span className="text-faint">of {show(included)}</span>
563 </span>
564 </div>
565 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
566 <div
567 className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
568 style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
569 />
570 </div>
571 </li>
572 ))}
573 </ul>
574 <p className="mt-3 text-xs text-faint">
575 Builds past the included minutes are charged by the second; this month's builds cost g1t{" "}
576 {dollars(usage.buildMicros, 4)} in all.
577 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
578 </p>
579 </div>
580 );
581}
582
583const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
584 new: { label: "New", detail: "No payment to g1t yet." },
585 paid: { label: "Paid", detail: "Grows with every payment." },
586 established: { label: "Established", detail: "Follows your monthly spend." },
587 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
588 internal: { label: "Comped", detail: "g1t covers this workspace's usage: nothing is charged, and there is no limit." },
589};
590
591/** One meter: how much of a limit is used. */
592function Meter({ used, of, state }: { used: number; of: number | null; state: "ok" | "warning" | "stopped" }) {
593 const share = of ? Math.min(1, used / Math.max(of, 1)) : 0;
594 const bar = state === "stopped" ? "bg-danger" : state === "warning" ? "bg-warn" : "bg-accent";
595 return (
596 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
597 <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
598 </div>
599 );
600}
601
602function meterState(used: number, of: number | null): "ok" | "warning" | "stopped" {
603 if (of == null) return "ok";
604 if (used >= of) return "stopped";
605 return used * 5 >= of * 4 ? "warning" : "ok";
606}
607
608/**
609 * The workspace's two limits, side by side: the owners' monthly spend
610 * limit, which protects them from a surprise; and what g1t lets go unpaid,
611 * which grows with what they pay and is charged to the card as it nears.
612 */
613function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
614 const tone =
615 limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
616 const trust = TRUST[limit.trust];
617 const spent = limit.spentMicros ?? 0;
618 const spendLimit = limit.spendLimitMicros;
619 const available = limit.availableMicros ?? limit.ceilingMicros;
620 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "none" : "fixed";
621 if (limit.trust === "internal") {
622 return (
623 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
624 <div className="flex flex-wrap items-baseline justify-between gap-2">
625 <h2 className="font-medium">Limits</h2>
626 <span className="rounded-full border border-accent/40 px-2 py-0.5 text-xs text-accent">Comped</span>
627 </div>
628 <p className="mt-1 text-sm text-muted">{trust.detail}</p>
629 </section>
630 );
631 }
632 return (
633 <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
634 <div className="flex flex-wrap items-baseline justify-between gap-2">
635 <h2 className="font-medium">Limits</h2>
636 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
637 </div>
638 {limit.account.startsWith("ent_") && (
639 <p className="mt-1 text-sm text-muted">
640 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
641 for all of its workspaces together.
642 </p>
643 )}
644
645 <div className="mt-5 grid gap-6 sm:grid-cols-2">
646 <div>
647 <p className="text-xs text-muted">Spent this month</p>
648 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
649 {dollars(spent)}
650 <span className="text-base font-normal text-muted">
651 {spendLimit == null ? " · no spend limit" : ` of ${dollars(spendLimit)}`}
652 </span>
653 </p>
654 {spendLimit != null && <Meter used={spent} of={spendLimit} state={meterState(spent, spendLimit)} />}
655 <p className="mt-2 text-xs text-faint">
656 {mode === "automatic"
657 ? "Your spend limit is automatic: $200, or twice last month's spend, so it keeps up as you grow."
658 : mode === "fixed"
659 ? "A spend limit you set. At it, work stops until the month turns."
660 : "No spend limit: work never stops for spend, only for what g1t lets go unpaid."}
661 </p>
662 </div>
663 <div>
664 <p className="text-xs text-muted">Not yet paid</p>
665 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
666 {dollars(limit.exposureMicros)}
667 <span className="text-base font-normal text-muted">
668 {available == null ? "" : ` of ${dollars(available)} available`}
669 </span>
670 </p>
671 {available != null && (
672 <Meter used={limit.exposureMicros} of={available} state={meterState(limit.exposureMicros, available)} />
673 )}
674 <p className="mt-2 text-xs text-faint">
675 With a card on file, g1t charges it as this nears what is available, so work keeps going.
676 {limit.growth ? ` ${limit.growth}` : ""}
677 </p>
678 </div>
679 </div>
680
681 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
682
683 {owner && (
684 <Form method="post" className="mt-5 border-t border-line pt-4">
685 <fieldset>
686 <legend className="text-xs text-muted">Your monthly spend limit</legend>
687 <RadioGroup name="mode" defaultValue={mode} className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
688 <label className="flex cursor-pointer items-center gap-2">
689 <RadioGroupItem value="automatic" />
690 Automatic
691 </label>
692 <label className="flex cursor-pointer items-center gap-2">
693 <RadioGroupItem value="fixed" />
694 Fixed at
695 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
696 <span className="text-muted">$</span>
697 <input
698 name="limit"
699 type="number"
700 min={1}
701 step={1}
702 defaultValue={mode === "fixed" && spendLimit != null ? spendLimit / MICROS_PER_DOLLAR : ""}
703 placeholder="500"
704 className="w-24 bg-transparent px-1 py-1 tabular-nums outline-none"
705 />
706 </span>
707 </label>
708 <label className="flex cursor-pointer items-center gap-2">
709 <RadioGroupItem value="none" />
710 None
711 </label>
712 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
713 Save
714 </Button>
715 </RadioGroup>
716 </fieldset>
717 <ErrorText>{error}</ErrorText>
718 <p className="mt-3 text-xs text-faint">
719 Need more than {available != null ? dollars(available) : "this"} available?{" "}
720 <a href="mailto:billing@g1t.sh" className="text-fg hover:underline">
721 Contact us
722 </a>{" "}
723 and we will set terms that fit.
724 </p>
725 </Form>
726 )}
727 </section>
728 );
729}
730
731/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
732function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
733 return (
734 <section className="mb-10">
735 <h2 className="font-medium">Invoices</h2>
736 <p className="mt-1 text-sm text-muted">
737 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also
738 on Stripe's billing page.
739 </p>
740 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
741 {invoices.map((invoice) => (
742 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
743 <div className="flex flex-wrap items-center gap-3">
744 <span className="font-medium">
745 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
746 </span>
747 <span
748 className={`rounded-full border px-2 py-0.5 text-xs ${
749 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
750 }`}
751 >
752 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
753 </span>
754 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
755 {invoice.hostedUrl && (
756 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
757 View
758 </a>
759 )}
760 {invoice.pdfUrl && (
761 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
762 PDF
763 </a>
764 )}
765 </div>
766 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
767 {invoice.lines.map((line) => (
768 <li key={line.description} className="flex justify-between gap-4">
769 <span>{line.description}</span>
770 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
771 </li>
772 ))}
773 </ul>
774 </li>
775 ))}
776 </ul>
777 </section>
778 );
779}