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

696 lines30,665 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A flat project menu with Code first, views as tabs, and Stripe for every owner1import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket } from "lucide-react";
Agents as a team: lifecycle, merge queue, billing and a new shell2import { Form, Link, data, redirect, useNavigation } from "react-router";
3
Deployments: a preview for every pull request, production on g1t.page4import {
5 DEPLOYMENTS_ALLOWANCE,
6 MICROS_PER_DOLLAR,
7 type DeployUsage,
8 type Feature,
9 type FeatureState,
Usage limits: unpaid usage can only go so far10 type Limit,
Two limits, real invoices, trust that grows by itself, sales signals11 type WorkspaceInvoice,
Deployments: a preview for every pull request, production on g1t.page12} from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell13
14import type { Route } from "./+types/billing";
Social cards for every page: og.g1t.sh15import { page } from "../../lib/meta";
The statement is a month at a time, a line per kind of charge16import { StatementView } from "../../components/statement";
Workspace names and icons, and a component kit for every control17import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
The statement is a month at a time, a line per kind of charge18import { Button, ErrorText } from "../../components/ui";
Deployments: a preview for every pull request, production on g1t.page19import { billing, deployments } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell20import {
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
Social cards for every page: og.g1t.sh31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `Billing · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell33}
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) {
Paid features: a workspace turns on Deployments with a monthly plan46 // 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 }
Agents as a team: lifecycle, merge queue, billing and a new shell51 await billing.confirm(slug, viewer, session);
52 throw redirect(`/${slug}/-/billing?added=1`);
53 }
The statement is a month at a time, a line per kind of charge54 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
55 const [account, statement, features, deployUsage, limit, invoices] = await Promise.all([
Agents as a team: lifecycle, merge queue, billing and a new shell56 billing.account(slug, viewer),
The statement is a month at a time, a line per kind of charge57 billing.statement(slug, viewer, url.searchParams.get("month"), group),
Paid features: a workspace turns on Deployments with a monthly plan58 billing.features(slug, viewer),
Deployments: a preview for every pull request, production on g1t.page59 deployments.usage(slug, viewer),
Usage limits: unpaid usage can only go so far60 billing.limit(slug, viewer),
Two limits, real invoices, trust that grows by itself, sales signals61 billing.invoices(slug, viewer).catch(() => null),
Agents as a team: lifecycle, merge queue, billing and a new shell62 ]);
63 return {
64 slug,
65 role,
66 account: unwrap(account),
The statement is a month at a time, a line per kind of charge67 statement: unwrap(statement),
68 group,
Paid features: a workspace turns on Deployments with a monthly plan69 features: unwrap(features),
Deployments: a preview for every pull request, production on g1t.page70 deployUsage: deployUsage.ok ? deployUsage.value : null,
Usage limits: unpaid usage can only go so far71 limit: limit.ok ? limit.value : null,
Two limits, real invoices, trust that grows by itself, sales signals72 invoices: invoices?.ok ? invoices.value : [],
Agents as a team: lifecycle, merge queue, billing and a new shell73 added: url.searchParams.has("added"),
Paid features: a workspace turns on Deployments with a monthly plan74 subscribed: url.searchParams.has("subscribed"),
Agents as a team: lifecycle, merge queue, billing and a new shell75 };
76}
77
78export async function action({ request, params, context }: Route.ActionArgs) {
79 assertSameOrigin(request);
80 const user = requireUser(context, request);
81 const form = await request.formData();
Paid features: a workspace turns on Deployments with a monthly plan82 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
83 const intent = form.get("intent");
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace84 if (intent === "portal") {
85 // Card, invoices and billing details live on Stripe's own page.
86 const started = await billing.billingPortal(user, params.owner, page);
87 if (!started.ok) return { error: started.error.message };
88 throw redirect(started.value.url);
89 }
Two limits, real invoices, trust that grows by itself, sales signals90 if (intent === "spend-limit") {
91 // automatic, fixed (with an amount), or none.
92 const mode = String(form.get("mode") ?? "automatic");
Usage limits: unpaid usage can only go so far93 const amount = Number(form.get("limit"));
Two limits, real invoices, trust that grows by itself, sales signals94 if (mode === "fixed" && !(Number.isFinite(amount) && amount >= 1)) {
95 return { error: "A spend limit is a dollar amount, $1 or more." };
Usage limits: unpaid usage can only go so far96 }
97 const set = await billing.setSpendLimit(
98 user,
99 params.owner,
Two limits, real invoices, trust that grows by itself, sales signals100 mode === "fixed" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
101 mode === "none",
Usage limits: unpaid usage can only go so far102 );
103 return set.ok ? null : { error: set.error.message };
104 }
Paid features: a workspace turns on Deployments with a monthly plan105 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
106 const feature = String(form.get("feature")) as Feature;
107 if (intent !== "subscribe") {
108 const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume");
109 return changed.ok ? null : { error: changed.error.message };
110 }
111 const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`);
112 if (!started.ok) return { error: started.error.message };
113 throw redirect(started.value.url);
114 }
Agents as a team: lifecycle, merge queue, billing and a new shell115 const dollars = Math.trunc(Number(form.get("amount")));
116 const started = await billing.checkout(
117 user,
118 params.owner,
119 Number.isFinite(dollars) ? dollars * 100 : 0,
120 // Spelled out: a form post arrives at a data address, not the page's.
121 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
122 );
123 if (!started.ok) return { error: started.error.message };
124 throw redirect(started.value.url);
125}
126
127/** Millionths of a dollar as dollars, to the cent or finer. */
128function dollars(micros: number, digits = 2): string {
129 const sign = micros < 0 ? "−" : "";
130 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
131}
132
133export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
The statement is a month at a time, a line per kind of charge134 const { slug, role, account, statement, group, features, deployUsage, limit, invoices, added, subscribed } = loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell135 const { status } = account;
136 const paying = useNavigation().state === "submitting";
137 const empty = account.balanceMicros <= 0;
138 return (
139 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
140 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks141 {status.free && (
142 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
143 <h2 className="font-medium">Free while g1t is being built out</h2>
144 <p className="mt-1.5 max-w-2xl text-sm text-muted">
145 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
146 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
147 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
148 will come later, and we will say so well before anything is charged.
149 </p>
150 </div>
151 )}
A flat project menu with Code first, views as tabs, and Stripe for every owner152 {account.status.enabled && (
153 <section className="mb-8 flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5">
154 <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent ring-1 ring-accent/30">
155 <Receipt size={18} />
156 </span>
157 <div className="min-w-0 grow">
158 <h2 className="font-medium">Billing on Stripe</h2>
159 <p className="mt-0.5 text-sm text-muted">
160 {account.card
161 ? `${account.card.brand[0].toUpperCase()}${account.card.brand.slice(1)} ending ${account.card.last4}. `
162 : "No card yet. "}
163 Your card, invoices and receipts, and the billing email, address and tax ID.
164 </p>
165 </div>
166 {role === "owner" ? (
167 <Form method="post">
168 <Button variant="accent" type="submit" name="intent" value="portal" disabled={paying}>
169 Open Stripe billing
170 <ArrowUpRight size={14} />
171 </Button>
172 </Form>
173 ) : (
174 <p className="text-xs text-faint">An owner manages billing.</p>
175 )}
176 </section>
177 )}
178
Usage limits: unpaid usage can only go so far179 {limit && account.status.enabled && (
180 <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
181 )}
182
A flat project menu with Code first, views as tabs, and Stripe for every owner183 {account.status.enabled && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace184 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
185 <div className="flex flex-wrap items-center justify-between gap-3">
186 <div>
187 <h2 className="font-medium">Card and invoices</h2>
188 {account.card ? (
189 <p className="mt-1 text-sm">
190 <span className="capitalize">{account.card.brand}</span> ending{" "}
191 <span className="font-mono">{account.card.last4}</span>
192 <span className="text-muted">
193 {" "}
194 · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}
195 </span>
196 </p>
197 ) : (
198 <p className="mt-1 max-w-xl text-sm text-muted">
199 No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each
200 month closes, so work never stops for a payment. Saving it charges nothing.
201 </p>
202 )}
203 <p className="mt-2 max-w-xl text-xs text-faint">
204 Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t
205 never sees card numbers.
206 </p>
207 </div>
208 {role === "owner" && (
209 <Form method="post">
210 <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}>
211 <CreditCard size={14} />
212 {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"}
213 </Button>
214 </Form>
215 )}
216 </div>
217 {account.card && (
218 <p className="mt-3 text-xs text-faint">
219 Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card
220 stops work until it is paid.
221 </p>
222 )}
223 {!account.status.live && (
224 <p className="mt-3 text-xs text-faint">
225 Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged
226 automatically.
227 </p>
228 )}
229 </section>
230 )}
231
Two limits, real invoices, trust that grows by itself, sales signals232 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
233
Paid features: a workspace turns on Deployments with a monthly plan234 <h2 className="font-medium">Plans</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell235 <p className="mt-1 max-w-2xl text-sm text-muted">
Paid features: a workspace turns on Deployments with a monthly plan236 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
237 g1t is.
238 </p>
239 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
240 <div className="mt-5 space-y-4">
241 {features.map((state) => (
242 <PlanCard
243 key={state.plan.feature}
244 state={state}
245 owner={role === "owner"}
246 enabled={account.status.enabled}
247 live={account.status.live}
248 busy={paying}
Deployments: a preview for every pull request, production on g1t.page249 usage={state.plan.feature === "deployments" ? deployUsage : null}
Paid features: a workspace turns on Deployments with a monthly plan250 />
251 ))}
252 </div>
253 <div className="mt-2">
254 <ErrorText>{actionData?.error}</ErrorText>
255 </div>
256
257 <h2 className="mt-12 font-medium">Agent credit</h2>
258 <p className="mt-1 max-w-2xl text-sm text-muted">
Agents as a team: lifecycle, merge queue, billing and a new shell259 g1t agents that work on this workspace's repositories are paid for from
Billing accounts, terms and enterprises; g1t is no longer free260 its account: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
261 queue and workflows, is metered by the second past 500 free minutes a month. Credit added here pays usage in
262 advance; the usage limit above decides whether work starts.
Agents as a team: lifecycle, merge queue, billing and a new shell263 </p>
264
265 <div
266 className={`mt-5 rounded-xl border p-5 ${
267 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
268 }`}
269 >
270 <p className="text-xs text-muted">Balance</p>
271 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
272 {dollars(account.balanceMicros)}
273 </p>
274 {added && (
275 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
276 )}
Paid features: a workspace turns on Deployments with a monthly plan277 {status.free && !features.some((state) => state.on && state.subscription) ? (
Billing: nothing to add while it is free278 <p className="mt-3 text-sm text-muted">
279 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
280 pricing starts.
281 </p>
282 ) : !status.enabled ? (
Agents as a team: lifecycle, merge queue, billing and a new shell283 <p className="mt-3 text-sm text-muted">
284 Payments are not set up on this g1t yet, so nothing is charged and
285 agents are limited to selected accounts.
286 </p>
287 ) : role === "owner" ? (
288 <Form method="post" className="mt-4">
Paid features: a workspace turns on Deployments with a monthly plan289 <p className="text-sm text-muted">
290 {status.free
291 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
292 : "Add credit by card:"}
293 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell294 <div className="mt-2 flex flex-wrap gap-2">
295 {AMOUNTS.map((amount) => (
296 <Button
297 key={amount}
298 variant={amount === 25 ? "accent" : "quiet"}
299 type="submit"
300 name="amount"
301 value={amount}
302 disabled={paying}
303 >
304 <CreditCard size={14} />${amount}
305 </Button>
306 ))}
307 </div>
308 {!status.live && (
309 <p className="mt-3 text-xs text-faint">
310 Payments are in test mode. No real card is charged; use Stripe's
311 test card 4242 4242 4242 4242 with any future date and any code.
312 </p>
313 )}
314 </Form>
315 ) : (
316 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
317 )}
318 </div>
319
The statement is a month at a time, a line per kind of charge320 <StatementView slug={slug} statement={statement} group={group} />
Agents as a team: lifecycle, merge queue, billing and a new shell321 </div>
322
323 <aside className="space-y-5 text-sm">
324 <section className="rounded-xl border border-line bg-surface p-5">
325 <h3 className="font-medium">How it is charged</h3>
326 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
327 <li>
A flat project menu with Code first, views as tabs, and Stripe for every owner328 Usage is charged after it runs, to the workspace that owns the repository: agents at what the model cost
329 plus {account.marginPercent}%, and sandbox time, builds and apps at what they cost g1t plus a set markup.
Agents as a team: lifecycle, merge queue, billing and a new shell330 </li>
331 <li>
A flat project menu with Code first, views as tabs, and Stripe for every owner332 Each month closes with an itemised invoice, charged to the card on file. Near your limit, g1t sends one
333 sooner, so work keeps going.
Agents as a team: lifecycle, merge queue, billing and a new shell334 </li>
335 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read336 With your own model provider, connected under{" "}
337 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
338 Integrations
339 </Link>
A flat project menu with Code first, views as tabs, and Stripe for every owner340 , the provider bills you for the model and each run here is {dollars(account.orchestrationFeeMicros)}, plus
341 its sandbox time.
Agents as a team: lifecycle, merge queue, billing and a new shell342 </li>
A flat project menu with Code first, views as tabs, and Stripe for every owner343 <li>No seats: add as many people and agents as you like.</li>
Agents as a team: lifecycle, merge queue, billing and a new shell344 </ul>
A flat project menu with Code first, views as tabs, and Stripe for every owner345 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
346 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
347 <FileText size={14} /> Usage, run by run
348 </Link>
349 <a href="https://g1t.sh/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
350 <CreditCard size={14} /> Today's prices
351 </a>
352 <a
353 href="https://docs.g1t.sh/guides/usage-and-billing/"
354 className="flex items-center gap-2 text-muted hover:text-fg"
355 >
356 <ArrowUpRight size={14} /> How usage and billing work
357 </a>
358 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell359 </section>
360 </aside>
361 </div>
362 );
363}
Paid features: a workspace turns on Deployments with a monthly plan364
365/** A paid feature: what it costs and includes, and its plan. */
366function PlanCard({
367 state,
368 owner,
369 enabled,
370 live,
371 busy,
Deployments: a preview for every pull request, production on g1t.page372 usage,
Paid features: a workspace turns on Deployments with a monthly plan373}: {
374 state: FeatureState;
375 owner: boolean;
376 enabled: boolean;
377 live: boolean;
378 busy: boolean;
Deployments: a preview for every pull request, production on g1t.page379 usage: DeployUsage | null;
Paid features: a workspace turns on Deployments with a monthly plan380}) {
381 const { plan, subscription } = state;
382 const ending = subscription?.status === "canceling";
383 const owed = subscription?.status === "past_due";
384 return (
385 <section
386 className={`rounded-xl border p-5 ${
387 state.on && subscription
388 ? "border-accent/40 bg-accent/5"
389 : owed
390 ? "border-warn/40 bg-warn/5"
391 : "border-line bg-surface"
392 }`}
393 >
394 <div className="flex flex-wrap items-start justify-between gap-4">
395 <div className="min-w-0">
396 <h3 className="flex flex-wrap items-center gap-2 font-medium">
397 <Rocket size={15} className="text-accent" />
398 {plan.title}
399 {state.on && subscription && (
400 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
401 {ending ? "Ends " : "On"}
402 {subscription.periodEnd && (
403 <>
404 {ending ? "" : " · renews "}
405 {new Date(subscription.periodEnd).toLocaleDateString()}
406 </>
407 )}
408 </span>
409 )}
410 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
411 </h3>
412 <p className="mt-1 text-sm text-muted">
413 Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge.
414 </p>
415 </div>
416 <p className="shrink-0 text-right">
417 <span className="text-2xl font-semibold tabular-nums tracking-tight">
418 ${(plan.monthlyCents / 100).toFixed(0)}
419 </span>
420 <span className="text-sm text-muted"> / month</span>
421 </p>
422 </div>
423 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
424 {plan.includes.map((line) => (
425 <li key={line} className="flex gap-2">
426 <span className="text-accent">✓</span>
427 {line}
428 </li>
429 ))}
430 </ul>
431 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
Deployments: a preview for every pull request, production on g1t.page432 {state.on && usage && <DeployMeter usage={usage} />}
Paid features: a workspace turns on Deployments with a monthly plan433 {!enabled ? (
434 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
435 ) : !owner ? (
436 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
437 ) : (
438 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
439 <input type="hidden" name="feature" value={plan.feature} />
440 {!state.on || !subscription ? (
441 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
442 <CreditCard size={14} />
443 Turn on {plan.title}
444 </Button>
445 ) : ending ? (
446 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
447 Keep {plan.title}
448 </Button>
449 ) : (
450 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
451 Turn off at the end of the period
452 </Button>
453 )}
454 {!live && !state.on && (
455 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
456 )}
457 </Form>
458 )}
459 </section>
460 );
461}
Deployments: a preview for every pull request, production on g1t.page462
463/** This month's use of the Deployments plan against what it includes. */
464function DeployMeter({ usage }: { usage: DeployUsage }) {
465 const a = DEPLOYMENTS_ALLOWANCE;
466 const rows: [string, number, number, (n: number) => string][] = [
467 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
468 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
469 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
470 ];
471 return (
472 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
473 <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
474 <ul className="mt-2 space-y-2.5">
475 {rows.map(([label, used, included, show]) => (
476 <li key={label} className="text-sm">
477 <div className="flex justify-between gap-4">
478 <span className="text-muted">{label}</span>
479 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
480 {show(used)} <span className="text-faint">of {show(included)}</span>
481 </span>
482 </div>
483 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
484 <div
485 className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
486 style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
487 />
488 </div>
489 </li>
490 ))}
491 </ul>
492 <p className="mt-3 text-xs text-faint">
493 Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost.
494 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
495 </p>
496 </div>
497 );
498}
Usage limits: unpaid usage can only go so far499
500const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
Two limits, real invoices, trust that grows by itself, sales signals501 new: { label: "New", detail: "No payment to g1t yet." },
502 paid: { label: "Paid", detail: "Grows with every payment." },
503 established: { label: "Established", detail: "Follows your monthly spend." },
Usage limits: unpaid usage can only go so far504 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
Billing accounts, terms and enterprises; g1t is no longer free505 internal: { label: "Comped", detail: "g1t covers this workspace's usage: nothing is charged, and there is no limit." },
Usage limits: unpaid usage can only go so far506};
507
Two limits, real invoices, trust that grows by itself, sales signals508/** One meter: how much of a limit is used. */
509function Meter({ used, of, state }: { used: number; of: number | null; state: "ok" | "warning" | "stopped" }) {
510 const share = of ? Math.min(1, used / Math.max(of, 1)) : 0;
511 const bar = state === "stopped" ? "bg-danger" : state === "warning" ? "bg-warn" : "bg-accent";
512 return (
513 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
514 <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
515 </div>
516 );
517}
518
519function meterState(used: number, of: number | null): "ok" | "warning" | "stopped" {
520 if (of == null) return "ok";
521 if (used >= of) return "stopped";
522 return used * 5 >= of * 4 ? "warning" : "ok";
523}
524
Usage limits: unpaid usage can only go so far525/**
Two limits, real invoices, trust that grows by itself, sales signals526 * The workspace's two limits, side by side: the owners' monthly spend
527 * limit, which protects them from a surprise; and what g1t lets go unpaid,
528 * which grows with what they pay and is charged to the card as it nears.
Usage limits: unpaid usage can only go so far529 */
530function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
531 const tone =
532 limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
533 const trust = TRUST[limit.trust];
Two limits, real invoices, trust that grows by itself, sales signals534 const spent = limit.spentMicros ?? 0;
535 const spendLimit = limit.spendLimitMicros;
536 const available = limit.availableMicros ?? limit.ceilingMicros;
537 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "none" : "fixed";
538 if (limit.trust === "internal") {
539 return (
540 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
541 <div className="flex flex-wrap items-baseline justify-between gap-2">
542 <h2 className="font-medium">Limits</h2>
543 <span className="rounded-full border border-accent/40 px-2 py-0.5 text-xs text-accent">Comped</span>
544 </div>
545 <p className="mt-1 text-sm text-muted">{trust.detail}</p>
546 </section>
547 );
548 }
Usage limits: unpaid usage can only go so far549 return (
550 <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
551 <div className="flex flex-wrap items-baseline justify-between gap-2">
Two limits, real invoices, trust that grows by itself, sales signals552 <h2 className="font-medium">Limits</h2>
Usage limits: unpaid usage can only go so far553 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
554 </div>
Billing accounts, terms and enterprises; g1t is no longer free555 {limit.account.startsWith("ent_") && (
Two limits, real invoices, trust that grows by itself, sales signals556 <p className="mt-1 text-sm text-muted">
Billing accounts, terms and enterprises; g1t is no longer free557 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
558 for all of its workspaces together.
559 </p>
560 )}
Two limits, real invoices, trust that grows by itself, sales signals561
562 <div className="mt-5 grid gap-6 sm:grid-cols-2">
563 <div>
564 <p className="text-xs text-muted">Spent this month</p>
565 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
566 {dollars(spent)}
567 <span className="text-base font-normal text-muted">
568 {spendLimit == null ? " · no spend limit" : ` of ${dollars(spendLimit)}`}
569 </span>
570 </p>
571 {spendLimit != null && <Meter used={spent} of={spendLimit} state={meterState(spent, spendLimit)} />}
572 <p className="mt-2 text-xs text-faint">
573 {mode === "automatic"
574 ? "Your spend limit is automatic: $200, or twice last month's spend, so it keeps up as you grow."
575 : mode === "fixed"
576 ? "A spend limit you set. At it, work stops until the month turns."
577 : "No spend limit: work never stops for spend, only for what g1t lets go unpaid."}
578 </p>
579 </div>
580 <div>
581 <p className="text-xs text-muted">Not yet paid</p>
582 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
583 {dollars(limit.exposureMicros)}
584 <span className="text-base font-normal text-muted">
585 {available == null ? "" : ` of ${dollars(available)} available`}
Usage limits: unpaid usage can only go so far586 </span>
Two limits, real invoices, trust that grows by itself, sales signals587 </p>
588 {available != null && (
589 <Meter used={limit.exposureMicros} of={available} state={meterState(limit.exposureMicros, available)} />
Usage limits: unpaid usage can only go so far590 )}
Two limits, real invoices, trust that grows by itself, sales signals591 <p className="mt-2 text-xs text-faint">
592 With a card on file, g1t charges it as this nears what is available, so work keeps going.
593 {limit.growth ? ` ${limit.growth}` : ""}
594 </p>
595 </div>
596 </div>
597
598 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
599
600 {owner && (
601 <Form method="post" className="mt-5 border-t border-line pt-4">
602 <fieldset>
603 <legend className="text-xs text-muted">Your monthly spend limit</legend>
Workspace names and icons, and a component kit for every control604 <RadioGroup name="mode" defaultValue={mode} className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
605 <label className="flex cursor-pointer items-center gap-2">
606 <RadioGroupItem value="automatic" />
Two limits, real invoices, trust that grows by itself, sales signals607 Automatic
608 </label>
Workspace names and icons, and a component kit for every control609 <label className="flex cursor-pointer items-center gap-2">
610 <RadioGroupItem value="fixed" />
Two limits, real invoices, trust that grows by itself, sales signals611 Fixed at
612 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
613 <span className="text-muted">$</span>
614 <input
615 name="limit"
616 type="number"
617 min={1}
618 step={1}
619 defaultValue={mode === "fixed" && spendLimit != null ? spendLimit / MICROS_PER_DOLLAR : ""}
620 placeholder="500"
621 className="w-24 bg-transparent px-1 py-1 tabular-nums outline-none"
622 />
623 </span>
624 </label>
Workspace names and icons, and a component kit for every control625 <label className="flex cursor-pointer items-center gap-2">
626 <RadioGroupItem value="none" />
Two limits, real invoices, trust that grows by itself, sales signals627 None
628 </label>
629 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
630 Save
631 </Button>
Workspace names and icons, and a component kit for every control632 </RadioGroup>
Two limits, real invoices, trust that grows by itself, sales signals633 </fieldset>
Usage limits: unpaid usage can only go so far634 <ErrorText>{error}</ErrorText>
Two limits, real invoices, trust that grows by itself, sales signals635 <p className="mt-3 text-xs text-faint">
636 Need more than {available != null ? dollars(available) : "this"} available?{" "}
637 <a href="mailto:billing@g1t.sh" className="text-fg hover:underline">
638 Contact us
639 </a>{" "}
640 and we will set terms that fit.
641 </p>
Usage limits: unpaid usage can only go so far642 </Form>
643 )}
644 </section>
645 );
646}
Two limits, real invoices, trust that grows by itself, sales signals647
648/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
649function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
650 return (
651 <section className="mb-10">
652 <h2 className="font-medium">Invoices</h2>
653 <p className="mt-1 text-sm text-muted">
654 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also
655 on Stripe's billing page.
656 </p>
657 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
658 {invoices.map((invoice) => (
659 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
660 <div className="flex flex-wrap items-center gap-3">
661 <span className="font-medium">
662 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
663 </span>
664 <span
665 className={`rounded-full border px-2 py-0.5 text-xs ${
666 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
667 }`}
668 >
669 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
670 </span>
671 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
672 {invoice.hostedUrl && (
673 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
674 View
675 </a>
676 )}
677 {invoice.pdfUrl && (
678 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
679 PDF
680 </a>
681 )}
682 </div>
683 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
684 {invoice.lines.map((line) => (
685 <li key={line.description} className="flex justify-between gap-4">
686 <span>{line.description}</span>
687 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
688 </li>
689 ))}
690 </ul>
691 </li>
692 ))}
693 </ul>
694 </section>
695 );
696}