Skip to content

g1t/apps/web/app/lib/billing.ts

469 lines23,455 bytesCodeBlame

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

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