Public: anyone can read this; the crew can change it.
A forge's billing moves active → grace → none → active as plain data, with grace and retention as settings #11401
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11391-a-forge-s-billing-moves-active-grace-non → main 7 commits fork 3 threads · 3 unresolved
7 files
changed +278
−5
docs/MCP.md +1 −1
| @@ -133,7 +133,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 133 | 133 | | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 | |
| 134 | 134 | | `lift` | — | Lift an issue out from behind the Later gate | 214 | |
| 135 | 135 | | `limb` | `defer`, `resume`, `close`, `raise`, `lower`, `replan`, `takeback`, `undo` | Act on a limb by `action` | 532 | |
| 136 | | `limits` | — | Every limit the owner can hit | 959 | | |
| 136 | | `limits` | — | Every limit the owner can hit | 1005 | | |
| 137 | 137 | | `maintenance` | `status`, `prepare`, `resume`, `pause-duty`, `resume-duty` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 308 | |
| 138 | 138 | | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 | |
| 139 | 139 | | `map` | — | An app's plan: ready or waiting, critical path, crew, parkedTwice; `done` adds closed; `first: N` ranks N first | 375 | |
src/actions/limits.ts +3 −2
| @@ -13,7 +13,7 @@ import { | ||
| 13 | 13 | LIMIT_KEYS, |
| 14 | 14 | isLimitKey, |
| 15 | 15 | limitChanges, |
| 16 | limitState, | |
| 16 | limitStates, | |
| 17 | 17 | setLimit, |
| 18 | 18 | type LimitChange, |
| 19 | 19 | type LimitDef, |
| @@ -39,11 +39,12 @@ export function listLimits( | ||
| 39 | 39 | ): Result<{ limits: LimitRow[]; changes: LimitChange[] }, "not_found"> { |
| 40 | 40 | const who = admin(actor); |
| 41 | 41 | if (!who.ok) return who; |
| 42 | const states = limitStates(app.db, LIMIT_KEYS); | |
| 42 | 43 | return ok({ |
| 43 | 44 | limits: LIMIT_KEYS.map((key) => ({ |
| 44 | 45 | ...LIMITS[key], |
| 45 | 46 | key, |
| 46 | ...limitState(app.db, key), | |
| 47 | ...states.get(key)!, | |
| 47 | 48 | })), |
| 48 | 49 | changes: limitChanges(app.db), |
| 49 | 50 | }); |
src/billing/lapse.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** | |
| 2 | * Where a forge's billing stands when payment lapses (#11391): plain data in, | |
| 3 | * plain data out. `active` → `grace` (past due, until `graceUntil`) → `none` | |
| 4 | * (grace over, or cancelled), and back to `active` the moment a provider says | |
| 5 | * it is paid. `keepUntil` is when the forge's data may stop being kept. State | |
| 6 | * only: nothing here sleeps, stops or removes anything. Grace and retention | |
| 7 | * are settings (`billing.grace_days`, `billing.retention_days`), passed in. | |
| 8 | * A forge on the free plan with no provider subscription, or with a `granted` | |
| 9 | * entitlement, is always active. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { Database } from "bun:sqlite"; | |
| 13 | import { FREE } from "../settings/plans.ts"; | |
| 14 | import { limitValue } from "../settings/limits.ts"; | |
| 15 | import { getStored } from "./store.ts"; | |
| 16 | import type { EntitlementStatus } from "./types.ts"; | |
| 17 | ||
| 18 | export type Lapse = | |
| 19 | | { state: "active" } | |
| 20 | | { state: "grace"; since: number; graceUntil: number; keepUntil: number } | |
| 21 | | { state: "none"; since: number; keepUntil: number }; | |
| 22 | ||
| 23 | export type LapseInput = { | |
| 24 | plan: string; | |
| 25 | status: EntitlementStatus; | |
| 26 | source: string; | |
| 27 | /** Whether a provider subscription backs this forge (a cancelled one drops to the free plan but stays lapsed). */ | |
| 28 | subscribed: boolean; | |
| 29 | /** Epoch ms the forge entered `status`. */ | |
| 30 | since: number; | |
| 31 | }; | |
| 32 | ||
| 33 | const DAY = 86_400_000; | |
| 34 | ||
| 35 | export function lapseOf(e: LapseInput, now: number, graceDays: number, retentionDays: number): Lapse { | |
| 36 | if (e.source === "granted" || e.status === "active" || (e.plan === FREE && !e.subscribed)) return { state: "active" }; | |
| 37 | const graceUntil = e.since + graceDays * DAY; | |
| 38 | if (e.status === "grace" && now < graceUntil) return { state: "grace", since: e.since, graceUntil, keepUntil: graceUntil + retentionDays * DAY }; | |
| 39 | const since = e.status === "grace" ? graceUntil : e.since; | |
| 40 | return { state: "none", since, keepUntil: since + retentionDays * DAY }; | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * When the forge entered its current status: its latest change into `status` | |
| 45 | * from another one, after the last change to anything else. No row cap. | |
| 46 | * Undefined when the log does not end on `status`. | |
| 47 | */ | |
| 48 | export function statusSince(db: Database, forge: string, status: EntitlementStatus): number | undefined { | |
| 49 | const row = db | |
| 50 | .query<{ at: string }, [string, string]>( | |
| 51 | `SELECT at FROM entitlement_change | |
| 52 | WHERE forge = ?1 AND to_status = ?2 AND from_status != ?2 | |
| 53 | AND id > COALESCE((SELECT MAX(id) FROM entitlement_change WHERE forge = ?1 AND to_status != ?2), 0) | |
| 54 | ORDER BY id DESC LIMIT 1`, | |
| 55 | ) | |
| 56 | .get(forge, status); | |
| 57 | return row === null ? undefined : Date.parse(row.at); | |
| 58 | } | |
| 59 | ||
| 60 | /** A forge's lapse state now, from its stored entitlement and the two settings. */ | |
| 61 | export function lapseFor(db: Database, forge: string, now = Date.now()): Lapse { | |
| 62 | const s = getStored(db, forge); | |
| 63 | if (s === undefined) return { state: "active" }; | |
| 64 | const since = statusSince(db, forge, s.status) ?? Date.parse(s.updatedAt); | |
| 65 | const input: LapseInput = { plan: s.plan, status: s.status, source: s.source, subscribed: s.subscriptionId !== "", since }; | |
| 66 | return lapseOf(input, now, limitValue(db, "billing.grace_days"), limitValue(db, "billing.retention_days")); | |
| 67 | } |
src/settings/limits.ts +44 −1
| @@ -52,7 +52,9 @@ export type LimitKey = | ||
| 52 | 52 | | "model_review.auto" |
| 53 | 53 | | "model_review.min_landed" |
| 54 | 54 | | "model_review.slack_pct" |
| 55 | | "shell_idle_minutes"; | |
| 55 | | "shell_idle_minutes" | |
| 56 | | "billing.grace_days" | |
| 57 | | "billing.retention_days"; | |
| 56 | 58 | |
| 57 | 59 | export interface LimitDef { |
| 58 | 60 | readonly label: string; |
| @@ -312,6 +314,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = { | ||
| 312 | 314 | max: 1440, |
| 313 | 315 | reason: "A shell nobody types in and that prints nothing holds a process and memory for no reason; one that prints, like a build, is never stopped.", |
| 314 | 316 | }, |
| 317 | "billing.grace_days": { | |
| 318 | label: "Days a forge keeps working after its payment fails", | |
| 319 | unit: "days", | |
| 320 | default: 7, | |
| 321 | min: 0, | |
| 322 | max: 90, | |
| 323 | reason: "A card that fails is usually fixed within days, so a lapse is not felt at once; past this the forge's billing reads as none.", | |
| 324 | }, | |
| 325 | "billing.retention_days": { | |
| 326 | label: "Days a lapsed forge keeps its data", | |
| 327 | unit: "days", | |
| 328 | default: 30, | |
| 329 | min: 1, | |
| 330 | max: 365, | |
| 331 | reason: "A forge that lapsed may come back, so its data waits this long before anything is allowed to remove it; paying again makes it active at once.", | |
| 332 | }, | |
| 315 | 333 | }; |
| 316 | 334 | |
| 317 | 335 | export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[]; |
| @@ -400,6 +418,31 @@ export function limitState( | ||
| 400 | 418 | return { value: set ?? LIMITS[key].default, isSet: set !== undefined, defaultValue: LIMITS[key].default }; |
| 401 | 419 | } |
| 402 | 420 | |
| 421 | /** `limitState` for every limit with one read of the stored settings, not one per limit. */ | |
| 422 | export function limitStates( | |
| 423 | db: Database, | |
| 424 | keys: readonly LimitKey[], | |
| 425 | ): Map<LimitKey, { value: number; isSet: boolean; defaultValue: number }> { | |
| 426 | const rows = new Map( | |
| 427 | db | |
| 428 | .query<{ key: string; value: string }, []>("SELECT key, value FROM forge_setting WHERE key LIKE 'limit.%'") | |
| 429 | .all() | |
| 430 | .map((r) => [r.key, r.value]), | |
| 431 | ); | |
| 432 | const out = new Map<LimitKey, { value: number; isSet: boolean; defaultValue: number }>(); | |
| 433 | for (const key of keys) { | |
| 434 | if (capacityKey(key) !== undefined) { | |
| 435 | out.set(key, limitState(db, key)); | |
| 436 | continue; | |
| 437 | } | |
| 438 | const raw = rows.get(storeKey(key)); | |
| 439 | const n = raw !== undefined && /^\d+$/.test(raw) ? Number(raw) : NaN; | |
| 440 | const set = validLimit(key, n) ? n : undefined; | |
| 441 | out.set(key, { value: set ?? LIMITS[key].default, isSet: set !== undefined, defaultValue: LIMITS[key].default }); | |
| 442 | } | |
| 443 | return out; | |
| 444 | } | |
| 445 | ||
| 403 | 446 | export type LimitError = { |
| 404 | 447 | readonly kind: "out_of_bounds"; |
| 405 | 448 | readonly message: string; |
src/web/pages/billing.ts +10 −1
| @@ -9,6 +9,7 @@ import { ledBlock } from "./visitors.ts"; | ||
| 9 | 9 | import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts"; |
| 10 | 10 | import { retryOrder } from "../../actions/order.ts"; |
| 11 | 11 | import { REFUND_URL } from "../../billing/checkout.ts"; |
| 12 | import { lapseFor, type Lapse } from "../../billing/lapse.ts"; | |
| 12 | 13 | import { failedOrders } from "../../billing/orders.ts"; |
| 13 | 14 | import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts"; |
| 14 | 15 | import { publicOrigin } from "../../core/public_origin.ts"; |
| @@ -163,6 +164,14 @@ function funnelRow(r: FunnelReport): Html { | ||
| 163 | 164 | </section>`; |
| 164 | 165 | } |
| 165 | 166 | |
| 167 | /** The lapse state as a suffix for a forge's row: nothing while active. */ | |
| 168 | function lapseWords(l: Lapse): string { | |
New page behaviour with no test. Add one that renders /settings/billing for a forge in grace and in none and asserts these strings. Also, the dates are ISO only, which is fine and plain. |
||
| 169 | const day = (t: number) => new Date(t).toISOString().slice(0, 10); | |
| 170 | if (l.state === "grace") return ` · past due until ${day(l.graceUntil)}, data kept until ${day(l.keepUntil)}`; | |
| 171 | if (l.state === "none") return ` · lapsed, data kept until ${day(l.keepUntil)}`; | |
| 172 | return ""; | |
| 173 | } | |
| 174 | ||
| 166 | 175 | // GET /settings/billing |
| 167 | 176 | export function billingPage(ctx: Ctx): Response { |
| 168 | 177 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -179,7 +188,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = { | ||
| 179 | 188 | <form class="row-fields" method="post" action="${PATH}/grant"> |
| 180 | 189 | <input type="hidden" name="forge" value="${f.forge}" /> |
| 181 | 190 | <span class="mono">${f.forge}</span> |
| 182 | <span class="muted small">${f.status} · ${f.source}</span> | |
| 191 | <span class="muted small">${f.status} · ${f.source}${lapseWords(lapseFor(ctx.app.db, f.forge))}</span> | |
| 183 | 192 | <select class="input" name="plan" aria-label="Plan for ${f.forge}"> |
| 184 | 193 | ${Object.keys(SIZES).map((p) => html`<option value="${p}" ${p === f.plan ? html`selected` : null}>${p}</option>`)} |
| 185 | 194 | </select> |
test/billing-lapse.test.ts added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | // A forge's billing moves active → grace → none → active as plain data, driven by the fake provider's webhooks (#11391). | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { sealKey, sealWebhook } from "../src/billing/connection.ts"; | |
| 8 | import { lapseFor, lapseOf, statusSince } from "../src/billing/lapse.ts"; | |
| 9 | import { putEntitlement } from "../src/billing/store.ts"; | |
| 10 | import { setStripeFetch } from "../src/billing/sync.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { LIMIT_KEYS, LIMITS, limitState, limitStates, setLimit } from "../src/settings/limits.ts"; | |
| 13 | ||
| 14 | const secret = "whsec_lapse_test_secret"; | |
| 15 | const DAY = 86_400_000; | |
| 16 | let dir: string; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let now: { status: string }; | |
| 20 | let n = 0; | |
| 21 | ||
| 22 | async function sign(body: string): Promise<string> { | |
| 23 | const t = Math.floor(Date.now() / 1000); | |
| 24 | const k = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 25 | const mac = new Uint8Array(await crypto.subtle.sign("HMAC", k, new TextEncoder().encode(`${t}.${body}`))); | |
| 26 | return `t=${t},v1=${Buffer.from(mac).toString("hex")}`; | |
| 27 | } | |
| 28 | async function deliver(status: string): Promise<void> { | |
| 29 | now = { status }; | |
| 30 | const body = JSON.stringify({ id: `evt_l${++n}`, type: "customer.subscription.updated", data: { object: { id: "sub_1", status } } }); | |
| 31 | const res = await fetch(`http://127.0.0.1:${servers.port}/hooks/stripe`, { | |
| 32 | method: "POST", headers: { "stripe-signature": await sign(body), "x-forwarded-for": `10.9.0.${n}` }, body, | |
| 33 | }); | |
| 34 | expect(res.status).toBe(200); | |
| 35 | } | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "billing-lapse-")); | |
| 39 | app = createApp({ dataDir: dir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | sealKey(app.db, dir, "sk_test_abcdefgh12345678"); | |
| 42 | sealWebhook(app.db, dir, secret); | |
| 43 | setStripeFetch(app, (async () => | |
| 44 | new Response(JSON.stringify({ id: "sub_1", status: now.status, customer: "cus_1", metadata: { forge: "acme", plan: "medium" }, items: { data: [{ current_period_end: 1900000000 }] } }), { status: 200 })) as unknown as typeof fetch); | |
| 45 | }); | |
| 46 | afterAll(async () => { | |
| 47 | setStripeFetch(app, undefined); | |
| 48 | await servers.stop(); | |
| 49 | await rm(dir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | describe("billing lapse", () => { | |
| 53 | test("grace and retention are settings with a reason, defaulting to 7 and 30 days", () => { | |
| 54 | expect(LIMITS["billing.grace_days"]).toMatchObject({ default: 7 }); | |
| 55 | expect(LIMITS["billing.retention_days"]).toMatchObject({ default: 30 }); | |
| 56 | expect(LIMITS["billing.grace_days"].reason.length).toBeGreaterThan(10); | |
| 57 | expect(LIMITS["billing.retention_days"].reason.length).toBeGreaterThan(10); | |
| 58 | }); | |
| 59 | ||
| 60 | test("active → grace → none → active through the provider's webhooks", async () => { | |
| 61 | await deliver("active"); | |
| 62 | expect(lapseFor(app.db, "acme")).toEqual({ state: "active" }); | |
| 63 | ||
| 64 | await deliver("past_due"); | |
| 65 | const t0 = Date.now(); | |
| 66 | const g = lapseFor(app.db, "acme", t0); | |
| 67 | expect(g.state).toBe("grace"); | |
| 68 | if (g.state !== "grace") throw new Error("unreachable"); | |
| 69 | expect(g.graceUntil - g.since).toBe(7 * DAY); | |
| 70 | expect(g.keepUntil - g.graceUntil).toBe(30 * DAY); | |
| 71 | ||
| 72 | // a repeated past_due does not restart the clock | |
| 73 | await deliver("past_due"); | |
| 74 | expect(lapseFor(app.db, "acme", t0)).toEqual(g); | |
| 75 | ||
| 76 | // grace over: none, kept from where grace ended | |
| 77 | const over = lapseFor(app.db, "acme", g.graceUntil + 1); | |
| 78 | expect(over).toEqual({ state: "none", since: g.graceUntil, keepUntil: g.graceUntil + 30 * DAY }); | |
| 79 | ||
| 80 | // paid again: active at once | |
| 81 | await deliver("active"); | |
| 82 | expect(lapseFor(app.db, "acme", g.graceUntil + 1)).toEqual({ state: "active" }); | |
| 83 | }); | |
| 84 | ||
| 85 | test("cancelled is none from the change, kept for the retention setting", async () => { | |
| 86 | await deliver("canceled"); | |
| 87 | const l = lapseFor(app.db, "acme"); | |
| 88 | expect(l.state).toBe("none"); | |
| 89 | if (l.state !== "none") throw new Error("unreachable"); | |
| 90 | expect(l.keepUntil - l.since).toBe(30 * DAY); | |
| 91 | await deliver("active"); | |
| 92 | expect(lapseFor(app.db, "acme")).toEqual({ state: "active" }); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the settings govern: a 2 day grace and 10 day retention", async () => { | |
| 96 | expect(setLimit(app.db, "billing.grace_days", 2, "test").ok).toBe(true); | |
| 97 | expect(setLimit(app.db, "billing.retention_days", 10, "test").ok).toBe(true); | |
| 98 | await deliver("past_due"); | |
| 99 | const g = lapseFor(app.db, "acme"); | |
| 100 | if (g.state !== "grace") throw new Error("not grace"); | |
| 101 | expect(g.graceUntil - g.since).toBe(2 * DAY); | |
| 102 | expect(g.keepUntil - g.graceUntil).toBe(10 * DAY); | |
| 103 | }); | |
| 104 | ||
| 105 | test("free plan and granted entitlements are always active", () => { | |
| 106 | const base = { subscribed: false, since: 0 }; | |
| 107 | expect(lapseOf({ ...base, plan: "free", status: "none", source: "stripe" }, 1e15, 7, 30)).toEqual({ state: "active" }); | |
| 108 | expect(lapseOf({ ...base, plan: "medium", status: "none", source: "granted" }, 1e15, 7, 30)).toEqual({ state: "active" }); | |
| 109 | putEntitlement(app.db, { forge: "gift", plan: "medium", status: "grace", source: "granted", provider: "none" }, "small", "u"); | |
| 110 | expect(lapseFor(app.db, "gift", Date.now() + 400 * DAY)).toEqual({ state: "active" }); | |
| 111 | expect(lapseFor(app.db, "no-row")).toEqual({ state: "active" }); | |
| 112 | }); | |
| 113 | ||
| 114 | test("since survives a long run of same-status rows", () => { | |
| 115 | const ins = app.db.query( | |
| 116 | "INSERT INTO entitlement_change (forge, from_plan, to_plan, from_status, to_status, source, actor_id, at) VALUES ('long', 'medium', 'medium', ?1, ?2, 'stripe', 'u', ?3)", | |
| 117 | ); | |
| 118 | ins.run("active", "grace", "2026-01-01T00:00:00.000Z"); | |
| 119 | for (let i = 0; i < 80; i++) ins.run("grace", "grace", "2026-01-02T00:00:00.000Z"); | |
| 120 | expect(statusSince(app.db, "long", "grace")).toBe(Date.parse("2026-01-01T00:00:00.000Z")); | |
| 121 | expect(statusSince(app.db, "long", "active")).toBeUndefined(); | |
| 122 | }); | |
| 123 | ||
| 124 | test("limitStates agrees with limitState for every limit", () => { | |
| 125 | const all = limitStates(app.db, LIMIT_KEYS); | |
| 126 | for (const k of LIMIT_KEYS) expect(all.get(k)).toEqual(limitState(app.db, k)); | |
| 127 | }); | |
| 128 | }); |
test/billing.test.ts +25
| @@ -164,4 +164,29 @@ describe("billing seam", () => { | ||
| 164 | 164 | expect(text).not.toContain("invoice.paid"); |
| 165 | 165 | expect(text).not.toContain("Refused today"); |
| 166 | 166 | }); |
| 167 | ||
| 168 | test("the Billing page says a forge's lapse: past due until, then lapsed, with the keep-until date", async () => { | |
| 169 | const row = async () => { | |
| 170 | const text = await (await send("GET", "/settings/billing", admin)).text(); | |
| 171 | return text.slice(text.indexOf('data-forge="kid"'), text.indexOf("</li>", text.indexOf('data-forge="kid"'))); | |
| 172 | }; | |
| 173 | const enter = (status: "grace" | "none", at: string) => { | |
| 174 | putEntitlement(app.db, { forge: "kid", plan: "medium", status, source: "stripe", provider: "stripe", subscriptionId: "sub_kid" }, "small", "test"); | |
| 175 | app.db.query("UPDATE entitlement_change SET at = ?1 WHERE id = (SELECT MAX(id) FROM entitlement_change WHERE forge = 'kid')").run(at); | |
| 176 | }; | |
| 177 | const soon = new Date(Date.now() - 86_400_000); | |
| 178 | enter("grace", soon.toISOString()); | |
| 179 | const day = (ms: number) => new Date(soon.getTime() + ms * 86_400_000).toISOString().slice(0, 10); | |
| 180 | expect(await row()).toContain(`past due until ${day(7)}, data kept until ${day(37)}`); | |
| 181 | ||
| 182 | enter("none", "2026-01-01T00:00:00.000Z"); | |
| 183 | const none = await row(); | |
| 184 | expect(none).toContain("lapsed, data kept until 2026-01-31"); | |
| 185 | expect(none).not.toContain("past due"); | |
| 186 | ||
| 187 | putEntitlement(app.db, { forge: "kid", plan: "medium", status: "active", source: "granted" }, "small", "test"); | |
| 188 | const back = await row(); | |
| 189 | expect(back).not.toContain("lapsed"); | |
| 190 | expect(back).not.toContain("past due"); | |
| 191 | }); | |
| 167 | 192 | }); |
50-row window silently gives a wrong
sinceon long same-status runs. Query the run properly or document the cap.