g1t/apps/web/app/lib/billing.ts
| 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 | |
| 6 | import 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. */ |
| 9 | const MICROS_PER_DOLLAR = 1_000_000; |
| 10 | |
| 11 | /** Millionths of a dollar as dollars, to the cent or finer. */ |
| 12 | export 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". */ |
| 18 | export 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. */ |
| 27 | export 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 | |
| 35 | export 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. */ |
| 38 | export 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. */ |
| 41 | export 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. */ |
| 59 | export 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. */ |
| 65 | export 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. */ |
| 84 | export 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 | */ |
| 119 | export 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 | */ |
| 138 | export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null }; |
| 139 | |
| 140 | export 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 | */ |
| 158 | export 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. */ |
| 165 | export type PlanStatus = { |
| 166 | kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise"; |
| 167 | label: string; |
| 168 | }; |
| 169 | |
| 170 | export 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. */ |
| 188 | const 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 | */ |
| 195 | export 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. */ |
| 200 | export 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. */ |
| 206 | export 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. */ |
| 212 | export 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 | |
| 221 | const 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." */ |
| 228 | export 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. */ |
| 235 | export 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. */ |
| 241 | export 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. */ |
| 249 | export 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. */ |
| 257 | export 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. */ |
| 267 | export 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. */ |
| 272 | export 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 | |
| 289 | export 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 | } |