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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11391-a-forge-s-billing-moves-active-grace-non → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/11391-a-forge-s-billing-moves-active-grace-non was merged into main as 0b060c6.

Review

Sign in to review this change.
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`;
133133 | `land_past_gate` | — | Land one pull past a broken gate, with a recorded reason; an owner's only | 321 |
134134 | `lift` | — | Lift an issue out from behind the Later gate | 214 |
135135 | `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 |
137137 | `maintenance` | `status`, `prepare`, `resume`, `pause-duty`, `resume-duty` | Prepare the forge for a power-off, check the drain, or end maintenance (admin) | 308 |
138138 | `make_it_yours` | — | Fork an app you can see: code and history, following the original | 337 |
139139 | `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 {
1313 LIMIT_KEYS,
1414 isLimitKey,
1515 limitChanges,
16 limitState,
16 limitStates,
1717 setLimit,
1818 type LimitChange,
1919 type LimitDef,
@@ -39,11 +39,12 @@ export function listLimits(
3939 ): Result<{ limits: LimitRow[]; changes: LimitChange[] }, "not_found"> {
4040 const who = admin(actor);
4141 if (!who.ok) return who;
42 const states = limitStates(app.db, LIMIT_KEYS);
4243 return ok({
4344 limits: LIMIT_KEYS.map((key) => ({
4445 ...LIMITS[key],
4546 key,
46 ...limitState(app.db, key),
47 ...states.get(key)!,
4748 })),
4849 changes: limitChanges(app.db),
4950 });
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 /**
outdated

50-row window silently gives a wrong since on long same-status runs. Query the run properly or document the cap.

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 =
5252 | "model_review.auto"
5353 | "model_review.min_landed"
5454 | "model_review.slack_pct"
55 | "shell_idle_minutes";
55 | "shell_idle_minutes"
56 | "billing.grace_days"
57 | "billing.retention_days";
5658
5759 export interface LimitDef {
5860 readonly label: string;
@@ -312,6 +314,22 @@ export const LIMITS: Record<LimitKey, LimitDef> = {
312314 max: 1440,
313315 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.",
314316 },
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 },
315333 };
316334
317335 export const LIMIT_KEYS = Object.keys(LIMITS) as LimitKey[];
@@ -400,6 +418,31 @@ export function limitState(
400418 return { value: set ?? LIMITS[key].default, isSet: set !== undefined, defaultValue: LIMITS[key].default };
401419 }
402420
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
403446 export type LimitError = {
404447 readonly kind: "out_of_bounds";
405448 readonly message: string;
src/web/pages/billing.ts +10 −1
@@ -9,6 +9,7 @@ import { ledBlock } from "./visitors.ts";
99 import { BILLING_WORDS, STRIPE_WORDS, connectStripe, disconnectStripe, grantPlan, readBilling, saveSigningSecret, type BillingError, type StripeWords } from "../../actions/billing.ts";
1010 import { retryOrder } from "../../actions/order.ts";
1111 import { REFUND_URL } from "../../billing/checkout.ts";
12 import { lapseFor, type Lapse } from "../../billing/lapse.ts";
1213 import { failedOrders } from "../../billing/orders.ts";
1314 import { CODE_ERRORS, makeCode, readCodes, revoke, type CodeError } from "../../actions/codes.ts";
1415 import { publicOrigin } from "../../core/public_origin.ts";
@@ -163,6 +164,14 @@ function funnelRow(r: FunnelReport): Html {
163164 </section>`;
164165 }
165166
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
166175 // GET /settings/billing
167176 export function billingPage(ctx: Ctx): Response {
168177 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -179,7 +188,7 @@ function billingView(ctx: Ctx, shown?: Shown, values: Record<string, string> = {
179188 <form class="row-fields" method="post" action="${PATH}/grant">
180189 <input type="hidden" name="forge" value="${f.forge}" />
181190 <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>
183192 <select class="input" name="plan" aria-label="Plan for ${f.forge}">
184193 ${Object.keys(SIZES).map((p) => html`<option value="${p}" ${p === f.plan ? html`selected` : null}>${p}</option>`)}
185194 </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", () => {
164164 expect(text).not.toContain("invoice.paid");
165165 expect(text).not.toContain("Refused today");
166166 });
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 });
167192 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs