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

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