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 | |
| 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. | 8 | import { MICROS_PER_DOLLAR, money, wholeDollars } from "./money.ts"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 9 | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 10 | /** Prices on g1t exclude tax; Stripe adds it at checkout from the billing address. */ |
| 11 | export 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 | */ | |
| 19 | export 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. */ | |
| 27 | export 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 person | 29 | } |
| 30 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 31 | /** A form's dollar amount as micros, or null when it is empty or not a number. */ |
| 32 | export 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 | ||
| 40 | export 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. */ | |
| 43 | export 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. */ | |
| 46 | export 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. */ | |
| 64 | export 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. */ | |
| 70 | export 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. */ | |
| 89 | export 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 | */ | |
| 124 | export 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 | */ | |
| 143 | export type SpendRange = { selfServeMicros: number | null; raiseOnceMicros: number | null; raisedAt: string | null }; | |
| 144 | ||
| 145 | export 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 | */ | |
| 163 | export 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. */ | |
| 170 | export type PlanStatus = { | |
| 171 | kind: "free" | "trial" | "paid" | "canceling" | "past_due" | "comped" | "enterprise"; | |
| 172 | label: string; | |
| 173 | }; | |
| 174 | ||
| 175 | export 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 charging | 179 | 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 | 180 | 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 quotas | 192 | /** Meters only the plan runs: a free workspace never builds, serves apps or adds custom domains. */ |
| 193 | const 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 | */ | |
| 200 | export 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. */ | |
| 205 | export 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 look | 210 | /** A share from 0 to 1 of `used` against `of`, for a meter. */ |
| 211 | export 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. */ | |
| 217 | export 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 | ||
| 226 | const 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." */ | |
| 233 | export 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 look | 237 | } |
| 238 | ||
| 239 | /** How loud an alert is. */ | |
| 240 | export 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. */ | |
| 246 | export 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. */ | |
| 254 | export 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. */ | |
| 262 | export 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 look | 267 | plan: { label: "Planning", color: "#f0a6ca" }, |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 268 | /** 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 look | 270 | other: { label: "Other", color: "var(--color-faint)" }, |
| 271 | }; | |
| 272 | ||
| 273 | /** A kind of work's words and colour, or Other's. */ | |
| 274 | export 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 it | 278 | /** |
| 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 | */ | |
| 282 | export 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 look | 293 | /** The workspace's month at a glance, for the Usage card on its overview. */ |
| 294 | export 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 | ||
| 311 | export function usageGlance(input: { | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 312 | usage: Pick<Usage, "free" | "spentMicros" | "usedMicros" | "byTask" | "priceMicros">; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 313 | 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 audit | 323 | // 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 look | 325 | 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 charging | 346 | |
| 347 | /** What a credit from g1t is for, as the Billing page names it. */ | |
| 348 | export 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). */ | |
| 356 | export 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 | */ | |
| 366 | export 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 charging | 368 | 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 charging | 370 | 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 audit | 376 | |
| 377 | /** AI credit's amounts, in dollars, as billing takes them. */ | |
| 378 | export 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. */ | |
| 381 | export 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. */ | |
| 393 | export 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. */ | |
| 410 | export 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). */ | |
| 422 | export 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 person | 436 | // 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 audit | 438 | 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.