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.

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