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.

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