flagon-io/g1t

public

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

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

732 lines32,159 bytesCodeBlame

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