Skip to content
453 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1/**
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
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging6import type { CreditGrant, Entitlements, FeatureState, Limit, LimitRequest, MeterUsage, Usage, UsageAlert } from "@g1t/contracts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.8import { MICROS_PER_DOLLAR, money, wholeDollars } from "./money.ts";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look9
Merge Stripe Tax, the card fee on card payments, and one free workspace per person10/** 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/**
14 * The card processing fee on a card payment of `cents`, as billing works it
15 * out (`ai::card_fee_cents`): Stripe's percent and fixed fee grossed up, so
16 * what is left after Stripe's fee is the amount, rounded up to the cent.
17 * 0 when the fee is off.
18 */
19export function cardFeeCents(cents: number, fee: { on: boolean; percentMicros: number; fixedCents: number } | null | undefined): number {
20 if (!fee?.on || !(cents > 0)) return 0;
21 const rate = fee.percentMicros / MICROS_PER_DOLLAR;
22 if (!(rate >= 0 && rate < 0.5)) return 0;
23 return Math.max(0, Math.ceil((cents + fee.fixedCents) / (1 - rate)) - cents);
24}
25
26/** "Card processing fee $1.06, plus tax where it applies", as shown before paying. */
27export function feeAndTax(feeCents: number): string {
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.28 return feeCents > 0 ? `Card processing fee ${money(feeCents * 10_000)}, ${PLUS_TAX}` : `Plus tax where it applies`;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person29}
30
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look31/** A form's dollar amount as micros, or null when it is empty or not a number. */
32export function readDollars(value: FormDataEntryValue | null | undefined): number | null {
33 const text = String(value ?? "").replace(/[$,\s]/g, "");
34 if (!text) return null;
35 const amount = Number(text);
36 if (!Number.isFinite(amount)) return null;
37 return Math.round(amount * MICROS_PER_DOLLAR);
38}
39
40export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
41
42/** The smallest prepayment, the most by card, and where bank transfer starts, in dollars. */
43export const PREPAY = { min: 25, maxCard: 10_000, bankFrom: 1_000, presets: [100, 500, 1_000] } as const;
44
45/** A prepayment: a preset or a custom amount, by card or bank transfer. */
46export function parsePrepay(form: {
47 amount?: FormDataEntryValue | null;
48 custom?: FormDataEntryValue | null;
49 method?: FormDataEntryValue | null;
50}): Parsed<{ amountCents: number; method: "card" | "bank_transfer" }> {
51 const micros = readDollars(form.custom) ?? readDollars(form.amount);
52 const method = String(form.method ?? "card") === "bank_transfer" ? "bank_transfer" : "card";
53 if (micros == null) return { ok: false, error: "Choose an amount to prepay." };
54 const amount = micros / MICROS_PER_DOLLAR;
55 if (amount < PREPAY.min) return { ok: false, error: `The smallest prepayment is $${PREPAY.min}.` };
56 if (method === "card" && amount > PREPAY.maxCard)
57 return { ok: false, error: `By card, the most is $${PREPAY.maxCard.toLocaleString("en-US")}; pay more by bank transfer.` };
58 if (method === "bank_transfer" && amount < PREPAY.bankFrom)
59 return { ok: false, error: `Bank transfer is for $${PREPAY.bankFrom.toLocaleString("en-US")} or more.` };
60 return { ok: true, value: { amountCents: Math.round(amount * 100), method } };
61}
62
63/** What owners may set each cap to, in micros, and what applies when they set none. */
64export const CAPS = {
65 run: { min: 100_000, max: 100_000_000, default: 2_000_000 },
66 issue: { min: 1_000_000, max: 1_000_000_000, default: 10_000_000 },
67} as const;
68
69/** The owners' caps: empty goes back to the default. */
70export function parseCaps(form: {
71 run?: FormDataEntryValue | null;
72 issue?: FormDataEntryValue | null;
73}): Parsed<{ runCapMicros: number | null; issueCapMicros: number | null }> {
74 const run = readDollars(form.run);
75 const issue = readDollars(form.issue);
76 if (String(form.run ?? "").trim() && run == null) return { ok: false, error: "A run's cap is a dollar amount." };
77 if (String(form.issue ?? "").trim() && issue == null) return { ok: false, error: "An issue's cap is a dollar amount." };
78 if (run != null && (run < CAPS.run.min || run > CAPS.run.max))
79 return { ok: false, error: `A run's cap is between ${wholeDollars(CAPS.run.min)} and ${wholeDollars(CAPS.run.max)}.` };
80 if (issue != null && (issue < CAPS.issue.min || issue > CAPS.issue.max))
81 return {
82 ok: false,
83 error: `An issue's cap is between ${wholeDollars(CAPS.issue.min)} and ${wholeDollars(CAPS.issue.max)}.`,
84 };
85 return { ok: true, value: { runCapMicros: run, issueCapMicros: issue } };
86}
87
88/** A request for a higher limit, or for help with usage past what was meant. */
89export function parseLimitRequest(form: {
90 kind?: FormDataEntryValue | null;
91 amount?: FormDataEntryValue | null;
92 reason?: FormDataEntryValue | null;
93 expected?: FormDataEntryValue | null;
94}): Parsed<{ kind: "limit" | "overage"; amountMicros: number; reason: string; expectedMonthlyMicros: number }> {
95 const kind = String(form.kind ?? "limit") === "overage" ? "overage" : "limit";
96 const reason = String(form.reason ?? "").trim();
97 const amount = readDollars(form.amount);
98 const expected = readDollars(form.expected);
99 if (!reason)
100 return {
101 ok: false,
102 error: kind === "limit" ? "Say what the higher limit is for." : "Say what happened, so we can look at it.",
103 };
104 if (kind === "limit") {
105 if (amount == null || amount <= 0) return { ok: false, error: "Say the limit you need, in dollars." };
106 if (expected == null || expected < 0) return { ok: false, error: "Say what you expect to spend in a month." };
107 }
108 return {
109 ok: true,
110 value: {
111 kind,
112 amountMicros: Math.max(0, amount ?? 0),
113 reason: reason.slice(0, 2000),
114 expectedMonthlyMicros: Math.max(0, expected ?? 0),
115 },
116 };
117}
118
119/**
120 * A spend limit as owners set it: automatic, fixed at an amount, as high as
121 * is available, or the one-time raise (an amount, or blank for as high as
122 * it goes, which the page fills in from the limit).
123 */
124export function parseSpendLimit(form: {
125 mode?: FormDataEntryValue | null;
126 limit?: FormDataEntryValue | null;
127}): Parsed<{ micros: number | null; useFull: boolean; raiseOnce: boolean }> {
128 const mode = String(form.mode ?? "automatic");
129 if (mode === "full") return { ok: true, value: { micros: null, useFull: true, raiseOnce: false } };
130 if (mode !== "fixed" && mode !== "raise") return { ok: true, value: { micros: null, useFull: false, raiseOnce: false } };
131 const micros = readDollars(form.limit);
132 if (mode === "raise" && micros == null) return { ok: true, value: { micros: null, useFull: false, raiseOnce: true } };
133 if (micros == null || micros < MICROS_PER_DOLLAR) return { ok: false, error: "A spend limit is a dollar amount, $1 or more." };
134 return { ok: true, value: { micros, useFull: false, raiseOnce: mode === "raise" } };
135}
136
137/**
138 * How far owners may set their spend limit themselves: up to the highest
139 * ceiling the workspace has had plus what is prepaid, and, once, up to
140 * twice that highest ceiling. Null `selfServeMicros` means no ceiling
141 * (g1t's own, or staff set it).
142 */
143export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null };
144
145export function spendRange(
146 limit: Pick<Limit, "availableMicros" | "ceilingMicros" | "raiseOnceMicros" | "raisedAt">,
147): SpendRange {
148 const selfServe = limit.availableMicros !== undefined ? limit.availableMicros : limit.ceilingMicros;
149 const once = limit.raiseOnceMicros ?? null;
150 return {
151 selfServeMicros: selfServe ?? null,
152 // The raise only matters when it goes further than owners can already.
153 raiseOnceMicros: once != null && (selfServe == null || once > selfServe) ? once : null,
154 raisedAt: limit.raisedAt ?? null,
155 };
156}
157
158/**
159 * What setting the spend limit to `micros` takes, as billing decides it:
160 * nothing (`self`), the one-time raise (`raise`), or a request to g1t
161 * (`ask`).
162 */
163export function spendPath(micros: number, range: SpendRange): "self" | "raise" | "ask" {
164 if (range.selfServeMicros == null || micros <= range.selfServeMicros) return "self";
165 if (range.raiseOnceMicros != null && micros <= range.raiseOnceMicros) return "raise";
166 return "ask";
167}
168
169/** Where the workspace stands with the g1t plan, for the plan card. */
170export type PlanStatus = {
171 kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise";
172 label: string;
173};
174
175export function planStatus(
176 plan: Pick<FeatureState, "on" | "included" | "subscription"> | null | undefined,
177 entitlements: Pick<Entitlements, "plan" | "trialMicrosLeft" | "trialVerified" | "firstMonth"> | null | undefined,
178): PlanStatus {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging179 if (entitlements?.plan === "internal") return { kind: "comped", label: "100% discount from g1t" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look180 if (entitlements?.plan === "enterprise") return { kind: "enterprise", label: "Paid by an enterprise" };
181 if (plan?.included) return { kind: "comped", label: "Included by g1t" };
182 const subscription = plan?.subscription;
183 if (subscription?.status === "past_due") return { kind: "past_due", label: "Payment failed" };
184 if (plan?.on && subscription?.status === "canceling") return { kind: "canceling", label: "Ends at the end of the period" };
185 if ((plan?.on && subscription) || entitlements?.plan === "paid") {
186 return { kind: "paid", label: entitlements?.firstMonth ? "On the g1t plan, first month" : "On the g1t plan" };
187 }
188 if (entitlements?.trialVerified && entitlements.trialMicrosLeft > 0) return { kind: "trial", label: "Free, on the trial" };
189 return { kind: "free", label: "Free" };
190}
191
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas192/** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */
193const PLAN_ONLY_METERS = new Set(["builds", "requests", "domains"]);
194
195/**
196 * The lines of "This month's usage": every meter on the plan, so it reads
197 * the same each month; without it, only what a free workspace can use,
198 * plus anything that was used anyway (from before the plan ended).
199 */
200export function shownMeters(meters: MeterUsage[], onPlan: boolean): MeterUsage[] {
201 return meters.filter((meter) => onPlan || !PLAN_ONLY_METERS.has(meter.key) || meter.micros > 0);
202}
203
204/** `1 GB`, `500 MB`: storage as it is priced, in powers of ten. */
205export function gigabytes(bytes: number): string {
206 if (bytes >= 1e9) return `${Math.round((bytes / 1e9) * 10) / 10} GB`;
207 return `${Math.round(bytes / 1e6)} MB`;
208}
209
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look210/** A share from 0 to 1 of `used` against `of`, for a meter. */
211export function share(used: number, of: number | null | undefined): number {
212 if (!of || of <= 0) return 0;
213 return Math.min(1, Math.max(0, used / of));
214}
215
216/** Where the trial stands after a card check, in a sentence for the owner. */
217export function cardCheckResult(
218 entitlements: Pick<Entitlements, "trialVerified" | "trialMicrosLeft">,
219 trialMicros: number,
220): string {
221 if (!entitlements.trialVerified) return "The card check did not finish. Try again; the card is never charged.";
222 if (entitlements.trialMicrosLeft > 0) return `Card checked. Your ${wholeDollars(entitlements.trialMicrosLeft)} trial is ready to use.`;
223 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.`;
224}
225
226const METERS: Record<string, string> = {
227 included: "the plan's included usage",
228 spend_limit: "your spend limit",
229 ceiling: "what g1t lets go unpaid",
230};
231
232/** One alert as a sentence: "90% of the plan's included usage: $9.00 of $10.00." */
233export function alertText(alert: UsageAlert): string {
234 const what = METERS[alert.meter] ?? alert.meter.replace(/_/g, " ");
235 const reached = alert.level >= 100 ? `All of ${what}` : `${alert.level}% of ${what}`;
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.236 return `${reached}: ${money(alert.usedMicros)} of ${money(alert.limitMicros)}.`;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look237}
238
239/** How loud an alert is. */
240export function alertTone(level: number): "ok" | "warning" | "stopped" {
241 if (level >= 100) return "stopped";
242 return level >= 75 ? "warning" : "ok";
243}
244
245/** Whether the workspace needs an owner's eye now: compute paused, a spike waiting, or an alert at 90% or more. */
246export function needsAttention(entitlements: Pick<Entitlements, "paused" | "spike" | "alerts"> | null | undefined): boolean {
247 if (!entitlements) return false;
248 if (entitlements.paused) return true;
249 if (entitlements.spike && entitlements.spike.status !== "continued") return true;
250 return (entitlements.alerts ?? []).some((alert) => alert.level >= 90);
251}
252
253/** A request's state as the owner sees it. */
254export function requestStatus(request: LimitRequest): string {
255 if (request.status === "approved")
256 return request.decidedMicros != null ? `Approved at ${wholeDollars(request.decidedMicros)}` : "Approved";
257 if (request.status === "declined") return "Declined";
258 return "Waiting for an answer";
259}
260
261/** What each kind of agent work is called, and its colour, on Usage and the workspace's overview. */
262export const USAGE_TASKS: Record<string, { label: string; color: string }> = {
263 implement: { label: "Making changes", color: "var(--color-merged)" },
264 review: { label: "Reviews", color: "var(--color-info)" },
265 revise: { label: "Revisions", color: "var(--color-warn)" },
Merge branch 'worktree-agent-ad7c6d88d93adc817'266 update: { label: "Catching up", color: "var(--color-success)" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look267 plan: { label: "Planning", color: "#f0a6ca" },
Fast pages, required checks on the branch, self-hosted runners, honest incidents268 /** Time on the workspace's own runners: its minutes, at $0. */
269 self_hosted: { label: "Self-hosted runners ($0)", color: "var(--color-line-strong)" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look270 other: { label: "Other", color: "var(--color-faint)" },
271};
272
273/** A kind of work's words and colour, or Other's. */
274export function usageTask(key: string): { label: string; color: string } {
275 return USAGE_TASKS[key] ?? USAGE_TASKS.other!;
276}
277
Usage lists Other once: kinds of work without words of their own are added into it278/**
279 * Usage by kind of work with every kind that has no words of its own
280 * added into one Other, last, so Other is listed once.
281 */
282export function foldTasks<T extends { key: string; micros: number; runs: number }>(slices: T[]): T[] {
283 const known = slices.filter((slice) => slice.key !== "other" && slice.key in USAGE_TASKS);
284 const rest = slices.filter((slice) => !known.includes(slice));
285 if (rest.length === 0) return known;
286 const other = rest.reduce(
287 (sum, slice) => ({ ...sum, micros: sum.micros + slice.micros, runs: sum.runs + slice.runs }),
288 { ...rest[0]!, key: "other", micros: 0, runs: 0 },
289 );
290 return [...known, other];
291}
292
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look293/** The workspace's month at a glance, for the Usage card on its overview. */
294export type UsageGlance = {
295 /**
296 * `beta` while g1t charges nothing; `comped` when g1t or an enterprise
297 * pays; `plan` on the g1t plan; `trial` on trial credit; `forge` with
298 * neither, where only what runs no compute is open.
299 */
300 kind: "beta" | "comped" | "plan" | "trial" | "forge";
301 /** Charged this month, or used at cost while g1t is free. */
302 spentMicros: number;
303 /** What pays first, and how much of it is used: the plan's included usage, or the trial. */
304 credit: { label: string; usedMicros: number; ofMicros: number } | null;
305 /** Charged past what is included, and the spend limit if there is one; null when it does not apply. */
306 onDemand: { micros: number; limitMicros: number | null } | null;
307 /** What it went on, most first. */
308 lines: { key: string; label: string; micros: number; runs: number }[];
309};
310
311export function usageGlance(input: {
Usage, Billing settings and prepaid AI credit; fixes from the UX audit312 usage: Pick<Usage, "free" | "spentMicros" | "usedMicros" | "byTask" | "priceMicros">;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look313 status: PlanStatus;
314 entitlements: Pick<Entitlements, "includedMicros" | "includedUsedMicros" | "trialMicrosLeft"> | null;
315 limit: Pick<Limit, "spentMicros" | "spendLimitMicros"> | null;
316 trialMicros: number;
317}): UsageGlance {
318 const { usage, status, entitlements, limit, trialMicros } = input;
319 const lines = [...usage.byTask]
320 .filter((slice) => slice.micros > 0)
321 .sort((a, b) => b.micros - a.micros)
322 .map((slice) => ({ key: slice.key, label: usageTask(slice.key).label, micros: slice.micros, runs: slice.runs }));
Usage, Billing settings and prepaid AI credit; fixes from the UX audit323 // Usage at price, the one figure every page shows.
324 const spentMicros = usage.free ? usage.usedMicros : (usage.priceMicros ?? usage.spentMicros);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look325 const plain = { spentMicros, credit: null, onDemand: null, lines };
326 if (usage.free) return { kind: "beta", ...plain };
327 if (status.kind === "comped" || status.kind === "enterprise") return { kind: "comped", ...plain };
328 if (status.kind === "trial") {
329 const left = Math.max(0, entitlements?.trialMicrosLeft ?? 0);
330 const of = Math.max(trialMicros, left);
331 return { ...plain, kind: "trial", credit: { label: "Trial credit", usedMicros: of - left, ofMicros: of } };
332 }
333 if (status.kind === "free") return { kind: "forge", ...plain };
334 const included = entitlements?.includedMicros ?? 0;
335 const includedUsed = Math.min(entitlements?.includedUsedMicros ?? 0, included);
336 return {
337 ...plain,
338 kind: "plan",
339 credit: included > 0 ? { label: "Included usage", usedMicros: includedUsed, ofMicros: included } : null,
340 onDemand: {
341 micros: limit?.spentMicros ?? Math.max(0, spentMicros - includedUsed),
342 limitMicros: limit?.spendLimitMicros ?? null,
343 },
344 };
345}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging346
347/** What a credit from g1t is for, as the Billing page names it. */
348export const CREDIT_KIND: Record<CreditGrant["kind"], string> = {
349 promotional: "Promotional",
350 goodwill: "Goodwill",
351 refund: "Refund",
352 purchased: "Purchased",
353};
354
355/** `Jan 5`, or `Jan 5, 2028` outside this year (UTC). */
356export function shortDay(at: string, now = new Date()): string {
357 const date = new Date(at);
358 const sameYear = date.getUTCFullYear() === now.getUTCFullYear();
359 return date.toLocaleDateString("en-US", { month: "short", day: "numeric", ...(sameYear ? {} : { year: "numeric" }), timeZone: "UTC" });
360}
361
362/**
363 * A credit from g1t in a line: `$25.00 credit, $12.40 left, expires Jan 5`;
364 * once it is spent, expired or withdrawn, says so.
365 */
366export function creditLine(grant: Pick<CreditGrant, "amountMicros" | "leftMicros" | "expiresAt" | "state">, now = new Date()): string {
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.367 const parts = [`${money(grant.amountMicros)} credit`];
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging368 if (grant.state === "open") {
Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means.369 parts.push(`${money(grant.leftMicros)} left`);
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging370 if (grant.expiresAt) parts.push(`expires ${shortDay(grant.expiresAt, now)}`);
371 } else {
372 parts.push(grant.state === "used" ? "all used" : grant.state === "expired" ? "expired" : "withdrawn");
373 }
374 return parts.join(", ");
375}
Usage, Billing settings and prepaid AI credit; fixes from the UX audit376
377/** AI credit's amounts, in dollars, as billing takes them. */
378export const AI_CREDIT = { min: 10, max: 1_000 } as const;
379
380/** A purchase of AI credit: a preset or a custom amount, whole dollars. */
381export function parseAiPurchase(form: { amount?: FormDataEntryValue | null; custom?: FormDataEntryValue | null }): Parsed<{ amountCents: number }> {
382 const chosen = String(form.amount ?? "");
383 const micros = chosen === "custom" ? readDollars(form.custom) : readDollars(chosen);
384 if (micros == null) return { ok: false, error: "Choose an amount, or give one." };
385 const dollarsAsked = micros / MICROS_PER_DOLLAR;
386 if (!Number.isInteger(dollarsAsked) || dollarsAsked < AI_CREDIT.min || dollarsAsked > AI_CREDIT.max) {
387 return { ok: false, error: `Buy between $${AI_CREDIT.min} and $${AI_CREDIT.max.toLocaleString("en-US")} of AI credit, in whole dollars.` };
388 }
389 return { ok: true, value: { amountCents: dollarsAsked * 100 } };
390}
391
392/** Auto-reload's form: on or off, below what, back to what, at most what a month. */
393export function parseAiReload(form: {
394 enabled?: FormDataEntryValue | null;
395 threshold?: FormDataEntryValue | null;
396 target?: FormDataEntryValue | null;
397 monthly?: FormDataEntryValue | null;
398}): Parsed<{ enabled: boolean; thresholdMicros: number; targetMicros: number; monthlyMaxMicros: number }> {
399 const threshold = readDollars(form.threshold);
400 const target = readDollars(form.target);
401 const monthly = readDollars(form.monthly);
402 if (threshold == null || target == null || monthly == null) return { ok: false, error: "Give each amount in whole dollars." };
403 if ([threshold, target, monthly].some((m) => m % MICROS_PER_DOLLAR !== 0 || m < 0)) return { ok: false, error: "Use whole dollars." };
404 if (target < threshold + 10 * MICROS_PER_DOLLAR) return { ok: false, error: "Reload to at least $10 more than the amount it reloads below." };
405 if (monthly < target - threshold) return { ok: false, error: "The monthly maximum has to cover at least one reload." };
406 return { ok: true, value: { enabled: form.enabled === "on", thresholdMicros: threshold, targetMicros: target, monthlyMaxMicros: monthly } };
407}
408
409/** The budget's alerts form: the levels ticked, whether usage pauses, and a webhook. */
410export function parseBudgetAlerts(form: {
411 alerts: FormDataEntryValue[];
412 pause?: FormDataEntryValue | null;
413 webhook?: FormDataEntryValue | null;
414}): Parsed<{ alerts: number[]; pauseAtLimit: boolean; webhook: string | null }> {
415 const alerts = [...new Set(form.alerts.map((a) => Number(a)).filter((a) => [50, 75, 90, 100].includes(a)))].sort((a, b) => b - a);
416 const webhook = String(form.webhook ?? "").trim();
417 if (webhook && !/^https:\/\/[^\s/]+\.[^\s]+$/.test(webhook)) return { ok: false, error: "The webhook is an https:// address." };
418 return { ok: true, value: { alerts, pauseAtLimit: form.pause === "on", webhook: webhook || null } };
419}
420
421/** The invoice details form, each field as given (empty clears it on Stripe). */
422export function parseInvoiceDetails(form: FormData): Parsed<{
423 email: string;
424 name: string;
425 address: { line1: string; line2: string; city: string; state: string; postalCode: string; country: string };
426 taxIdType: string;
427 taxId: string;
428 poNumber: string;
429 language: string;
430}> {
431 const text = (name: string) => String(form.get(name) ?? "").trim();
432 const email = text("email");
433 if (email && !/^[^\s@]+@[^\s@]+$/.test(email)) return { ok: false, error: "That is not an email address." };
434 const country = text("country").toUpperCase();
435 if (country && !/^[A-Z]{2}$/.test(country)) return { ok: false, error: "The country is two letters, such as US or DE." };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person436 // Stripe Tax places a US customer by ZIP code: without it, tax cannot be worked out.
437 if (country === "US" && !text("postalCode")) return { ok: false, error: "Add the ZIP code: in the US, tax is worked out from it." };
Usage, Billing settings and prepaid AI credit; fixes from the UX audit438 const taxIdType = text("taxIdType");
439 const taxId = text("taxId");
440 if (Boolean(taxIdType) !== Boolean(taxId)) return { ok: false, error: "Give the tax ID's kind and its number together." };
441 return {
442 ok: true,
443 value: {
444 email,
445 name: text("name"),
446 address: { line1: text("line1"), line2: text("line2"), city: text("city"), state: text("state"), postalCode: text("postalCode"), country },
447 taxIdType,
448 taxId,
449 poNumber: text("poNumber"),
450 language: text("language"),
451 },
452 };
453}

This file's history is long; its oldest lines are credited to the oldest commit read.