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

779 lines34,839 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.

Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1import { ArrowUpRight, CreditCard, FileText, Receipt, Rocket, Users } 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,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put8 type Entitlements,
Deployments: a preview for every pull request, production on g1t.page9 type Feature,
10 type FeatureState,
Usage limits: unpaid usage can only go so far11 type Limit,
Two limits, real invoices, trust that grows by itself, sales signals12 type WorkspaceInvoice,
Deployments: a preview for every pull request, production on g1t.page13} from "@g1t/contracts";
Agents as a team: lifecycle, merge queue, billing and a new shell14
15import type { Route } from "./+types/billing";
Social cards for every page: og.g1t.sh16import { page } from "../../lib/meta";
The statement is a month at a time, a line per kind of charge17import { StatementView } from "../../components/statement";
Workspace names and icons, and a component kit for every control18import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group";
The statement is a month at a time, a line per kind of charge19import { Button, ErrorText } from "../../components/ui";
Deployments: a preview for every pull request, production on g1t.page20import { billing, deployments } from "../../lib/services.server";
Agents as a team: lifecycle, merge queue, billing and a new shell21import {
22 assertSameOrigin,
23 getViewer,
24 requireUser,
25 roleIn,
26 unwrap,
27} from "../../lib/session.server";
28
29/** What can be added in one payment, in dollars. */
30const AMOUNTS = [10, 25, 50, 100];
31
Social cards for every page: og.g1t.sh32export function meta({ params, ...args }: Route.MetaArgs) {
33 return page(args, { title: `Billing · ${params.owner} · g1t` });
Agents as a team: lifecycle, merge queue, billing and a new shell34}
35
36export async function loader({ params, context, request }: Route.LoaderArgs) {
37 const viewer = getViewer(context);
38 const role = roleIn(viewer, params.owner);
39 // Members only; to anyone else the page does not exist.
40 if (!role) throw data(null, { status: 404 });
41 const slug = params.owner.toLowerCase();
42
43 // Back from the payment page: credit it, then drop the id from the address.
44 const url = new URL(request.url);
45 const session = url.searchParams.get("session");
46 if (session) {
Paid features: a workspace turns on Deployments with a monthly plan47 // The same page returns from both a credit payment and a plan.
48 if (url.searchParams.get("plan")) {
49 await billing.confirmSubscription(slug, viewer, session);
50 throw redirect(`/${slug}/-/billing?subscribed=1`);
51 }
Agents as a team: lifecycle, merge queue, billing and a new shell52 await billing.confirm(slug, viewer, session);
53 throw redirect(`/${slug}/-/billing?added=1`);
54 }
The statement is a month at a time, a line per kind of charge55 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
The balance shows what was spent this month and in total56 const now = new Date();
57 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put58 const [account, statement, features, deployUsage, limit, invoices, thisMonth, allTime, entitlements] = await Promise.all([
Agents as a team: lifecycle, merge queue, billing and a new shell59 billing.account(slug, viewer),
The statement is a month at a time, a line per kind of charge60 billing.statement(slug, viewer, url.searchParams.get("month"), group),
Paid features: a workspace turns on Deployments with a monthly plan61 billing.features(slug, viewer),
Deployments: a preview for every pull request, production on g1t.page62 deployments.usage(slug, viewer),
Usage limits: unpaid usage can only go so far63 billing.limit(slug, viewer),
Two limits, real invoices, trust that grows by itself, sales signals64 billing.invoices(slug, viewer).catch(() => null),
The balance shows what was spent this month and in total65 billing.usage(slug, viewer, monthStart).catch(() => null),
66 billing.usage(slug, viewer, "1970-01-01T00:00:00.000Z").catch(() => null),
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put67 billing.entitlements(slug).catch(() => null),
Agents as a team: lifecycle, merge queue, billing and a new shell68 ]);
69 return {
70 slug,
71 role,
72 account: unwrap(account),
The statement is a month at a time, a line per kind of charge73 statement: unwrap(statement),
74 group,
Paid features: a workspace turns on Deployments with a monthly plan75 features: unwrap(features),
Deployments: a preview for every pull request, production on g1t.page76 deployUsage: deployUsage.ok ? deployUsage.value : null,
Usage limits: unpaid usage can only go so far77 limit: limit.ok ? limit.value : null,
Two limits, real invoices, trust that grows by itself, sales signals78 invoices: invoices?.ok ? invoices.value : [],
The balance shows what was spent this month and in total79 spent: {
80 month: thisMonth?.ok ? thisMonth.value.spentMicros : null,
81 total: allTime?.ok ? allTime.value.spentMicros : null,
82 added: allTime?.ok ? allTime.value.addedMicros : null,
83 },
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put84 entitlements,
Agents as a team: lifecycle, merge queue, billing and a new shell85 added: url.searchParams.has("added"),
Paid features: a workspace turns on Deployments with a monthly plan86 subscribed: url.searchParams.has("subscribed"),
Agents as a team: lifecycle, merge queue, billing and a new shell87 };
88}
89
90export async function action({ request, params, context }: Route.ActionArgs) {
91 assertSameOrigin(request);
92 const user = requireUser(context, request);
93 const form = await request.formData();
Paid features: a workspace turns on Deployments with a monthly plan94 const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`;
95 const intent = form.get("intent");
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace96 if (intent === "portal") {
97 // Card, invoices and billing details live on Stripe's own page.
98 const started = await billing.billingPortal(user, params.owner, page);
99 if (!started.ok) return { error: started.error.message };
100 throw redirect(started.value.url);
101 }
Two limits, real invoices, trust that grows by itself, sales signals102 if (intent === "spend-limit") {
103 // automatic, fixed (with an amount), or none.
104 const mode = String(form.get("mode") ?? "automatic");
Usage limits: unpaid usage can only go so far105 const amount = Number(form.get("limit"));
Two limits, real invoices, trust that grows by itself, sales signals106 if (mode === "fixed" && !(Number.isFinite(amount) && amount >= 1)) {
107 return { error: "A spend limit is a dollar amount, $1 or more." };
Usage limits: unpaid usage can only go so far108 }
109 const set = await billing.setSpendLimit(
110 user,
111 params.owner,
Two limits, real invoices, trust that grows by itself, sales signals112 mode === "fixed" ? Math.round(amount * MICROS_PER_DOLLAR) : null,
113 mode === "none",
Usage limits: unpaid usage can only go so far114 );
115 return set.ok ? null : { error: set.error.message };
116 }
Paid features: a workspace turns on Deployments with a monthly plan117 if (intent === "subscribe" || intent === "cancel" || intent === "resume") {
118 const feature = String(form.get("feature")) as Feature;
119 if (intent !== "subscribe") {
120 const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume");
121 return changed.ok ? null : { error: changed.error.message };
122 }
123 const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`);
124 if (!started.ok) return { error: started.error.message };
125 throw redirect(started.value.url);
126 }
Agents as a team: lifecycle, merge queue, billing and a new shell127 const dollars = Math.trunc(Number(form.get("amount")));
128 const started = await billing.checkout(
129 user,
130 params.owner,
131 Number.isFinite(dollars) ? dollars * 100 : 0,
132 // Spelled out: a form post arrives at a data address, not the page's.
133 `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`,
134 );
135 if (!started.ok) return { error: started.error.message };
136 throw redirect(started.value.url);
137}
138
139/** Millionths of a dollar as dollars, to the cent or finer. */
140function dollars(micros: number, digits = 2): string {
141 const sign = micros < 0 ? "−" : "";
142 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
143}
144
145export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put146 const { slug, role, account, statement, group, spent, features, deployUsage, limit, invoices, entitlements, added, subscribed } =
147 loaderData;
Agents as a team: lifecycle, merge queue, billing and a new shell148 const { status } = account;
149 const paying = useNavigation().state === "submitting";
150 const empty = account.balanceMicros <= 0;
151 return (
152 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
153 <div className="min-w-0">
Free while g1t is being built out; agents can check out their own forks154 {status.free && (
155 <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5">
156 <h2 className="font-medium">Free while g1t is being built out</h2>
157 <p className="mt-1.5 max-w-2xl text-sm text-muted">
158 While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own
159 model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still
160 recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing
161 will come later, and we will say so well before anything is charged.
162 </p>
163 </div>
164 )}
A flat project menu with Code first, views as tabs, and Stripe for every owner165 {account.status.enabled && (
166 <section className="mb-8 flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5">
167 <span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10 text-accent ring-1 ring-accent/30">
168 <Receipt size={18} />
169 </span>
170 <div className="min-w-0 grow">
171 <h2 className="font-medium">Billing on Stripe</h2>
172 <p className="mt-0.5 text-sm text-muted">
173 {account.card
174 ? `${account.card.brand[0].toUpperCase()}${account.card.brand.slice(1)} ending ${account.card.last4}. `
175 : "No card yet. "}
176 Your card, invoices and receipts, and the billing email, address and tax ID.
177 </p>
178 </div>
179 {role === "owner" ? (
180 <Form method="post">
181 <Button variant="accent" type="submit" name="intent" value="portal" disabled={paying}>
182 Open Stripe billing
183 <ArrowUpRight size={14} />
184 </Button>
185 </Form>
186 ) : (
187 <p className="text-xs text-faint">An owner manages billing.</p>
188 )}
189 </section>
190 )}
191
Usage limits: unpaid usage can only go so far192 {limit && account.status.enabled && (
193 <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} />
194 )}
195
A flat project menu with Code first, views as tabs, and Stripe for every owner196 {account.status.enabled && (
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace197 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
198 <div className="flex flex-wrap items-center justify-between gap-3">
199 <div>
200 <h2 className="font-medium">Card and invoices</h2>
201 {account.card ? (
202 <p className="mt-1 text-sm">
203 <span className="capitalize">{account.card.brand}</span> ending{" "}
204 <span className="font-mono">{account.card.last4}</span>
205 <span className="text-muted">
206 {" "}
207 · expires {String(account.card.expMonth).padStart(2, "0")}/{account.card.expYear}
208 </span>
209 </p>
210 ) : (
211 <p className="mt-1 max-w-xl text-sm text-muted">
212 No card yet. With one on file, g1t charges it as the workspace nears its usage limit and when each
213 month closes, so work never stops for a payment. Saving it charges nothing.
214 </p>
215 )}
216 <p className="mt-2 max-w-xl text-xs text-faint">
217 Cards, invoices, receipts and the billing email and address are managed on Stripe's billing page. g1t
218 never sees card numbers.
219 </p>
220 </div>
221 {role === "owner" && (
222 <Form method="post">
223 <Button variant={account.card ? "quiet" : "accent"} type="submit" name="intent" value="portal" disabled={paying}>
224 <CreditCard size={14} />
225 {account.card ? "Manage billing on Stripe" : "Add a card on Stripe"}
226 </Button>
227 </Form>
228 )}
229 </div>
230 {account.card && (
231 <p className="mt-3 text-xs text-faint">
232 Charged near the usage limit, for what the workspace owes, and when each month closes. A declined card
233 stops work until it is paid.
234 </p>
235 )}
236 {!account.status.live && (
237 <p className="mt-3 text-xs text-faint">
238 Test mode: use card 4242 4242 4242 4242, any future date and code. Test cards are never charged
239 automatically.
240 </p>
241 )}
242 </section>
243 )}
244
Two limits, real invoices, trust that grows by itself, sales signals245 {invoices.length > 0 && <InvoiceList invoices={invoices} />}
246
Paid features: a workspace turns on Deployments with a monthly plan247 <h2 className="font-medium">Plans</h2>
Agents as a team: lifecycle, merge queue, billing and a new shell248 <p className="mt-1 max-w-2xl text-sm text-muted">
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put249 Plans are turned on per workspace, at one flat price a month for everyone in it: never per person. They are
250 charged including while the rest of g1t is free.
Paid features: a workspace turns on Deployments with a monthly plan251 </p>
252 {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>}
253 <div className="mt-5 space-y-4">
254 {features.map((state) => (
255 <PlanCard
256 key={state.plan.feature}
257 state={state}
258 owner={role === "owner"}
259 enabled={account.status.enabled}
260 live={account.status.live}
261 busy={paying}
Deployments: a preview for every pull request, production on g1t.page262 usage={state.plan.feature === "deployments" ? deployUsage : null}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put263 entitlements={entitlements}
Paid features: a workspace turns on Deployments with a monthly plan264 />
265 ))}
266 </div>
267 <div className="mt-2">
268 <ErrorText>{actionData?.error}</ErrorText>
269 </div>
270
271 <h2 className="mt-12 font-medium">Agent credit</h2>
272 <p className="mt-1 max-w-2xl text-sm text-muted">
Agents as a team: lifecycle, merge queue, billing and a new shell273 g1t agents that work on this workspace's repositories are paid for from
Billing accounts, terms and enterprises; g1t is no longer free274 its account: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge
Prices are what g1t pays plus 20%, from the first second275 queue and workflows, is metered by the second, from the first. Credit added here pays usage in
Billing accounts, terms and enterprises; g1t is no longer free276 advance; the usage limit above decides whether work starts.
Agents as a team: lifecycle, merge queue, billing and a new shell277 </p>
278
279 <div
280 className={`mt-5 rounded-xl border p-5 ${
281 empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface"
282 }`}
283 >
The balance shows what was spent this month and in total284 <dl className="flex flex-wrap items-end gap-x-10 gap-y-4">
285 <div>
286 <dt className="text-xs text-muted">Balance</dt>
287 <dd className="mt-1 text-3xl font-semibold tabular-nums tracking-tight">
288 {dollars(account.balanceMicros)}
289 </dd>
290 </div>
291 {spent.month !== null && (
292 <div>
293 <dt className="text-xs text-muted">Spent this month</dt>
294 <dd className="mt-1 text-xl font-medium tabular-nums">{dollars(spent.month)}</dd>
295 </div>
296 )}
297 {spent.total !== null && (
298 <div>
299 <dt className="text-xs text-muted">Spent in total</dt>
300 <dd className="mt-1 text-xl font-medium tabular-nums">{dollars(spent.total)}</dd>
301 </div>
302 )}
303 {spent.added !== null && spent.added !== 0 && (
304 <div>
305 <dt className="text-xs text-muted">Paid and credited in total</dt>
306 <dd className="mt-1 text-xl font-medium tabular-nums text-muted">{dollars(spent.added)}</dd>
307 </div>
308 )}
309 </dl>
Agents as a team: lifecycle, merge queue, billing and a new shell310 {added && (
311 <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p>
312 )}
Paid features: a workspace turns on Deployments with a monthly plan313 {status.free && !features.some((state) => state.on && state.subscription) ? (
Billing: nothing to add while it is free314 <p className="mt-3 text-sm text-muted">
315 Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when
316 pricing starts.
317 </p>
318 ) : !status.enabled ? (
Agents as a team: lifecycle, merge queue, billing and a new shell319 <p className="mt-3 text-sm text-muted">
320 Payments are not set up on this g1t yet, so nothing is charged and
321 agents are limited to selected accounts.
322 </p>
323 ) : role === "owner" ? (
324 <Form method="post" className="mt-4">
Paid features: a workspace turns on Deployments with a monthly plan325 <p className="text-sm text-muted">
326 {status.free
327 ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:"
328 : "Add credit by card:"}
329 </p>
Agents as a team: lifecycle, merge queue, billing and a new shell330 <div className="mt-2 flex flex-wrap gap-2">
331 {AMOUNTS.map((amount) => (
332 <Button
333 key={amount}
334 variant={amount === 25 ? "accent" : "quiet"}
335 type="submit"
336 name="amount"
337 value={amount}
338 disabled={paying}
339 >
340 <CreditCard size={14} />${amount}
341 </Button>
342 ))}
343 </div>
344 {!status.live && (
345 <p className="mt-3 text-xs text-faint">
346 Payments are in test mode. No real card is charged; use Stripe's
347 test card 4242 4242 4242 4242 with any future date and any code.
348 </p>
349 )}
350 </Form>
351 ) : (
352 <p className="mt-3 text-sm text-muted">An owner can add credit.</p>
353 )}
354 </div>
355
The statement is a month at a time, a line per kind of charge356 <StatementView slug={slug} statement={statement} group={group} />
Agents as a team: lifecycle, merge queue, billing and a new shell357 </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>
Prices are what g1t pays plus 20%, from the first second364 Usage is charged after it runs, to the workspace that owns the repository: models, sandbox time, builds
365 and apps at what they cost g1t plus {account.marginPercent}%, which pays for running and building g1t.
366 Nothing is bundled in.
Agents as a team: lifecycle, merge queue, billing and a new shell367 </li>
368 <li>
A flat project menu with Code first, views as tabs, and Stripe for every owner369 Each month closes with an itemised invoice, charged to the card on file. Near your limit, g1t sends one
370 sooner, so work keeps going.
Agents as a team: lifecycle, merge queue, billing and a new shell371 </li>
372 <li>
Integrations: your own model provider, alerts that open issues, tickets agents read373 With your own model provider, connected under{" "}
374 <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline">
375 Integrations
376 </Link>
Prices are what g1t pays plus 20%, from the first second377 , the provider bills you for the model, and a run here is charged only its sandbox time.
Agents as a team: lifecycle, merge queue, billing and a new shell378 </li>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put379 <li>
380 Work on public repositories is paid for by g1t's open-source pool first, up to a monthly cap per
381 repository; the statement says so on each line it paid.
382 </li>
383 <li>
384 No card is charged less than {dollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR, 0)}: smaller
385 amounts carry over to the next invoice.
386 </li>
A flat project menu with Code first, views as tabs, and Stripe for every owner387 <li>No seats: add as many people and agents as you like.</li>
Agents as a team: lifecycle, merge queue, billing and a new shell388 </ul>
A flat project menu with Code first, views as tabs, and Stripe for every owner389 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
390 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
391 <FileText size={14} /> Usage, run by run
392 </Link>
393 <a href="https://g1t.sh/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
394 <CreditCard size={14} /> Today's prices
395 </a>
396 <a
397 href="https://docs.g1t.sh/guides/usage-and-billing/"
398 className="flex items-center gap-2 text-muted hover:text-fg"
399 >
400 <ArrowUpRight size={14} /> How usage and billing work
401 </a>
402 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell403 </section>
404 </aside>
405 </div>
406 );
407}
Paid features: a workspace turns on Deployments with a monthly plan408
409/** A paid feature: what it costs and includes, and its plan. */
410function PlanCard({
411 state,
412 owner,
413 enabled,
414 live,
415 busy,
Deployments: a preview for every pull request, production on g1t.page416 usage,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put417 entitlements,
Paid features: a workspace turns on Deployments with a monthly plan418}: {
419 state: FeatureState;
420 owner: boolean;
421 enabled: boolean;
422 live: boolean;
423 busy: boolean;
Deployments: a preview for every pull request, production on g1t.page424 usage: DeployUsage | null;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put425 entitlements: Entitlements | null;
Paid features: a workspace turns on Deployments with a monthly plan426}) {
427 const { plan, subscription } = state;
428 const ending = subscription?.status === "canceling";
429 const owed = subscription?.status === "past_due";
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put430 const team = plan.feature === "team";
431 const Icon = team ? Users : Rocket;
Paid features: a workspace turns on Deployments with a monthly plan432 return (
433 <section
434 className={`rounded-xl border p-5 ${
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put435 (state.on && subscription) || state.included
Paid features: a workspace turns on Deployments with a monthly plan436 ? "border-accent/40 bg-accent/5"
437 : owed
438 ? "border-warn/40 bg-warn/5"
439 : "border-line bg-surface"
440 }`}
441 >
442 <div className="flex flex-wrap items-start justify-between gap-4">
443 <div className="min-w-0">
444 <h3 className="flex flex-wrap items-center gap-2 font-medium">
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put445 <Icon size={15} className="text-accent" />
Paid features: a workspace turns on Deployments with a monthly plan446 {plan.title}
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put447 {state.included && (
448 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">Included, no charge</span>
449 )}
Paid features: a workspace turns on Deployments with a monthly plan450 {state.on && subscription && (
451 <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent">
452 {ending ? "Ends " : "On"}
453 {subscription.periodEnd && (
454 <>
455 {ending ? "" : " · renews "}
456 {new Date(subscription.periodEnd).toLocaleDateString()}
457 </>
458 )}
459 </span>
460 )}
461 {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>}
462 </h3>
463 <p className="mt-1 text-sm text-muted">
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put464 {team
465 ? "For a workspace that works here every day: a monthly usage credit, more private storage and a longer audit log, at one price for everyone in it."
466 : "Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge."}
Paid features: a workspace turns on Deployments with a monthly plan467 </p>
468 </div>
469 <p className="shrink-0 text-right">
470 <span className="text-2xl font-semibold tabular-nums tracking-tight">
471 ${(plan.monthlyCents / 100).toFixed(0)}
472 </span>
473 <span className="text-sm text-muted"> / month</span>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put474 {team && <span className="block text-xs text-faint">per workspace</span>}
Paid features: a workspace turns on Deployments with a monthly plan475 </p>
476 </div>
477 <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2">
478 {plan.includes.map((line) => (
479 <li key={line} className="flex gap-2">
480 <span className="text-accent">✓</span>
481 {line}
482 </li>
483 ))}
484 </ul>
485 <p className="mt-3 text-xs text-faint">{plan.overage}</p>
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put486 {state.on && usage && <DeployMeter usage={usage} entitlements={entitlements} />}
487 {team && state.on && entitlements?.team && <TeamMeter entitlements={entitlements} />}
488 {state.included ? (
489 <p className="mt-4 text-sm text-muted">
490 {plan.title} is on for this workspace at no charge, under terms g1t set with it.
491 </p>
492 ) : !enabled ? (
Paid features: a workspace turns on Deployments with a monthly plan493 <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p>
494 ) : !owner ? (
495 !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p>
496 ) : (
497 <Form method="post" className="mt-4 flex flex-wrap items-center gap-3">
498 <input type="hidden" name="feature" value={plan.feature} />
499 {!state.on || !subscription ? (
500 <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}>
501 <CreditCard size={14} />
502 Turn on {plan.title}
503 </Button>
504 ) : ending ? (
505 <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}>
506 Keep {plan.title}
507 </Button>
508 ) : (
509 <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}>
510 Turn off at the end of the period
511 </Button>
512 )}
513 {!live && !state.on && (
514 <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span>
515 )}
516 </Form>
517 )}
518 </section>
519 );
520}
Deployments: a preview for every pull request, production on g1t.page521
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put522/** This month's Team credit: what of it the month's usage has drawn. */
523function TeamMeter({ entitlements }: { entitlements: Entitlements }) {
524 const used = entitlements.teamCreditUsedMicros;
525 const of = entitlements.teamCreditMicros;
526 return (
527 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
528 <div className="flex justify-between gap-4 text-sm">
529 <span className="text-muted">Credit used this month</span>
530 <span className="tabular-nums">
531 {dollars(used)} <span className="text-faint">of {dollars(of)}</span>
532 </span>
533 </div>
534 <Meter used={used} of={of} state={used >= of ? "warning" : "ok"} />
535 <p className="mt-3 text-xs text-faint">
536 Usage draws on the credit first, at cost plus 20%; past it, usage is charged as usual. It starts again on the
537 1st, and what is unused does not carry over.
538 </p>
539 </div>
540 );
541}
542
Deployments: a preview for every pull request, production on g1t.page543/** This month's use of the Deployments plan against what it includes. */
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put544function DeployMeter({ usage, entitlements }: { usage: DeployUsage; entitlements: Entitlements | null }) {
Deployments: a preview for every pull request, production on g1t.page545 const a = DEPLOYMENTS_ALLOWANCE;
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put546 const buildMinutes = (entitlements?.buildSecondsIncluded ?? a.buildSeconds) / 60;
Deployments: a preview for every pull request, production on g1t.page547 const rows: [string, number, number, (n: number) => string][] = [
548 ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)],
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put549 ["Build minutes", Math.ceil(usage.buildSeconds / 60), buildMinutes, (n) => n.toLocaleString("en-US")],
Deployments: a preview for every pull request, production on g1t.page550 ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")],
551 ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")],
552 ];
553 return (
554 <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4">
555 <p className="text-xs font-medium text-muted">This month ({usage.month})</p>
556 <ul className="mt-2 space-y-2.5">
557 {rows.map(([label, used, included, show]) => (
558 <li key={label} className="text-sm">
559 <div className="flex justify-between gap-4">
560 <span className="text-muted">{label}</span>
561 <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}>
562 {show(used)} <span className="text-faint">of {show(included)}</span>
563 </span>
564 </div>
565 <div className="mt-1 h-1 overflow-hidden rounded-full bg-line">
566 <div
567 className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`}
568 style={{ width: `${Math.min(100, (used / included) * 100)}%` }}
569 />
570 </div>
571 </li>
572 ))}
573 </ul>
574 <p className="mt-3 text-xs text-faint">
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put575 Builds past the included minutes are charged by the second; this month's builds cost g1t{" "}
576 {dollars(usage.buildMicros, 4)} in all.
Deployments: a preview for every pull request, production on g1t.page577 {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."}
578 </p>
579 </div>
580 );
581}
Usage limits: unpaid usage can only go so far582
583const TRUST: Record<Limit["trust"], { label: string; detail: string }> = {
Two limits, real invoices, trust that grows by itself, sales signals584 new: { label: "New", detail: "No payment to g1t yet." },
585 paid: { label: "Paid", detail: "Grows with every payment." },
586 established: { label: "Established", detail: "Follows your monthly spend." },
Usage limits: unpaid usage can only go so far587 reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." },
Billing accounts, terms and enterprises; g1t is no longer free588 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 far589};
590
Two limits, real invoices, trust that grows by itself, sales signals591/** One meter: how much of a limit is used. */
592function Meter({ used, of, state }: { used: number; of: number | null; state: "ok" | "warning" | "stopped" }) {
593 const share = of ? Math.min(1, used / Math.max(of, 1)) : 0;
594 const bar = state === "stopped" ? "bg-danger" : state === "warning" ? "bg-warn" : "bg-accent";
595 return (
596 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="presentation">
597 <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} />
598 </div>
599 );
600}
601
602function meterState(used: number, of: number | null): "ok" | "warning" | "stopped" {
603 if (of == null) return "ok";
604 if (used >= of) return "stopped";
605 return used * 5 >= of * 4 ? "warning" : "ok";
606}
607
Usage limits: unpaid usage can only go so far608/**
Two limits, real invoices, trust that grows by itself, sales signals609 * The workspace's two limits, side by side: the owners' monthly spend
610 * limit, which protects them from a surprise; and what g1t lets go unpaid,
611 * which grows with what they pay and is charged to the card as it nears.
Usage limits: unpaid usage can only go so far612 */
613function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) {
614 const tone =
615 limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface";
616 const trust = TRUST[limit.trust];
Two limits, real invoices, trust that grows by itself, sales signals617 const spent = limit.spentMicros ?? 0;
618 const spendLimit = limit.spendLimitMicros;
619 const available = limit.availableMicros ?? limit.ceilingMicros;
620 const mode = limit.defaultSpendLimit ? "automatic" : spendLimit == null ? "none" : "fixed";
621 if (limit.trust === "internal") {
622 return (
623 <section className="mb-10 rounded-xl border border-line bg-surface p-5">
624 <div className="flex flex-wrap items-baseline justify-between gap-2">
625 <h2 className="font-medium">Limits</h2>
626 <span className="rounded-full border border-accent/40 px-2 py-0.5 text-xs text-accent">Comped</span>
627 </div>
628 <p className="mt-1 text-sm text-muted">{trust.detail}</p>
629 </section>
630 );
631 }
Usage limits: unpaid usage can only go so far632 return (
633 <section className={`mb-10 rounded-xl border p-5 ${tone}`}>
634 <div className="flex flex-wrap items-baseline justify-between gap-2">
Two limits, real invoices, trust that grows by itself, sales signals635 <h2 className="font-medium">Limits</h2>
Usage limits: unpaid usage can only go so far636 <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span>
637 </div>
Billing accounts, terms and enterprises; g1t is no longer free638 {limit.account.startsWith("ent_") && (
Two limits, real invoices, trust that grows by itself, sales signals639 <p className="mt-1 text-sm text-muted">
Billing accounts, terms and enterprises; g1t is no longer free640 Paid for by the <span className="font-medium text-fg">{limit.accountName}</span> enterprise: these figures are
641 for all of its workspaces together.
642 </p>
643 )}
Two limits, real invoices, trust that grows by itself, sales signals644
645 <div className="mt-5 grid gap-6 sm:grid-cols-2">
646 <div>
647 <p className="text-xs text-muted">Spent this month</p>
648 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
649 {dollars(spent)}
650 <span className="text-base font-normal text-muted">
651 {spendLimit == null ? " · no spend limit" : ` of ${dollars(spendLimit)}`}
652 </span>
653 </p>
654 {spendLimit != null && <Meter used={spent} of={spendLimit} state={meterState(spent, spendLimit)} />}
655 <p className="mt-2 text-xs text-faint">
656 {mode === "automatic"
657 ? "Your spend limit is automatic: $200, or twice last month's spend, so it keeps up as you grow."
658 : mode === "fixed"
659 ? "A spend limit you set. At it, work stops until the month turns."
660 : "No spend limit: work never stops for spend, only for what g1t lets go unpaid."}
661 </p>
662 </div>
663 <div>
664 <p className="text-xs text-muted">Not yet paid</p>
665 <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight">
666 {dollars(limit.exposureMicros)}
667 <span className="text-base font-normal text-muted">
668 {available == null ? "" : ` of ${dollars(available)} available`}
Usage limits: unpaid usage can only go so far669 </span>
Two limits, real invoices, trust that grows by itself, sales signals670 </p>
671 {available != null && (
672 <Meter used={limit.exposureMicros} of={available} state={meterState(limit.exposureMicros, available)} />
Usage limits: unpaid usage can only go so far673 )}
Two limits, real invoices, trust that grows by itself, sales signals674 <p className="mt-2 text-xs text-faint">
675 With a card on file, g1t charges it as this nears what is available, so work keeps going.
676 {limit.growth ? ` ${limit.growth}` : ""}
677 </p>
678 </div>
679 </div>
680
681 {limit.message && <p className="mt-4 text-sm">{limit.message}</p>}
682
683 {owner && (
684 <Form method="post" className="mt-5 border-t border-line pt-4">
685 <fieldset>
686 <legend className="text-xs text-muted">Your monthly spend limit</legend>
Workspace names and icons, and a component kit for every control687 <RadioGroup name="mode" defaultValue={mode} className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
688 <label className="flex cursor-pointer items-center gap-2">
689 <RadioGroupItem value="automatic" />
Two limits, real invoices, trust that grows by itself, sales signals690 Automatic
691 </label>
Workspace names and icons, and a component kit for every control692 <label className="flex cursor-pointer items-center gap-2">
693 <RadioGroupItem value="fixed" />
Two limits, real invoices, trust that grows by itself, sales signals694 Fixed at
695 <span className="flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent">
696 <span className="text-muted">$</span>
697 <input
698 name="limit"
699 type="number"
700 min={1}
701 step={1}
702 defaultValue={mode === "fixed" && spendLimit != null ? spendLimit / MICROS_PER_DOLLAR : ""}
703 placeholder="500"
704 className="w-24 bg-transparent px-1 py-1 tabular-nums outline-none"
705 />
706 </span>
707 </label>
Workspace names and icons, and a component kit for every control708 <label className="flex cursor-pointer items-center gap-2">
709 <RadioGroupItem value="none" />
Two limits, real invoices, trust that grows by itself, sales signals710 None
711 </label>
712 <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}>
713 Save
714 </Button>
Workspace names and icons, and a component kit for every control715 </RadioGroup>
Two limits, real invoices, trust that grows by itself, sales signals716 </fieldset>
Usage limits: unpaid usage can only go so far717 <ErrorText>{error}</ErrorText>
Two limits, real invoices, trust that grows by itself, sales signals718 <p className="mt-3 text-xs text-faint">
719 Need more than {available != null ? dollars(available) : "this"} available?{" "}
720 <a href="mailto:billing@g1t.sh" className="text-fg hover:underline">
721 Contact us
722 </a>{" "}
723 and we will set terms that fit.
724 </p>
Usage limits: unpaid usage can only go so far725 </Form>
726 )}
727 </section>
728 );
729}
Two limits, real invoices, trust that grows by itself, sales signals730
731/** The workspace's invoices from g1t, each kept on Stripe with its PDF. */
732function InvoiceList({ invoices }: { invoices: WorkspaceInvoice[] }) {
733 return (
734 <section className="mb-10">
735 <h2 className="font-medium">Invoices</h2>
736 <p className="mt-1 text-sm text-muted">
737 One when each month closes, and one each time g1t charges the card near your limit. Receipts and PDFs are also
738 on Stripe's billing page.
739 </p>
740 <ul className="mt-4 divide-y divide-line overflow-hidden rounded-xl border border-line">
741 {invoices.map((invoice) => (
742 <li key={invoice.invoiceId} className="px-4 py-3 text-sm">
743 <div className="flex flex-wrap items-center gap-3">
744 <span className="font-medium">
745 {invoice.reason === "month" ? `Usage for ${invoice.period}` : `Charged near the limit, ${invoice.period}`}
746 </span>
747 <span
748 className={`rounded-full border px-2 py-0.5 text-xs ${
749 invoice.status === "paid" ? "border-accent/40 text-accent" : "border-danger/40 text-danger"
750 }`}
751 >
752 {invoice.status === "paid" ? "Paid" : invoice.status === "failed" ? "Payment failed" : invoice.status}
753 </span>
754 <span className="ml-auto font-mono tabular-nums">{dollars(invoice.amountMicros)}</span>
755 {invoice.hostedUrl && (
756 <a href={invoice.hostedUrl} className="text-xs text-muted hover:text-fg">
757 View
758 </a>
759 )}
760 {invoice.pdfUrl && (
761 <a href={invoice.pdfUrl} className="text-xs text-muted hover:text-fg">
762 PDF
763 </a>
764 )}
765 </div>
766 <ul className="mt-1.5 space-y-0.5 text-xs text-faint">
767 {invoice.lines.map((line) => (
768 <li key={line.description} className="flex justify-between gap-4">
769 <span>{line.description}</span>
770 <span className="font-mono tabular-nums">{dollars(line.amountMicros)}</span>
771 </li>
772 ))}
773 </ul>
774 </li>
775 ))}
776 </ul>
777 </section>
778 );
779}