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

697 lines30,366 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.

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