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

698 lines30,424 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";
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 )}
Usage limits: unpaid usage can only go so far148 {limit && account.status.enabled && (
149 <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
150 )}
151
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace152 {account.status.enabled && limit?.trust !== "internal" && (
153 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
154 <div className="flex flex-wrap items-center justify-between gap-3">
155 <div>
156 <h2 className="font-medium">Card and invoices</h2>
157 {account.card ? (
158 <p className="mt-1 text-sm">
159 <span className="capitalize">{account.card.brand}</span> ending{" "}
160 <span className="font-mono">{account.card.last4}</span>
161 <span className="text-muted">
162 {" "}
163 · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}
164 </span>
165 </p>
166 ) : (
167 <p className="mt-1 max-w-xl text-sm text-muted">
168 No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each
169 month closes, so work never stops for a payment. Saving it charges nothing.
170 </p>
171 )}
172 <p className="mt-2 max-w-xl text-xs text-faint">
173 Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t
174 never sees card numbers.
175 </p>
176 </div>
177 {role === "owner" && (
178 <Form method="post">
179 <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}>
180 <CreditCard size={14} />
181 {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"}
182 </Button>
183 </Form>
184 )}
185 </div>
186 {account.card && (
187 <p className="mt-3 text-xs text-faint">
188 Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card
189 stops work until it is paid.
190 </p>
191 )}
192 {!account.status.live && (
193 <p className="mt-3 text-xs text-faint">
194 Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged
195 automatically.
196 </p>
197 )}
198 </section>
199 )}
200
Two limits, real invoices, trust that grows by itself, sales signals201 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
202
Paid features: a workspace turns on Deployments with a monthly plan203 <h2 className="font-medium">Plans</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell204 <p className="mt-1 max-w-2xl text-sm text-muted">
Paid features: a workspace turns on Deployments with a monthly plan205 Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of
206 g1t is.
207 </p>
208 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
209 <div className="mt-5 space-y-4">
210 {features.map((state) => (
211 <PlanCard
212 key={state.plan.feature}
213 state={state}
214 owner={role === "owner"}
215 enabled={account.status.enabled}
216 live={account.status.live}
217 busy={paying}
Deployments: a preview for every pull request, production on g1t.page218 usage={state.plan.feature === "deployments" ? deployUsage : null}
Paid features: a workspace turns on Deployments with a monthly plan219 />
220 ))}
221 </div>
222 <div className="mt-2">
223 <ErrorText>{actionData?.error}</ErrorText>
224 </div>
225
226 <h2 className="mt-12 font-medium">Agent credit</h2>
227 <p className="mt-1 max-w-2xl text-sm text-muted">
Agents as a team: lifecycle, merge queue, billing and a new shell228 g1t agents that work on this workspace's repositories are paid for from
Billing accounts, terms and enterprises; g1t is no longer free229 its account: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
230 queue and workflows, is metered by the second past 500 free minutes a month. Credit added here pays usage in
231 advance; the usage limit above decides whether work starts.
Agents as a team: lifecycle, merge queue, billing and a new shell232 </p>
233
234 <div
235 className={`mt-5 rounded-xl border p-5 ${
236 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
237 }`}
238 >
239 <p className="text-xs text-muted">Balance</p>
240 <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
241 {dollars(account.balanceMicros)}
242 </p>
243 {added && (
244 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
245 )}
Paid features: a workspace turns on Deployments with a monthly plan246 {status.free && !features.some((state) => state.on && state.subscription) ? (
Billing: nothing to add while it is free247 <p className="mt-3 text-sm text-muted">
248 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
249 pricing starts.
250 </p>
251 ) : !status.enabled ? (
Agents as a team: lifecycle, merge queue, billing and a new shell252 <p className="mt-3 text-sm text-muted">
253 Payments are not set up on this g1t yet, so nothing is charged and
254 agents are limited to selected accounts.
255 </p>
256 ) : role === "owner" ? (
257 <Form method="post" className="mt-4">
Paid features: a workspace turns on Deployments with a monthly plan258 <p className="text-sm text-muted">
259 {status.free
260 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
261 : "Add credit by card:"}
262 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell263 <div className="mt-2 flex flex-wrap gap-2">
264 {AMOUNTS.map((amount) => (
265 <Button
266 key={amount}
267 variant={amount === 25 ? "accent" : "quiet"}
268 type="submit"
269 name="amount"
270 value={amount}
271 disabled={paying}
272 >
273 <CreditCard size={14} />${amount}
274 </Button>
275 ))}
276 </div>
277 {!status.live && (
278 <p className="mt-3 text-xs text-faint">
279 Payments are in test mode. No real card is charged; use Stripe's
280 test card 4242 4242 4242 4242 with any future date and any code.
281 </p>
282 )}
283 </Form>
284 ) : (
285 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
286 )}
287 </div>
288
289 <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3>
290 <div className="mt-3">
291 {ledger.length === 0 ? (
292 <EmptyState title="Nothing yet">
293 Each agent run and each payment appears here.
294 </EmptyState>
295 ) : (
296 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
297 {ledger.map((entry) => (
298 <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm">
299 <div className="min-w-0 grow">
300 {entry.repo && entry.number ? (
301 <Link
302 to={`/${entry.repo}/pull/${entry.number}`}
303 className="block truncate font-medium hover:underline"
304 >
305 {entry.description}
306 </Link>
307 ) : (
308 <p className="truncate font-medium">{entry.description}</p>
309 )}
310 <p className="mt-0.5 text-xs text-faint">
311 <TimeAgo at={entry.createdAt} />
312 {entry.model && ` · ${entry.model}`}
313 {entry.createdBy && ` · ${entry.createdBy}`}
314 </p>
315 </div>
316 <span
317 className={`shrink-0 font-mono text-sm tabular-nums ${
318 entry.amountMicros > 0 ? "text-accent" : "text-muted"
319 }`}
320 >
321 {entry.amountMicros > 0 && "+"}
322 {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)}
323 </span>
324 </li>
325 ))}
326 </ul>
327 )}
328 </div>
329 </div>
330
331 <aside className="space-y-5 text-sm">
332 <section className="rounded-xl border border-line bg-surface p-5">
333 <h3 className="font-medium">How it is charged</h3>
334 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
335 <li>
336 Each run is charged when it finishes, to the workspace that owns the
337 repository, whoever assigned the issue.
338 </li>
339 <li>
340 A change, a review, a revision and a catch-up are each a run. The
341 statement links each to its pull request.
342 </li>
343 <li>
344 Every pull request's session ends with what that run cost before
345 the margin.
346 </li>
347 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read348 With your own model provider, connected under{" "}
349 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
350 Integrations
351 </Link>
352 , the provider bills you for the model and each run here is a flat{" "}
New workspaces are told to connect a model, before anything fails353 {dollars(account.orchestrationFeeMicros)}
354 {status.free && " once pricing starts; nothing while g1t is being built out"}.
Integrations: your own model provider, alerts that open issues, tickets agents read355 </li>
356 <li>
Agents as a team: lifecycle, merge queue, billing and a new shell357 Only members of <span className="font-mono text-fg">{slug}</span> can
358 put agents to work on its repositories.
359 </li>
360 </ul>
361 </section>
362 </aside>
363 </div>
364 );
365}
Paid features: a workspace turns on Deployments with a monthly plan366
367/** A paid feature: what it costs and includes, and its plan. */
368function PlanCard({
369 state,
370 owner,
371 enabled,
372 live,
373 busy,
Deployments: a preview for every pull request, production on g1t.page374 usage,
Paid features: a workspace turns on Deployments with a monthly plan375}: {
376 state: FeatureState;
377 owner: boolean;
378 enabled: boolean;
379 live: boolean;
380 busy: boolean;
Deployments: a preview for every pull request, production on g1t.page381 usage: DeployUsage | null;
Paid features: a workspace turns on Deployments with a monthly plan382}) {
383 const { plan, subscription } = state;
384 const ending = subscription?.status === "canceling";
385 const owed = subscription?.status === "past_due";
386 return (
387 <section
388 className={`rounded-xl border p-5 ${
389 state.on && subscription
390 ? "border-accent/40 bg-accent/5"
391 : owed
392 ? "border-warn/40 bg-warn/5"
393 : "border-line bg-surface"
394 }`}
395 >
396 <div className="flex flex-wrap items-start justify-between gap-4">
397 <div className="min-w-0">
398 <h3 className="flex flex-wrap items-center gap-2 font-medium">
399 <Rocket size={15} className="text-accent" />
400 {plan.title}
401 {state.on && subscription && (
402 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
403 {ending ? "Ends " : "On"}
404 {subscription.periodEnd && (
405 <>
406 {ending ? "" : " · renews "}
407 {new Date(subscription.periodEnd).toLocaleDateString()}
408 </>
409 )}
410 </span>
411 )}
412 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
413 </h3>
414 <p className="mt-1 text-sm text-muted">
415 Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge.
416 </p>
417 </div>
418 <p className="shrink-0 text-right">
419 <span className="text-2xl font-semibold tabular-nums tracking-tight">
420 ${(plan.monthlyCents / 100).toFixed(0)}
421 </span>
422 <span className="text-sm text-muted"> / month</span>
423 </p>
424 </div>
425 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
426 {plan.includes.map((line) => (
427 <li key={line} className="flex gap-2">
428 <span className="text-accent">✓</span>
429 {line}
430 </li>
431 ))}
432 </ul>
433 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
Deployments: a preview for every pull request, production on g1t.page434 {state.on && usage && <DeployMeter usage={usage} />}
Paid features: a workspace turns on Deployments with a monthly plan435 {!enabled ? (
436 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
437 ) : !owner ? (
438 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
439 ) : (
440 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
441 <input type="hidden" name="feature" value={plan.feature} />
442 {!state.on || !subscription ? (
443 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
444 <CreditCard size={14} />
445 Turn on {plan.title}
446 </Button>
447 ) : ending ? (
448 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
449 Keep {plan.title}
450 </Button>
451 ) : (
452 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
453 Turn off at the end of the period
454 </Button>
455 )}
456 {!live && !state.on && (
457 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
458 )}
459 </Form>
460 )}
461 </section>
462 );
463}
Deployments: a preview for every pull request, production on g1t.page464
465/** This month's use of the Deployments plan against what it includes. */
466function DeployMeter({ usage }: { usage: DeployUsage }) {
467 const a = DEPLOYMENTS_ALLOWANCE;
468 const rows: [string, number, number, (n: number) => string][] = [
469 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
470 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
471 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
472 ];
473 return (
474 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
475 <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
476 <ul className="mt-2 space-y-2.5">
477 {rows.map(([label, used, included, show]) => (
478 <li key={label} className="text-sm">
479 <div className="flex justify-between gap-4">
480 <span className="text-muted">{label}</span>
481 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
482 {show(used)} <span className="text-faint">of {show(included)}</span>
483 </span>
484 </div>
485 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
486 <div
487 className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
488 style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
489 />
490 </div>
491 </li>
492 ))}
493 </ul>
494 <p className="mt-3 text-xs text-faint">
495 Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost.
496 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
497 </p>
498 </div>
499 );
500}
Usage limits: unpaid usage can only go so far501
502const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
Two limits, real invoices, trust that grows by itself, sales signals503 new: { label: "New", detail: "No payment to g1t yet." },
504 paid: { label: "Paid", detail: "Grows with every payment." },
505 established: { label: "Established", detail: "Follows your monthly spend." },
Usage limits: unpaid usage can only go so far506 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
Billing accounts, terms and enterprises; g1t is no longer free507 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 far508};
509
Two limits, real invoices, trust that grows by itself, sales signals510/** One meter: how much of a limit is used. */
511function Meter({ used, of, state }: { used: number; of: number | null; state: "ok" | "warning" | "stopped" }) {
512 const share = of ? Math.min(1, used / Math.max(of, 1)) : 0;
513 const bar = state === "stopped" ? "bg-danger" : state === "warning" ? "bg-warn" : "bg-accent";
514 return (
515 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
516 <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
517 </div>
518 );
519}
520
521function meterState(used: number, of: number | null): "ok" | "warning" | "stopped" {
522 if (of == null) return "ok";
523 if (used >= of) return "stopped";
524 return used * 5 >= of * 4 ? "warning" : "ok";
525}
526
Usage limits: unpaid usage can only go so far527/**
Two limits, real invoices, trust that grows by itself, sales signals528 * The workspace's two limits, side by side: the owners' monthly spend
529 * limit, which protects them from a surprise; and what g1t lets go unpaid,
530 * which grows with what they pay and is charged to the card as it nears.
Usage limits: unpaid usage can only go so far531 */
532function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
533 const tone =
534 limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
535 const trust = TRUST[limit.trust];
Two limits, real invoices, trust that grows by itself, sales signals536 const spent = limit.spentMicros ?? 0;
537 const spendLimit = limit.spendLimitMicros;
538 const available = limit.availableMicros ?? limit.ceilingMicros;
539 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "none" : "fixed";
540 if (limit.trust === "internal") {
541 return (
542 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
543 <div className="flex flex-wrap items-baseline justify-between gap-2">
544 <h2 className="font-medium">Limits</h2>
545 <span className="rounded-full border border-accent/40 px-2 py-0.5 text-xs text-accent">Comped</span>
546 </div>
547 <p className="mt-1 text-sm text-muted">{trust.detail}</p>
548 </section>
549 );
550 }
Usage limits: unpaid usage can only go so far551 return (
552 <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
553 <div className="flex flex-wrap items-baseline justify-between gap-2">
Two limits, real invoices, trust that grows by itself, sales signals554 <h2 className="font-medium">Limits</h2>
Usage limits: unpaid usage can only go so far555 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
556 </div>
Billing accounts, terms and enterprises; g1t is no longer free557 {limit.account.startsWith("ent_") && (
Two limits, real invoices, trust that grows by itself, sales signals558 <p className="mt-1 text-sm text-muted">
Billing accounts, terms and enterprises; g1t is no longer free559 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
560 for all of its workspaces together.
561 </p>
562 )}
Two limits, real invoices, trust that grows by itself, sales signals563
564 <div className="mt-5 grid gap-6 sm:grid-cols-2">
565 <div>
566 <p className="text-xs text-muted">Spent this month</p>
567 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
568 {dollars(spent)}
569 <span className="text-base font-normal text-muted">
570 {spendLimit == null ? " · no spend limit" : ` of ${dollars(spendLimit)}`}
571 </span>
572 </p>
573 {spendLimit != null && <Meter used={spent} of={spendLimit} state={meterState(spent, spendLimit)} />}
574 <p className="mt-2 text-xs text-faint">
575 {mode === "automatic"
576 ? "Your spend limit is automatic: $200, or twice last month's spend, so it keeps up as you grow."
577 : mode === "fixed"
578 ? "A spend limit you set. At it, work stops until the month turns."
579 : "No spend limit: work never stops for spend, only for what g1t lets go unpaid."}
580 </p>
581 </div>
582 <div>
583 <p className="text-xs text-muted">Not yet paid</p>
584 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
585 {dollars(limit.exposureMicros)}
586 <span className="text-base font-normal text-muted">
587 {available == null ? "" : ` of ${dollars(available)} available`}
Usage limits: unpaid usage can only go so far588 </span>
Two limits, real invoices, trust that grows by itself, sales signals589 </p>
590 {available != null && (
591 <Meter used={limit.exposureMicros} of={available} state={meterState(limit.exposureMicros, available)} />
Usage limits: unpaid usage can only go so far592 )}
Two limits, real invoices, trust that grows by itself, sales signals593 <p className="mt-2 text-xs text-faint">
594 With a card on file, g1t charges it as this nears what is available, so work keeps going.
595 {limit.growth ? ` ${limit.growth}` : ""}
596 </p>
597 </div>
598 </div>
599
600 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
601
602 {owner && (
603 <Form method="post" className="mt-5 border-t border-line pt-4">
604 <fieldset>
605 <legend className="text-xs text-muted">Your monthly spend limit</legend>
606 <div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
607 <label className="flex items-center gap-2">
608 <input type="radio" name="mode" value="automatic" defaultChecked={mode === "automatic"} className="accent-accent" />
609 Automatic
610 </label>
611 <label className="flex items-center gap-2">
612 <input type="radio" name="mode" value="fixed" defaultChecked={mode === "fixed"} className="accent-accent" />
613 Fixed at
614 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
615 <span className="text-muted">$</span>
616 <input
617 name="limit"
618 type="number"
619 min={1}
620 step={1}
621 defaultValue={mode === "fixed" && spendLimit != null ? spendLimit / MICROS_PER_DOLLAR : ""}
622 placeholder="500"
623 className="w-24 bg-transparent px-1 py-1 tabular-nums outline-none"
624 />
625 </span>
626 </label>
627 <label className="flex items-center gap-2">
628 <input type="radio" name="mode" value="none" defaultChecked={mode === "none"} className="accent-accent" />
629 None
630 </label>
631 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
632 Save
633 </Button>
634 </div>
635 </fieldset>
Usage limits: unpaid usage can only go so far636 <ErrorText>{error}</ErrorText>
Two limits, real invoices, trust that grows by itself, sales signals637 <p className="mt-3 text-xs text-faint">
638 Need more than {available != null ? dollars(available) : "this"} available?{" "}
639 <a href="mailto:billing@g1t.sh" className="text-fg hover:underline">
640 Contact us
641 </a>{" "}
642 and we will set terms that fit.
643 </p>
Usage limits: unpaid usage can only go so far644 </Form>
645 )}
646 </section>
647 );
648}
Two limits, real invoices, trust that grows by itself, sales signals649
650/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
651function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
652 return (
653 <section className="mb-10">
654 <h2 className="font-medium">Invoices</h2>
655 <p className="mt-1 text-sm text-muted">
656 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also
657 on Stripe's billing page.
658 </p>
659 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
660 {invoices.map((invoice) => (
661 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
662 <div className="flex flex-wrap items-center gap-3">
663 <span className="font-medium">
664 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
665 </span>
666 <span
667 className={`rounded-full border px-2 py-0.5 text-xs ${
668 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
669 }`}
670 >
671 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
672 </span>
673 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
674 {invoice.hostedUrl && (
675 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
676 View
677 </a>
678 )}
679 {invoice.pdfUrl && (
680 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
681 PDF
682 </a>
683 )}
684 </div>
685 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
686 {invoice.lines.map((line) => (
687 <li key={line.description} className="flex justify-between gap-4">
688 <span>{line.description}</span>
689 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
690 </li>
691 ))}
692 </ul>
693 </li>
694 ))}
695 </ul>
696 </section>
697 );
698}