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/lib/billing.ts

322 lines15,858 bytesCodeBlame
1/**
2 * How the billing pages speak of money, read their forms and word a
3 * workspace's alerts. Pure, so it can be tested.
4 */
5
6import type { Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts";
7
8/** Millionths of a dollar in one dollar, as `MICROS_PER_DOLLAR`; here so the tests need no build of the contracts. */
9const MICROS_PER_DOLLAR = 1_000_000;
10
11/** Millionths of a dollar as dollars, to the cent or finer. */
12export function dollars(micros: number, digits = 2): string {
13 const sign = micros < 0 ? "−" : "";
14 return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`;
15}
16
17/** Whole dollars when they are whole, with thousands separated: "$1,000", "$0.10". */
18export function wholeDollars(micros: number): string {
19 const d = micros / MICROS_PER_DOLLAR;
20 const sign = d < 0 ? "−" : "";
21 const abs = Math.abs(d);
22 const digits = Number.isInteger(abs) ? 0 : 2;
23 return `${sign}$${abs.toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
24}
25
26/** A form's dollar amount as micros, or null when it is empty or not a number. */
27export function readDollars(value: FormDataEntryValue | null | undefined): number | null {
28 const text = String(value ?? "").replace(/[$,\s]/g, "");
29 if (!text) return null;
30 const amount = Number(text);
31 if (!Number.isFinite(amount)) return null;
32 return Math.round(amount * MICROS_PER_DOLLAR);
33}
34
35export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
36
37/** The smallest prepayment, the most by card, and where bank transfer starts, in dollars. */
38export const PREPAY = { min: 25, maxCard: 10_000, bankFrom: 1_000, presets: [100, 500, 1_000] } as const;
39
40/** A prepayment: a preset or a custom amount, by card or bank transfer. */
41export function parsePrepay(form: {
42 amount?: FormDataEntryValue | null;
43 custom?: FormDataEntryValue | null;
44 method?: FormDataEntryValue | null;
45}): Parsed<{ amountCents: number; method: "card" | "bank_transfer" }> {
46 const micros = readDollars(form.custom) ?? readDollars(form.amount);
47 const method = String(form.method ?? "card") === "bank_transfer" ? "bank_transfer" : "card";
48 if (micros == null) return { ok: false, error: "Choose an amount to prepay." };
49 const amount = micros / MICROS_PER_DOLLAR;
50 if (amount < PREPAY.min) return { ok: false, error: `The smallest prepayment is $${PREPAY.min}.` };
51 if (method === "card" && amount > PREPAY.maxCard)
52 return { ok: false, error: `By card, the most is $${PREPAY.maxCard.toLocaleString("en-US")}; pay more by bank transfer.` };
53 if (method === "bank_transfer" && amount < PREPAY.bankFrom)
54 return { ok: false, error: `Bank transfer is for $${PREPAY.bankFrom.toLocaleString("en-US")} or more.` };
55 return { ok: true, value: { amountCents: Math.round(amount * 100), method } };
56}
57
58/** What owners may set each cap to, in micros, and what applies when they set none. */
59export const CAPS = {
60 run: { min: 100_000, max: 100_000_000, default: 2_000_000 },
61 issue: { min: 1_000_000, max: 1_000_000_000, default: 10_000_000 },
62} as const;
63
64/** The owners' caps: empty goes back to the default. */
65export function parseCaps(form: {
66 run?: FormDataEntryValue | null;
67 issue?: FormDataEntryValue | null;
68}): Parsed<{ runCapMicros: number | null; issueCapMicros: number | null }> {
69 const run = readDollars(form.run);
70 const issue = readDollars(form.issue);
71 if (String(form.run ?? "").trim() && run == null) return { ok: false, error: "A run's cap is a dollar amount." };
72 if (String(form.issue ?? "").trim() && issue == null) return { ok: false, error: "An issue's cap is a dollar amount." };
73 if (run != null && (run < CAPS.run.min || run > CAPS.run.max))
74 return { ok: false, error: `A run's cap is between ${wholeDollars(CAPS.run.min)} and ${wholeDollars(CAPS.run.max)}.` };
75 if (issue != null && (issue < CAPS.issue.min || issue > CAPS.issue.max))
76 return {
77 ok: false,
78 error: `An issue's cap is between ${wholeDollars(CAPS.issue.min)} and ${wholeDollars(CAPS.issue.max)}.`,
79 };
80 return { ok: true, value: { runCapMicros: run, issueCapMicros: issue } };
81}
82
83/** A request for a higher limit, or for help with usage past what was meant. */
84export function parseLimitRequest(form: {
85 kind?: FormDataEntryValue | null;
86 amount?: FormDataEntryValue | null;
87 reason?: FormDataEntryValue | null;
88 expected?: FormDataEntryValue | null;
89}): Parsed<{ kind: "limit" | "overage"; amountMicros: number; reason: string; expectedMonthlyMicros: number }> {
90 const kind = String(form.kind ?? "limit") === "overage" ? "overage" : "limit";
91 const reason = String(form.reason ?? "").trim();
92 const amount = readDollars(form.amount);
93 const expected = readDollars(form.expected);
94 if (!reason)
95 return {
96 ok: false,
97 error: kind === "limit" ? "Say what the higher limit is for." : "Say what happened, so we can look at it.",
98 };
99 if (kind === "limit") {
100 if (amount == null || amount <= 0) return { ok: false, error: "Say the limit you need, in dollars." };
101 if (expected == null || expected < 0) return { ok: false, error: "Say what you expect to spend in a month." };
102 }
103 return {
104 ok: true,
105 value: {
106 kind,
107 amountMicros: Math.max(0, amount ?? 0),
108 reason: reason.slice(0, 2000),
109 expectedMonthlyMicros: Math.max(0, expected ?? 0),
110 },
111 };
112}
113
114/**
115 * A spend limit as owners set it: automatic, fixed at an amount, as high as
116 * is available, or the one-time raise (an amount, or blank for as high as
117 * it goes, which the page fills in from the limit).
118 */
119export function parseSpendLimit(form: {
120 mode?: FormDataEntryValue | null;
121 limit?: FormDataEntryValue | null;
122}): Parsed<{ micros: number | null; useFull: boolean; raiseOnce: boolean }> {
123 const mode = String(form.mode ?? "automatic");
124 if (mode === "full") return { ok: true, value: { micros: null, useFull: true, raiseOnce: false } };
125 if (mode !== "fixed" && mode !== "raise") return { ok: true, value: { micros: null, useFull: false, raiseOnce: false } };
126 const micros = readDollars(form.limit);
127 if (mode === "raise" && micros == null) return { ok: true, value: { micros: null, useFull: false, raiseOnce: true } };
128 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "A spend limit is a dollar amount, $1 or more." };
129 return { ok: true, value: { micros, useFull: false, raiseOnce: mode === "raise" } };
130}
131
132/**
133 * How far owners may set their spend limit themselves: up to the highest
134 * ceiling the workspace has had plus what is prepaid, and, once, up to
135 * twice that highest ceiling. Null `selfServeMicros` means no ceiling
136 * (g1t's own, or staff set it).
137 */
138export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null };
139
140export function spendRange(
141 limit: Pick<Limit, "availableMicros" | "ceilingMicros" | "raiseOnceMicros" | "raisedAt">,
142): SpendRange {
143 const selfServe = limit.availableMicros !== undefined ? limit.availableMicros : limit.ceilingMicros;
144 const once = limit.raiseOnceMicros ?? null;
145 return {
146 selfServeMicros: selfServe ?? null,
147 // The raise only matters when it goes further than owners can already.
148 raiseOnceMicros: once != null && (selfServe == null || once > selfServe) ? once : null,
149 raisedAt: limit.raisedAt ?? null,
150 };
151}
152
153/**
154 * What setting the spend limit to `micros` takes, as billing decides it:
155 * nothing (`self`), the one-time raise (`raise`), or a request to g1t
156 * (`ask`).
157 */
158export function spendPath(micros: number, range: SpendRange): "self" | "raise" | "ask" {
159 if (range.selfServeMicros == null || micros <= range.selfServeMicros) return "self";
160 if (range.raiseOnceMicros != null && micros <= range.raiseOnceMicros) return "raise";
161 return "ask";
162}
163
164/** Where the workspace stands with the g1t plan, for the plan card. */
165export type PlanStatus = {
166 kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise";
167 label: string;
168};
169
170export function planStatus(
171 plan: Pick<FeatureState, "on" | "included" | "subscription"> | null | undefined,
172 entitlements: Pick<Entitlements, "plan" | "trialMicrosLeft" | "trialVerified" | "firstMonth"> | null | undefined,
173): PlanStatus {
174 if (entitlements?.plan === "internal") return { kind: "comped", label: "Comped by g1t" };
175 if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" };
176 if (plan?.included) return { kind: "comped", label: "Included by g1t" };
177 const subscription = plan?.subscription;
178 if (subscription?.status === "past_due") return { kind: "past_due", label: "Payment failed" };
179 if (plan?.on && subscription?.status === "canceling") return { kind: "canceling", label: "Ends at the end of the period" };
180 if ((plan?.on && subscription) || entitlements?.plan === "paid") {
181 return { kind: "paid", label: entitlements?.firstMonth ? "On the g1t plan, first month" : "On the g1t plan" };
182 }
183 if (entitlements?.trialVerified && entitlements.trialMicrosLeft > 0) return { kind: "trial", label: "Free, on the trial" };
184 return { kind: "free", label: "Free" };
185}
186
187/** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */
188const PLAN_ONLY_METERS = new Set(["builds", "requests", "domains"]);
189
190/**
191 * The lines of "This month's usage": every meter on the plan, so it reads
192 * the same each month; without it, only what a free workspace can use,
193 * plus anything that was used anyway (from before the plan ended).
194 */
195export function shownMeters(meters: MeterUsage[], onPlan: boolean): MeterUsage[] {
196 return meters.filter((meter) => onPlan || !PLAN_ONLY_METERS.has(meter.key) || meter.micros > 0);
197}
198
199/** `1 GB`, `500 MB`: storage as it is priced, in powers of ten. */
200export function gigabytes(bytes: number): string {
201 if (bytes >= 1e9) return `${Math.round((bytes / 1e9) * 10) / 10} GB`;
202 return `${Math.round(bytes / 1e6)} MB`;
203}
204
205/** A share from 0 to 1 of `used` against `of`, for a meter. */
206export function share(used: number, of: number | null | undefined): number {
207 if (!of || of <= 0) return 0;
208 return Math.min(1, Math.max(0, used / of));
209}
210
211/** Where the trial stands after a card check, in a sentence for the owner. */
212export function cardCheckResult(
213 entitlements: Pick<Entitlements, "trialVerified" | "trialMicrosLeft">,
214 trialMicros: number,
215): string {
216 if (!entitlements.trialVerified) return "The card check did not finish. Try again; the card is never charged.";
217 if (entitlements.trialMicrosLeft > 0) return `Card checked. Your ${wholeDollars(entitlements.trialMicrosLeft)} trial is ready to use.`;
218 return `Card checked, but no trial started. The ${wholeDollars(trialMicros)} trial needs a credit or debit card that has not started one before, and comes from a monthly pool that can run out. Prepaid cards can still pay for the plan.`;
219}
220
221const METERS: Record<string, string> = {
222 included: "the plan's included usage",
223 spend_limit: "your spend limit",
224 ceiling: "what g1t lets go unpaid",
225};
226
227/** One alert as a sentence: "90% of the plan's included usage: $9.00 of $10.00." */
228export function alertText(alert: UsageAlert): string {
229 const what = METERS[alert.meter] ?? alert.meter.replace(/_/g, " ");
230 const reached = alert.level >= 100 ? `All of ${what}` : `${alert.level}% of ${what}`;
231 return `${reached}: ${dollars(alert.usedMicros)} of ${dollars(alert.limitMicros)}.`;
232}
233
234/** How loud an alert is. */
235export function alertTone(level: number): "ok" | "warning" | "stopped" {
236 if (level >= 100) return "stopped";
237 return level >= 75 ? "warning" : "ok";
238}
239
240/** Whether the workspace needs an owner's eye now: compute paused, a spike waiting, or an alert at 90% or more. */
241export function needsAttention(entitlements: Pick<Entitlements, "paused" | "spike" | "alerts"> | null | undefined): boolean {
242 if (!entitlements) return false;
243 if (entitlements.paused) return true;
244 if (entitlements.spike && entitlements.spike.status !== "continued") return true;
245 return (entitlements.alerts ?? []).some((alert) => alert.level >= 90);
246}
247
248/** A request's state as the owner sees it. */
249export function requestStatus(request: LimitRequest): string {
250 if (request.status === "approved")
251 return request.decidedMicros != null ? `Approved at ${wholeDollars(request.decidedMicros)}` : "Approved";
252 if (request.status === "declined") return "Declined";
253 return "Waiting for an answer";
254}
255
256/** What each kind of agent work is called, and its colour, on Usage and the workspace's overview. */
257export const USAGE_TASKS: Record<string, { label: string; color: string }> = {
258 implement: { label: "Making changes", color: "var(--color-merged)" },
259 review: { label: "Reviews", color: "var(--color-info)" },
260 revise: { label: "Revisions", color: "var(--color-warn)" },
261 update: { label: "Catching up", color: "var(--color-accent)" },
262 plan: { label: "Planning", color: "#f0a6ca" },
263 other: { label: "Other", color: "var(--color-faint)" },
264};
265
266/** A kind of work's words and colour, or Other's. */
267export function usageTask(key: string): { label: string; color: string } {
268 return USAGE_TASKS[key] ?? USAGE_TASKS.other!;
269}
270
271/** The workspace's month at a glance, for the Usage card on its overview. */
272export type UsageGlance = {
273 /**
274 * `beta` while g1t charges nothing; `comped` when g1t or an enterprise
275 * pays; `plan` on the g1t plan; `trial` on trial credit; `forge` with
276 * neither, where only what runs no compute is open.
277 */
278 kind: "beta" | "comped" | "plan" | "trial" | "forge";
279 /** Charged this month, or used at cost while g1t is free. */
280 spentMicros: number;
281 /** What pays first, and how much of it is used: the plan's included usage, or the trial. */
282 credit: { label: string; usedMicros: number; ofMicros: number } | null;
283 /** Charged past what is included, and the spend limit if there is one; null when it does not apply. */
284 onDemand: { micros: number; limitMicros: number | null } | null;
285 /** What it went on, most first. */
286 lines: { key: string; label: string; micros: number; runs: number }[];
287};
288
289export function usageGlance(input: {
290 usage: Pick<Usage, "free" | "spentMicros" | "usedMicros" | "byTask">;
291 status: PlanStatus;
292 entitlements: Pick<Entitlements, "includedMicros" | "includedUsedMicros" | "trialMicrosLeft"> | null;
293 limit: Pick<Limit, "spentMicros" | "spendLimitMicros"> | null;
294 trialMicros: number;
295}): UsageGlance {
296 const { usage, status, entitlements, limit, trialMicros } = input;
297 const lines = [...usage.byTask]
298 .filter((slice) => slice.micros > 0)
299 .sort((a, b) => b.micros - a.micros)
300 .map((slice) => ({ key: slice.key, label: usageTask(slice.key).label, micros: slice.micros, runs: slice.runs }));
301 const spentMicros = usage.free ? usage.usedMicros : usage.spentMicros;
302 const plain = { spentMicros, credit: null, onDemand: null, lines };
303 if (usage.free) return { kind: "beta", ...plain };
304 if (status.kind === "comped" || status.kind === "enterprise") return { kind: "comped", ...plain };
305 if (status.kind === "trial") {
306 const left = Math.max(0, entitlements?.trialMicrosLeft ?? 0);
307 const of = Math.max(trialMicros, left);
308 return { ...plain, kind: "trial", credit: { label: "Trial credit", usedMicros: of - left, ofMicros: of } };
309 }
310 if (status.kind === "free") return { kind: "forge", ...plain };
311 const included = entitlements?.includedMicros ?? 0;
312 const includedUsed = Math.min(entitlements?.includedUsedMicros ?? 0, included);
313 return {
314 ...plain,
315 kind: "plan",
316 credit: included > 0 ? { label: "Included usage", usedMicros: includedUsed, ofMicros: included } : null,
317 onDemand: {
318 micros: limit?.spentMicros ?? Math.max(0, spentMicros - includedUsed),
319 limitMicros: limit?.spendLimitMicros ?? null,
320 },
321 };
322}