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
345 linesCodeBlameRaw
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 { CreditGrant, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts";
7
8import { MICROS_PER_DOLLAR, money, wholeDollars } from "./money.ts";
9
10/** Prices on g1t exclude tax; Stripe adds it at checkout from the billing address. */
11export const PLUS_TAX = "plus tax where it applies";
12
13/** A form's dollar amount as micros, or null when it is empty or not a number. */
14export function readDollars(value: FormDataEntryValue | null | undefined): number | null {
15 const text = String(value ?? "").replace(/[$,\s]/g, "");
16 if (!text) return null;
17 const amount = Number(text);
18 if (!Number.isFinite(amount)) return null;
19 return Math.round(amount * MICROS_PER_DOLLAR);
20}
21
22export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
23
24/** The smallest prepayment, the most by card, and where bank transfer starts, in dollars. */
25export const PREPAY = { min: 25, maxCard: 10_000, bankFrom: 1_000, presets: [100, 500, 1_000] } as const;
26
27/** A prepayment: a preset or a custom amount, by card or bank transfer. */
28export function parsePrepay(form: {
29 amount?: FormDataEntryValue | null;
30 custom?: FormDataEntryValue | null;
31 method?: FormDataEntryValue | null;
32}): Parsed<{ amountCents: number; method: "card" | "bank_transfer" }> {
33 const micros = readDollars(form.custom) ?? readDollars(form.amount);
34 const method = String(form.method ?? "card") === "bank_transfer" ? "bank_transfer" : "card";
35 if (micros == null) return { ok: false, error: "Choose an amount to prepay." };
36 const amount = micros / MICROS_PER_DOLLAR;
37 if (amount < PREPAY.min) return { ok: false, error: `The smallest prepayment is $${PREPAY.min}.` };
38 if (method === "card" && amount > PREPAY.maxCard)
39 return { ok: false, error: `By card, the most is $${PREPAY.maxCard.toLocaleString("en-US")}; pay more by bank transfer.` };
40 if (method === "bank_transfer" && amount < PREPAY.bankFrom)
41 return { ok: false, error: `Bank transfer is for $${PREPAY.bankFrom.toLocaleString("en-US")} or more.` };
42 return { ok: true, value: { amountCents: Math.round(amount * 100), method } };
43}
44
45/** A request for a higher limit, or for help with usage past what was meant. */
46export function parseLimitRequest(form: {
47 kind?: FormDataEntryValue | null;
48 amount?: FormDataEntryValue | null;
49 reason?: FormDataEntryValue | null;
50 expected?: FormDataEntryValue | null;
51}): Parsed<{ kind: "limit" | "overage"; amountMicros: number; reason: string; expectedMonthlyMicros: number }> {
52 const kind = String(form.kind ?? "limit") === "overage" ? "overage" : "limit";
53 const reason = String(form.reason ?? "").trim();
54 const amount = readDollars(form.amount);
55 const expected = readDollars(form.expected);
56 if (!reason)
57 return {
58 ok: false,
59 error: kind === "limit" ? "Say what the higher limit is for." : "Say what happened, so we can look at it.",
60 };
61 if (kind === "limit") {
62 if (amount == null || amount <= 0) return { ok: false, error: "Say the limit you need, in dollars." };
63 if (expected == null || expected < 0) return { ok: false, error: "Say what you expect to spend in a month." };
64 }
65 return {
66 ok: true,
67 value: {
68 kind,
69 amountMicros: Math.max(0, amount ?? 0),
70 reason: reason.slice(0, 2000),
71 expectedMonthlyMicros: Math.max(0, expected ?? 0),
72 },
73 };
74}
75
76/**
77 * A spend limit as owners set it: automatic, fixed at an amount, as high as
78 * is available, or the one-time raise (an amount, or blank for as high as
79 * it goes, which the page fills in from the limit).
80 */
81export function parseSpendLimit(form: {
82 mode?: FormDataEntryValue | null;
83 limit?: FormDataEntryValue | null;
84}): Parsed<{ micros: number | null; useFull: boolean; raiseOnce: boolean }> {
85 const mode = String(form.mode ?? "automatic");
86 if (mode === "full") return { ok: true, value: { micros: null, useFull: true, raiseOnce: false } };
87 if (mode !== "fixed" && mode !== "raise") return { ok: true, value: { micros: null, useFull: false, raiseOnce: false } };
88 const micros = readDollars(form.limit);
89 if (mode === "raise" && micros == null) return { ok: true, value: { micros: null, useFull: false, raiseOnce: true } };
90 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "A spend limit is a dollar amount, $1 or more." };
91 return { ok: true, value: { micros, useFull: false, raiseOnce: mode === "raise" } };
92}
93
94/**
95 * How far owners may set their spend limit themselves: up to the highest
96 * ceiling the workspace has had plus what is prepaid, and, once, up to
97 * twice that highest ceiling. Null `selfServeMicros` means no ceiling
98 * (g1t's own, or staff set it).
99 */
100export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null };
101
102export function spendRange(
103 limit: Pick<Limit, "availableMicros" | "ceilingMicros" | "raiseOnceMicros" | "raisedAt">,
104): SpendRange {
105 const selfServe = limit.availableMicros !== undefined ? limit.availableMicros : limit.ceilingMicros;
106 const once = limit.raiseOnceMicros ?? null;
107 return {
108 selfServeMicros: selfServe ?? null,
109 // The raise only matters when it goes further than owners can already.
110 raiseOnceMicros: once != null && (selfServe == null || once > selfServe) ? once : null,
111 raisedAt: limit.raisedAt ?? null,
112 };
113}
114
115/**
116 * What setting the spend limit to `micros` takes, as billing decides it:
117 * nothing (`self`), the one-time raise (`raise`), or a request to g1t
118 * (`ask`).
119 */
120export function spendPath(micros: number, range: SpendRange): "self" | "raise" | "ask" {
121 if (range.selfServeMicros == null || micros <= range.selfServeMicros) return "self";
122 if (range.raiseOnceMicros != null && micros <= range.raiseOnceMicros) return "raise";
123 return "ask";
124}
125
126/** Where the workspace stands with the g1t plan, for the plan card. */
127export type PlanStatus = {
128 kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise" | "complimentary";
129 label: string;
130};
131
132export function planStatus(
133 plan: Pick<FeatureState, "on" | "included" | "subscription"> | null | undefined,
134 entitlements: Pick<Entitlements, "plan" | "trialMicrosLeft" | "trialVerified" | "firstMonth"> | null | undefined,
135): PlanStatus {
136 if (entitlements?.plan === "internal") return { kind: "comped", label: "100% discount from g1t" };
137 if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" };
138 if (entitlements?.plan === "complimentary") return { kind: "complimentary", label: "Complimentary from g1t" };
139 if (plan?.included) return { kind: "comped", label: "Included by g1t" };
140 const subscription = plan?.subscription;
141 if (subscription?.status === "past_due") return { kind: "past_due", label: "Payment failed" };
142 if (plan?.on && subscription?.status === "canceling") return { kind: "canceling", label: "Ends at the end of the period" };
143 if ((plan?.on && subscription) || entitlements?.plan === "paid") {
144 return { kind: "paid", label: entitlements?.firstMonth ? "On the g1t plan, first month" : "On the g1t plan" };
145 }
146 if (entitlements?.trialVerified && entitlements.trialMicrosLeft > 0) return { kind: "trial", label: "Free, on the trial" };
147 return { kind: "free", label: "Free" };
148}
149
150/** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */
151const PLAN_ONLY_METERS = new Set(["builds", "requests", "domains"]);
152
153/**
154 * The lines of "This month's usage": every meter on the plan, so it reads
155 * the same each month; without it, only what a free workspace can use,
156 * plus anything that was used anyway (from before the plan ended).
157 */
158export function shownMeters(meters: MeterUsage[], onPlan: boolean): MeterUsage[] {
159 return meters.filter((meter) => onPlan || !PLAN_ONLY_METERS.has(meter.key) || meter.micros > 0);
160}
161
162/** `1 GB`, `500 MB`: storage as it is priced, in powers of ten. */
163export function gigabytes(bytes: number): string {
164 if (bytes >= 1e9) return `${Math.round((bytes / 1e9) * 10) / 10} GB`;
165 return `${Math.round(bytes / 1e6)} MB`;
166}
167
168/** A share from 0 to 1 of `used` against `of`, for a meter. */
169export function share(used: number, of: number | null | undefined): number {
170 if (!of || of <= 0) return 0;
171 return Math.min(1, Math.max(0, used / of));
172}
173
174/** Where the trial stands after a card check, in a sentence for the owner. */
175export function cardCheckResult(
176 entitlements: Pick<Entitlements, "trialVerified" | "trialMicrosLeft">,
177 trialMicros: number,
178): string {
179 if (!entitlements.trialVerified) return "The card check did not finish. Try again; the card is never charged.";
180 if (entitlements.trialMicrosLeft > 0) return `Card checked. Your ${wholeDollars(entitlements.trialMicrosLeft)} trial is ready to use.`;
181 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.`;
182}
183
184const METERS: Record<string, string> = {
185 included: "the plan's included usage",
186 spend_limit: "your spend limit",
187 ceiling: "what g1t lets go unpaid",
188};
189
190/** One alert as a sentence: "90% of the plan's included usage: $9.00 of $10.00." */
191export function alertText(alert: UsageAlert): string {
192 const what = METERS[alert.meter] ?? alert.meter.replace(/_/g, " ");
193 const reached = alert.level >= 100 ? `All of ${what}` : `${alert.level}% of ${what}`;
194 return `${reached}: ${money(alert.usedMicros)} of ${money(alert.limitMicros)}.`;
195}
196
197/** How loud an alert is. */
198export function alertTone(level: number): "ok" | "warning" | "stopped" {
199 if (level >= 100) return "stopped";
200 return level >= 75 ? "warning" : "ok";
201}
202
203/** Whether the workspace needs an owner's eye now: compute paused, a spike waiting, or an alert at 90% or more. */
204export function needsAttention(entitlements: Pick<Entitlements, "paused" | "spike" | "alerts"> | null | undefined): boolean {
205 if (!entitlements) return false;
206 if (entitlements.paused) return true;
207 if (entitlements.spike && entitlements.spike.status !== "continued") return true;
208 return (entitlements.alerts ?? []).some((alert) => alert.level >= 90);
209}
210
211/** A request's state as the owner sees it. */
212export function requestStatus(request: LimitRequest): string {
213 if (request.status === "approved")
214 return request.decidedMicros != null ? `Approved at ${wholeDollars(request.decidedMicros)}` : "Approved";
215 if (request.status === "declined") return "Declined";
216 return "Waiting for an answer";
217}
218
219/** What each kind of work is called, and its colour, on Usage and the workspace's overview; the agent kinds are in older months. */
220export const USAGE_TASKS: Record<string, { label: string; color: string }> = {
221 implement: { label: "Making changes", color: "var(--color-merged)" },
222 review: { label: "Reviews", color: "var(--color-info)" },
223 revise: { label: "Revisions", color: "var(--color-warn)" },
224 update: { label: "Catching up", color: "var(--color-success)" },
225 plan: { label: "Planning", color: "#f0a6ca" },
226 /** Time on the workspace's own runners: its minutes, at $0. */
227 self_hosted: { label: "Self-hosted runners ($0)", color: "var(--color-line-strong)" },
228 other: { label: "Other", color: "var(--color-faint)" },
229};
230
231/** A kind of work's words and colour, or Other's. */
232export function usageTask(key: string): { label: string; color: string } {
233 return USAGE_TASKS[key] ?? USAGE_TASKS.other!;
234}
235
236/**
237 * Usage by kind of work with every kind that has no words of its own
238 * added into one Other, last, so Other is listed once.
239 */
240export function foldTasks<T extends { key: string; micros: number; runs: number }>(slices: T[]): T[] {
241 const known = slices.filter((slice) => slice.key !== "other" && slice.key in USAGE_TASKS);
242 const rest = slices.filter((slice) => !known.includes(slice));
243 if (rest.length === 0) return known;
244 const other = rest.reduce(
245 (sum, slice) => ({ ...sum, micros: sum.micros + slice.micros, runs: sum.runs + slice.runs }),
246 { ...rest[0]!, key: "other", micros: 0, runs: 0 },
247 );
248 return [...known, other];
249}
250
251/** The workspace's month at a glance, for the Usage card on its overview. */
252export type UsageGlance = {
253 /**
254 * `beta` while g1t charges nothing; `comped` when g1t or an enterprise
255 * pays; `plan` on the g1t plan; `trial` on trial credit; `forge` with
256 * neither, where only what runs no compute is open.
257 */
258 kind: "beta" | "comped" | "plan" | "trial" | "forge";
259 /** Charged this month, or used at cost while g1t is free. */
260 spentMicros: number;
261 /** What pays first, and how much of it is used: the plan's included usage, or the trial. */
262 credit: { label: string; usedMicros: number; ofMicros: number } | null;
263 /** Charged past what is included, and the spend limit if there is one; null when it does not apply. */
264 onDemand: { micros: number; limitMicros: number | null } | null;
265 /** What it went on, most first. */
266 lines: { key: string; label: string; micros: number; runs: number }[];
267};
268
269export function usageGlance(input: {
270 usage: Pick<Usage, "free" | "spentMicros" | "usedMicros" | "byTask" | "priceMicros">;
271 status: PlanStatus;
272 entitlements: Pick<Entitlements, "includedMicros" | "includedUsedMicros" | "trialMicrosLeft"> | null;
273 limit: Pick<Limit, "spentMicros" | "spendLimitMicros"> | null;
274 trialMicros: number;
275}): UsageGlance {
276 const { usage, status, entitlements, limit, trialMicros } = input;
277 const lines = [...usage.byTask]
278 .filter((slice) => slice.micros > 0)
279 .sort((a, b) => b.micros - a.micros)
280 .map((slice) => ({ key: slice.key, label: usageTask(slice.key).label, micros: slice.micros, runs: slice.runs }));
281 // Usage at price, the one figure every page shows.
282 const spentMicros = usage.free ? usage.usedMicros : (usage.priceMicros ?? usage.spentMicros);
283 const plain = { spentMicros, credit: null, onDemand: null, lines };
284 if (usage.free) return { kind: "beta", ...plain };
285 if (status.kind === "comped" || status.kind === "enterprise") return { kind: "comped", ...plain };
286 if (status.kind === "trial") {
287 const left = Math.max(0, entitlements?.trialMicrosLeft ?? 0);
288 const of = Math.max(trialMicros, left);
289 return { ...plain, kind: "trial", credit: { label: "Trial credit", usedMicros: of - left, ofMicros: of } };
290 }
291 if (status.kind === "free") return { kind: "forge", ...plain };
292 const included = entitlements?.includedMicros ?? 0;
293 const includedUsed = Math.min(entitlements?.includedUsedMicros ?? 0, included);
294 return {
295 ...plain,
296 kind: "plan",
297 credit: included > 0 ? { label: "Included usage", usedMicros: includedUsed, ofMicros: included } : null,
298 onDemand: {
299 micros: limit?.spentMicros ?? Math.max(0, spentMicros - includedUsed),
300 limitMicros: limit?.spendLimitMicros ?? null,
301 },
302 };
303}
304
305/** What a credit from g1t is for, as the Billing page names it. */
306export const CREDIT_KIND: Record<CreditGrant["kind"], string> = {
307 promotional: "Promotional",
308 goodwill: "Goodwill",
309 refund: "Refund",
310 purchased: "Purchased",
311};
312
313/** `Jan 5`, or `Jan 5, 2028` outside this year (UTC). */
314export function shortDay(at: string, now = new Date()): string {
315 const date = new Date(at);
316 const sameYear = date.getUTCFullYear() === now.getUTCFullYear();
317 return date.toLocaleDateString("en-US", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" });
318}
319
320/**
321 * A credit from g1t in a line: `$25.00 credit, $12.40 left, expires Jan 5`;
322 * once it is spent, expired or withdrawn, says so.
323 */
324export function creditLine(grant: Pick<CreditGrant, "amountMicros" | "leftMicros" | "expiresAt" | "state">, now = new Date()): string {
325 const parts = [`${money(grant.amountMicros)} credit`];
326 if (grant.state === "open") {
327 parts.push(`${money(grant.leftMicros)} left`);
328 if (grant.expiresAt) parts.push(`expires ${shortDay(grant.expiresAt, now)}`);
329 } else {
330 parts.push(grant.state === "used" ? "all used" : grant.state === "expired" ? "expired" : "withdrawn");
331 }
332 return parts.join(", ");
333}
334
335/** The budget's alerts form: the levels ticked, whether usage pauses, and a webhook. */
336export function parseBudgetAlerts(form: {
337 alerts: FormDataEntryValue[];
338 pause?: FormDataEntryValue | null;
339 webhook?: FormDataEntryValue | null;
340}): Parsed<{ alerts: number[]; pauseAtLimit: boolean; webhook: string | null }> {
341 const alerts = [...new Set(form.alerts.map((a) => Number(a)).filter((a) => [50, 75, 90, 100].includes(a)))].sort((a, b) => b - a);
342 const webhook = String(form.webhook ?? "").trim();
343 if (webhook && !/^https:\/\/[^\s/]+\.[^\s]+$/.test(webhook)) return { ok: false, error: "The webhook is an https:// address." };
344 return { ok: true, value: { alerts, pauseAtLimit: form.pause === "on", webhook: webhook || null } };
345}