flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

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

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