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.

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