flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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