Skip to content

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.sh
390 linesCodeBlameRaw
1import { ArrowUpRight, CreditCard, FileText } from "lucide-react";
2import { Suspense } from "react";
3import { Await, Form, Link, data, redirect } from "react-router";
4
5import { MICROS_PER_DOLLAR } from "@g1t/contracts";
6
7import type { Route } from "./+types/billing";
8import {
9 Alerts,
10 CreditsCard,
11 OverageCard,
12 PrepayCard,
13 RaiseCard,
14 type SectionError,
15 SpendLimitCard,
16 SpikeBanner,
17 TrialCard,
18} from "../../components/billing";
19import { AddOns, BillingOnStripeCard, BudgetAlerts, PlanSummary, StripeSkeleton } from "../../components/billing-settings";
20import { StatementView } from "../../components/statement";
21import {
22 cardCheckResult,
23 parseBudgetAlerts,
24 parseLimitRequest,
25 parsePrepay,
26 parseSpendLimit,
27 planStatus,
28} from "../../lib/billing";
29import { planReturnNotice, prepaymentSettled } from "../../lib/billing-return";
30import { wholeDollars } from "../../lib/money";
31import { isStaff } from "../../lib/usage";
32import { page } from "../../lib/meta";
33import { billing } from "../../lib/services.server";
34import { assertSameOrigin, getViewer, managesBilling, requireUser, roleIn, unwrap } from "../../lib/session.server";
35import { Card } from "../../components/ui/card";
36import { SubmitButton } from "../../components/ui";
37
38/** The trial and pools as published, when the price book cannot be read. */
39const DEFAULT_TRIAL_MICROS = 5_000_000;
40
41/** What a change says once it is done (`?done=`). */
42const DONE: Record<string, string> = {
43 prepaid: "Payment received. Your prepaid balance and what you can use went up by as much.",
44 prepaid_pending:
45 "Payment pending. Your prepaid balance goes up once the payment clears, and this page shows it the next time it loads. A bank transfer can take a few business days.",
46 budget: "Budget alerts saved.",
47 limit: "Spend limit saved.",
48 requested: "Sent. g1t answers within one business day, here and by email.",
49 told: "Thanks. g1t looks at it and answers within one business day, here and by email.",
50 keep: "Compute is running again for 24 hours, unless spending doubles again first.",
51 stop: "New compute stays paused until an owner chooses Keep going.",
52};
53
54export function meta({ params, ...args }: Route.MetaArgs) {
55 return page(args, { title: `Billing · ${params.owner} · g1t` });
56}
57
58export async function loader({ params, context, request }: Route.LoaderArgs) {
59 const viewer = getViewer(context);
60 const role = roleIn(viewer, params.owner);
61 // Members only; to anyone else the page does not exist.
62 if (!role) throw data(null, { status: 404 });
63 const slug = params.owner.toLowerCase();
64 const url = new URL(request.url);
65 const here = `/${slug}/-/billing`;
66
67 // Back from Stripe: record what happened, then drop the ids from the address.
68 const cardCheck = url.searchParams.get("card_check");
69 if (cardCheck) {
70 const checked = await billing.confirmCardCheck(slug, viewer, cardCheck);
71 throw redirect(checked.ok ? `${here}?checked=1#trial` : `${here}?problem=${encodeURIComponent(checked.error.message)}#trial`);
72 }
73 const session = url.searchParams.get("session");
74 if (session) {
75 if (url.searchParams.get("plan")) {
76 const started = await billing.confirmSubscription(slug, viewer, session);
77 const doneKey = url.searchParams.get("plan") === "security" ? "security_on" : "subscribed";
78 throw redirect(started.ok ? `${here}?done=${doneKey}` : `${here}?problem=${encodeURIComponent(started.error.message)}`);
79 }
80 // A card payment usually clears on the way back; a bank transfer never
81 // has yet. Say which, rather than "received" for both.
82 const before = await billing.account(slug, viewer).catch(() => null);
83 const paid = await billing.confirm(slug, viewer, session);
84 if (!paid.ok) throw redirect(`${here}?problem=${encodeURIComponent(paid.error.message)}#prepay`);
85 const ledger = await billing.ledger(slug, viewer).catch(() => null);
86 const settled = prepaymentSettled(before?.ok ? before.value.balanceMicros : null, paid.value.balanceMicros, ledger?.ok ? ledger.value : null);
87 throw redirect(`${here}?done=${settled ? "prepaid" : "prepaid_pending"}#prepay`);
88 }
89 // A plan started on the card already saved comes straight back.
90 const plans = url.searchParams.getAll("plan");
91 if (plans.includes("started")) throw redirect(`${here}?done=${plans.includes("security") ? "security_on" : "subscribed"}`);
92
93 const now = new Date();
94 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString().slice(0, 10);
95 const today = now.toISOString().slice(0, 10);
96 const group: "day" | "project" = url.searchParams.get("group") === "project" ? "project" : "day";
97 // Billing on Stripe (the card, and the way to Stripe's billing page) is
98 // for owners and billing managers; other members see the plan and usage.
99 const manages = managesBilling(viewer, params.owner);
100 // Stripe's own (the saved card, the upcoming invoice, whether Stripe Tax
101 // needs an address) is streamed: the page does not wait on Stripe, and
102 // its sections hold their place.
103 const details = billing
104 .billingDetails(slug, viewer)
105 .then((result) => (result.ok ? result.value : null))
106 .catch(() => null);
107 const [account, statement, features, limit, entitlements, requests, book, credits, report] = await Promise.all([
108 billing.account(slug, viewer),
109 billing.statement(slug, viewer, url.searchParams.get("month"), group),
110 billing.features(slug, viewer),
111 billing.limit(slug, viewer).catch(() => null),
112 billing.entitlements(slug).catch(() => null),
113 billing.limitRequests(slug, viewer).catch(() => null),
114 billing.prices().catch(() => null),
115 billing.credits(slug, viewer).catch(() => null),
116 billing.usageReport(slug, viewer, { from: monthStart, until: today }).catch(() => null),
117 ]);
118 const featureStates = unwrap(features);
119 const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS;
120 const done = url.searchParams.get("done");
121 const plan = featureStates.find((state) => state.plan.feature === "plan") ?? null;
122 const securityPlan = featureStates.find((state) => state.plan.feature === "security") ?? null;
123 // Credit from g1t for everything; grants scoped to models belong to usage no longer offered.
124 const general = credits?.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : [];
125 return {
126 slug,
127 role,
128 // Billing managers manage billing as owners do.
129 managesBilling: manages,
130 staff: isStaff(viewer),
131 account: unwrap(account),
132 statement: unwrap(statement),
133 group,
134 plan,
135 securityPlan,
136 limit: limit?.ok ? limit.value : null,
137 entitlements,
138 requests: requests?.ok ? requests.value : [],
139 credits: general.length > 0 && credits?.ok ? { ...credits.value, grants: general } : null,
140 report: report?.ok ? report.value : null,
141 details,
142 trialMicros,
143 // The first month's limit on usage not yet paid for, from the price book.
144 firstMonthMicros: book?.free?.paidStartCeilingMicros ?? null,
145 notice:
146 url.searchParams.has("checked") && entitlements
147 ? cardCheckResult(entitlements, trialMicros)
148 : done === "subscribed"
149 ? planReturnNotice(plan, done)
150 : done === "security_on"
151 ? planReturnNotice(securityPlan, done)
152 : done && DONE[done]
153 ? DONE[done]
154 : null,
155 problem: url.searchParams.get("problem"),
156 };
157}
158
159export async function action({ request, params, context }: Route.ActionArgs) {
160 assertSameOrigin(request);
161 const user = requireUser(context, request);
162 const form = await request.formData();
163 const slug = params.owner.toLowerCase();
164 // Spelled out: a form post arrives at a data address, not the page's.
165 const here = `${new URL(request.url).origin}/${slug}/-/billing`;
166 const intent = String(form.get("intent") ?? "");
167 const fail = (section: string, error: string) => data<SectionError>({ section, error }, { status: 422 });
168 const done = (key: string, anchor = "") => redirect(`/${slug}/-/billing?done=${key}${anchor}`);
169
170 switch (intent) {
171 case "portal": {
172 // The card, billing address, tax IDs, invoices and receipts, and
173 // ending the plan, are all on Stripe's own billing page.
174 const started = await billing.billingPortal(user, slug, here);
175 // From the plan's card (Manage billing) the error shows there.
176 if (!started.ok) return fail(form.get("from") === "plan" ? "plan" : "stripe", started.error.message);
177 throw redirect(started.value.url);
178 }
179 case "subscribe": {
180 // The plan. Security and quality comes with it: billing refuses it on its own.
181 const feature = form.get("feature") === "security" ? "security" : "plan";
182 const started = await billing.subscribe(user, slug, feature, `${here}?plan=${feature}`);
183 if (!started.ok) return fail(feature, started.error.message);
184 throw redirect(started.value.url);
185 }
186 case "card-check": {
187 const started = await billing.cardCheck(user, slug, here);
188 if (!started.ok) return fail("trial", started.error.message);
189 throw redirect(started.value.url);
190 }
191 case "budget": {
192 const parsed = parseBudgetAlerts({ alerts: form.getAll("alert"), pause: form.get("pause"), webhook: form.get("webhook") });
193 if (!parsed.ok) return fail("budget", parsed.error);
194 const set = await billing.setBudget(user, slug, { amountMicros: null, keepLimit: true, ...parsed.value });
195 if (!set.ok) return fail("budget", set.error.message);
196 throw done("budget", "#budget");
197 }
198 case "spend-limit": {
199 const parsed = parseSpendLimit({ mode: form.get("mode"), limit: form.get("limit") });
200 if (!parsed.ok) return fail("limit", parsed.error);
201 let micros = parsed.value.micros;
202 if (parsed.value.raiseOnce && micros == null) {
203 // The one-time raise with no amount: as high as it goes.
204 const limit = await billing.limit(slug, user);
205 micros = limit.ok ? (limit.value.raiseOnceMicros ?? null) : null;
206 if (micros == null) return fail("limit", "The one-time raise is not available. Prepay, or use Raise my limit.");
207 }
208 const set = await billing.setSpendLimit(user, slug, micros, parsed.value.useFull, parsed.value.raiseOnce);
209 if (!set.ok) return fail("limit", set.error.message);
210 throw done("limit", "#limit");
211 }
212 case "request": {
213 const parsed = parseLimitRequest({
214 kind: form.get("kind"),
215 amount: form.get("amount"),
216 reason: form.get("reason"),
217 expected: form.get("expected"),
218 });
219 const section = String(form.get("kind")) === "overage" ? "overage" : "raise";
220 if (!parsed.ok) return fail(section, parsed.error);
221 const sent = await billing.requestLimit(user, slug, parsed.value);
222 if (!sent.ok) return fail(section, sent.error.message);
223 throw done(section === "overage" ? "told" : "requested", `#${section}`);
224 }
225 case "spike": {
226 const keep = String(form.get("decision")) === "keep";
227 const answered = await billing.confirmSpike(user, slug, keep);
228 if (!answered.ok) return fail("spike", answered.error.message);
229 throw done(keep ? "keep" : "stop");
230 }
231 case "prepay": {
232 const parsed = parsePrepay({ amount: form.get("amount"), custom: form.get("custom"), method: form.get("method") });
233 if (!parsed.ok) return fail("prepay", parsed.error);
234 const started = await billing.checkout(user, slug, parsed.value.amountCents, here, parsed.value.method);
235 if (!started.ok) return fail("prepay", started.error.message);
236 throw redirect(started.value.url);
237 }
238 default:
239 return fail("plan", "Unknown action.");
240 }
241}
242
243export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
244 const { slug, role, staff, account, statement, group, plan, securityPlan, limit, entitlements, requests, credits, report, details, trialMicros, firstMonthMicros, notice, problem } =
245 loaderData;
246 const { status } = account;
247 const owner = role === "owner" || loaderData.managesBilling;
248 const error = actionData as SectionError;
249 const err = (section: string) => (error && error.section === section ? error.error : undefined);
250 const standing = planStatus(plan, entitlements);
251 const paying = standing.kind === "paid" || standing.kind === "canceling" || standing.kind === "past_due";
252 const free = standing.kind === "free" || standing.kind === "trial";
253 const prepaid = limit?.prepaidMicros ?? entitlements?.prepaidMicros ?? 0;
254 // The spend limit and its budget are the owners' to set only where
255 // billing lets them: on the plan, never a free workspace's ceiling.
256 const selfServe = !!limit && (limit.trust === "paid" || limit.trust === "established");
257 const summary = (loaded: Awaited<typeof details>) => (
258 <PlanSummary
259 slug={slug}
260 state={plan}
261 status={standing}
262 details={loaded}
263 report={report}
264 owner={owner}
265 enabled={status.enabled}
266 live={status.live}
267 staff={staff}
268 firstMonthMicros={firstMonthMicros}
269 error={err("plan")}
270 />
271 );
272
273 return (
274 <div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_20rem]">
275 <div className="min-w-0">
276 {status.free && (
277 <div className="mb-6 rounded-xl border border-accent/30 bg-accent/5 p-5">
278 <h2 className="font-medium">Free while g1t is being built out</h2>
279 <p className="mt-1.5 max-w-2xl text-sm text-muted">
280 Usage is recorded with what it costs, so Usage shows what you use, but nothing is charged for now. We will say so well
281 before anything is.
282 </p>
283 </div>
284 )}
285 {notice && <p className="mb-6 rounded-lg border border-accent/30 bg-accent/5 px-4 py-2.5 text-sm">{notice}</p>}
286 {problem && <p className="mb-6 rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{problem}</p>}
287
288 <Suspense fallback={null}>
289 <Await resolve={details}>
290 {(loaded) =>
291 loaded?.taxAddressNeededAt ? (
292 <div role="alert" className="mb-6 flex flex-wrap items-start gap-3 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm">
293 <div className="min-w-0 flex-1 basis-64">
294 <p className="font-medium">Add your billing address in Stripe</p>
295 <p className="mt-1 text-muted">
296 Stripe needs it to work out tax, so g1t did not charge the card. Nothing is lost: the charge goes through once the
297 address is saved.{owner ? "" : " An owner or billing manager adds it from Manage billing."}
298 </p>
299 </div>
300 {owner && status.enabled && (
301 <Form method="post">
302 <SubmitButton variant="accent" name="intent" value="portal" pending="Opening Stripe…">
303 Manage billing
304 <ArrowUpRight size={14} />
305 </SubmitButton>
306 </Form>
307 )}
308 </div>
309 ) : null
310 }
311 </Await>
312 </Suspense>
313 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
314 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
315 <Alerts alerts={entitlements?.alerts ?? []} />
316
317 <Suspense fallback={summary(null)}>
318 <Await resolve={details}>{(loaded) => summary(loaded)}</Await>
319 </Suspense>
320
321 {free && status.enabled && (
322 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
323 )}
324
325 {paying && limit && selfServe && (
326 <>
327 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
328 <BudgetAlerts limit={limit} owner={owner} error={err("budget")} />
329 <RaiseCard requests={requests} owner={owner} error={err("raise")} />
330 <PrepayCard prepaidMicros={prepaid} owner={owner} live={status.live || !staff} error={err("prepay")} />
331 </>
332 )}
333
334 {credits && <CreditsCard credits={credits} />}
335
336 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
337
338 <AddOns plan={plan} security={securityPlan} error={err("security")} />
339
340 {status.enabled && owner && (
341 <Suspense fallback={<StripeSkeleton />}>
342 <Await resolve={details}>
343 {(loaded) => <BillingOnStripeCard details={loaded} enabled={status.enabled} live={status.live} staff={staff} error={err("stripe")} />}
344 </Await>
345 </Suspense>
346 )}
347
348 <StatementView slug={slug} statement={statement} group={group} />
349 </div>
350
351 <aside className="space-y-5 text-sm">
352 <Card asChild className="p-5">
353 <section>
354 <h3 className="font-medium">How it is charged</h3>
355 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
356 <li>
357 The plan is {wholeDollars((plan?.plan.monthlyCents ?? 2100) * 10_000)} a month for the workspace, with $10 of usage
358 included. Runner minutes are a flat rate per machine; other usage is charged at what it costs g1t plus{" "}
359 {account.marginPercent}%, from the first second, after what is included.
360 </li>
361 <li>Credit from g1t comes off what you owe, the soonest-expiring first.</li>
362 <li>
363 Each month closes with an itemised invoice. No card is charged less than{" "}
364 {wholeDollars(entitlements?.minChargeMicros ?? 5 * MICROS_PER_DOLLAR)}; less carries over.
365 </li>
366 <li>
367 Prices exclude tax. Stripe adds tax where it applies, worked out from the billing address you keep on Stripe, and it is
368 its own line on every receipt and invoice.
369 </li>
370 <li>No card processing fee on top: it is part of the price.</li>
371 <li>Invoices and receipts are on Stripe's billing page, under Manage billing.</li>
372 <li>No seats: add as many people as you like, once the workspace is on the plan.</li>
373 </ul>
374 <div className="mt-4 space-y-1.5 border-t border-line pt-4 text-sm">
375 <Link to={`/${slug}/-/usage`} className="flex items-center gap-2 text-muted hover:text-fg">
376 <FileText size={14} /> Usage, by product and project
377 </Link>
378 <Link to="/pricing" className="flex items-center gap-2 text-muted hover:text-fg">
379 <CreditCard size={14} /> Pricing and today's prices
380 </Link>
381 <a href="https://docs.g1t.sh/guides/usage-and-billing/" className="flex items-center gap-2 text-muted hover:text-fg">
382 <ArrowUpRight size={14} /> How usage and billing work
383 </a>
384 </div>
385 </section>
386 </Card>
387 </aside>
388 </div>
389 );
390}