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 look | 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 | ||
| Billing: credits with a kind and expiry, discounts instead of comped, and safer charging | 6 | import 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 look | 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 | ||
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 26 | /** Prices on g1t exclude tax; Stripe adds it at checkout from the billing address. */ |
| 27 | export 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 | */ | |
| 35 | export 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. */ | |
| 43 | export 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 look | 47 | /** A form's dollar amount as micros, or null when it is empty or not a number. */ |
| 48 | export 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 | ||
| 56 | export 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. */ | |
| 59 | export 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. */ | |
| 62 | export 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. */ | |
| 80 | export 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. */ | |
| 86 | export 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. */ | |
| 105 | export 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 | */ | |
| 140 | export 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 | */ | |
| 159 | export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null }; | |
| 160 | ||
| 161 | export 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 | */ | |
| 179 | export 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. */ | |
| 186 | export type PlanStatus = { | |
| 187 | kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise"; | |
| 188 | label: string; | |
| 189 | }; | |
| 190 | ||
| 191 | export 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 charging | 195 | 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 look | 196 | 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 quotas | 208 | /** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */ |
| 209 | const 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 | */ | |
| 216 | export 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. */ | |
| 221 | export 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 look | 226 | /** A share from 0 to 1 of `used` against `of`, for a meter. */ |
| 227 | export 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. */ | |
| 233 | export 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 | ||
| 242 | const 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." */ | |
| 249 | export 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. */ | |
| 256 | export 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. */ | |
| 262 | export 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. */ | |
| 270 | export 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. */ | |
| 278 | export 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 look | 283 | plan: { label: "Planning", color: "#f0a6ca" }, |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 284 | /** 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 look | 286 | other: { label: "Other", color: "var(--color-faint)" }, |
| 287 | }; | |
| 288 | ||
| 289 | /** A kind of work's words and colour, or Other's. */ | |
| 290 | export 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 it | 294 | /** |
| 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 | */ | |
| 298 | export 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 look | 309 | /** The workspace's month at a glance, for the Usage card on its overview. */ |
| 310 | export 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 | ||
| 327 | export function usageGlance(input: { | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 328 | usage: Pick<Usage, "free" | "spentMicros" | "usedMicros" | "byTask" | "priceMicros">; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 329 | 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 audit | 339 | // 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 look | 341 | 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 charging | 362 | |
| 363 | /** What a credit from g1t is for, as the Billing page names it. */ | |
| 364 | export 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). */ | |
| 372 | export 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 | */ | |
| 382 | export 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 audit | 392 | |
| 393 | /** AI credit's amounts, in dollars, as billing takes them. */ | |
| 394 | export 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. */ | |
| 397 | export 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. */ | |
| 409 | export 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. */ | |
| 426 | export 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). */ | |
| 438 | export 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 person | 452 | // 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 audit | 454 | 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.