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

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