Public: anyone can read this; the crew can change it.

Revenue: awake time, storage and crew spend with the margin, per app per month — the statement billing reads #10418

Merged opened this

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

Merged

torque/forge:torque/508-revenue-awake-time-storage-and-crew-spen → main 8 commits fork

  • Merged. torque/508-revenue-awake-time-storage-and-crew-spen was merged into main as 3f4dad5.

Review

Sign in to review this change.
31 files changed +1043 −80
docs/PROFIT.md +13
@@ -64,6 +64,19 @@ Rounding happens once, at the end, to the cent. Worked example: a $300 box,
6464 40 h of cloud at $0.05, 12 GB-months at $0.02 and $57.10 of crew spend
6565 cost $359.34.
6666
67 `src/core/revenue.ts` (`statementFor`) is the revenue half, one app and one
68 UTC calendar month at a time: lines `{ what, quantity, unit, price, amount }`
69 for awake time (every environment, owned or cloud), storage, crew spend at
70 cost, and the margin on crew spend — the margin touches crew spend only.
71 List prices are whole cents beside the costs (`awakePriceHourUsd`,
72 `storagePriceGbMonthUsd` in Settings → Economics and the economics API).
73 Amounts and the total are rounded once, at the end, as for cost. Worked
74 example: 100 awake-hours at $0.10, 3 GB-months at $0.25 and $57.10 of crew
75 at a 20% margin charge 10.00 + 0.75 + 57.10 + 11.42 = $79.27; at margin 0,
76 $67.85. Read it at `GET /api/v1/repos/:owner/:name/statement?month=YYYY-MM`
77 (JSON, or `format=csv`) or print it from the app's settings
78 (`/:owner/:name/settings/app/statement`); see `docs/features/statement.md`.
79
6780 ## The order (what waits for what)
6881
6982 | issue | what | waits for |
docs/features/economics.md +6
@@ -48,6 +48,12 @@ cents, and every change is kept in the same history as the position.
4848 | `cloudHourUsd` | US dollars per awake-hour on cloud | 0–1,000,000.00 |
4949 | `storageGbMonthUsd` | US dollars per GB-month kept (decimal GB) | 0–1,000,000.00 |
5050
51 Beside them, two list prices a customer pays (#508), same rules, set by the
52 same form row, terminal rows, API and MCP fields: `awakePriceHourUsd` (US
53 dollars per awake-hour, wherever it ran) and `storagePriceGbMonthUsd` (US
54 dollars per GB-month). Crew spend is charged at cost plus the margin. They
55 price each app's statement: `docs/features/statement.md`.
56
5157 Prices take at most two decimals and cannot be negative. Anything else is
5258 refused as `bad_price` (or `bad_slots`) and nothing changes.
5359
docs/features/statement.md added +27
@@ -0,0 +1,27 @@
1 # The statement — what the forge would charge an app
2
3 One app, one UTC calendar month (#508). No payment processor: every line is
4 quantity × price, so anyone can check it by hand.
5
6 | line | quantity | price |
7 | ---------------------------- | ----------------------------- | -------------------------- |
8 | Awake time | awake-hours, all environments | list $/awake-hour |
9 | Storage | GB-months (GB-hours / 730) | list $/GB-month |
10 | Crew spend at cost | USD of crew spend | 1 |
11 | Margin on crew spend (N%) | USD of crew spend | N / 100 |
12
13 The total is the unrounded sum, rounded once to the cent.
14
15 - **Set the list prices** in Settings → Economics, beside the costs, or with
16 `PUT /api/v1/forge/economics` (`awakePriceHourUsd`, `storagePriceGbMonthUsd`).
17 The margin is the one beside the profit slider.
18 - **Read it** at `GET /api/v1/repos/:owner/:name/statement?month=2026-10`;
19 `format=csv` (or `accept: text/csv`) is the same lines as a table file.
20 Without `month`, this month so far.
21 - **Print it** from the repository's Settings → App → Statement
22 (`/:owner/:name/settings/app/statement`); printing hides the navigation.
23 - **At the terminal**, open the repository and pick `owner/name — Statement`
24 from the `:` palette: the same lines and total. `m` takes a month
25 (YYYY-MM), `[` and `]` step back and forward.
26
27 The repository's admins and forge admins can read it; anyone else gets a 404.
src/actions/economics.ts +14 −2
@@ -119,6 +119,10 @@ export interface PriceInput {
119119 ownedSlots?: unknown;
120120 cloudHourUsd?: unknown;
121121 storageGbMonthUsd?: unknown;
122 /** List price a customer pays, $/awake-hour (#508). */
123 awakePriceHourUsd?: unknown;
124 /** List price a customer pays, $/GB-month (#508). */
125 storagePriceGbMonthUsd?: unknown;
122126 }
123127
124128 function readPrices(before: Prices, input: PriceInput): Result<Prices, "bad_price" | "bad_slots"> {
@@ -126,11 +130,19 @@ function readPrices(before: Prices, input: PriceInput): Result<Prices, "bad_pric
126130 const ownedMonthCents = usd(input.ownedMonthUsd, before.ownedMonthCents);
127131 const cloudHourCents = usd(input.cloudHourUsd, before.cloudHourCents);
128132 const storageGbMonthCents = usd(input.storageGbMonthUsd, before.storageGbMonthCents);
129 if (ownedMonthCents === undefined || cloudHourCents === undefined || storageGbMonthCents === undefined)
133 const awakePriceHourCents = usd(input.awakePriceHourUsd, before.awakePriceHourCents);
134 const storagePriceGbMonthCents = usd(input.storagePriceGbMonthUsd, before.storagePriceGbMonthCents);
135 if (
136 ownedMonthCents === undefined ||
137 cloudHourCents === undefined ||
138 storageGbMonthCents === undefined ||
139 awakePriceHourCents === undefined ||
140 storagePriceGbMonthCents === undefined
141 )
130142 return err("bad_price");
131143 const ownedSlots = input.ownedSlots === undefined ? before.ownedSlots : parseSlots(input.ownedSlots);
132144 if (ownedSlots === undefined) return err("bad_slots");
133 return ok({ ownedMonthCents, ownedSlots, cloudHourCents, storageGbMonthCents });
145 return ok({ ownedMonthCents, ownedSlots, cloudHourCents, storageGbMonthCents, awakePriceHourCents, storagePriceGbMonthCents });
134146 }
135147
136148 /**
src/actions/statement.ts added +65
@@ -0,0 +1,65 @@
1 /**
2 * One app's statement for one UTC month (#508), for those who administer
3 * the repository and forge admins: the meter's awake time and storage
4 * (`core/meter.ts`) and the crew ledger's spend (`core/usage.ts`) for this
5 * repository alone, priced at the forge's list prices and margin
6 * (`settings/economics.ts`) by `core/revenue.ts`. Anyone else is told
7 * `not_found`; a month that is not "YYYY-MM" is `bad_month`. A month not yet
8 * over counts up to now; a month not begun is all zeros.
9 */
10
11 import type { App } from "../app.ts";
12 import { canAdmin, type Repo } from "../core/repos.ts";
13 import { usageBetween } from "../core/meter.ts";
14 import { monthOf, monthRange, parseMonth, statementFor, type Statement } from "../core/revenue.ts";
15 import { usageTotals } from "../core/usage.ts";
16 import type { User } from "../core/users.ts";
17 import { err, ok, type Result } from "../kernel/result.ts";
18 import { getEconomics, getPrices } from "../settings/economics.ts";
19
20 export type StatementError = "not_found" | "bad_month";
21
22 export const STATEMENT_WORDS = { bad_month: "month is YYYY-MM, from 2000-01" } as const;
23
24 /** Who may read an app's statement: its admins and the forge's. */
25 export function mayReadStatement(app: App, repo: Repo, actor: User | undefined): boolean {
26 return actor !== undefined && (actor.isAdmin || canAdmin(app.db, repo, actor));
27 }
28
29 /**
30 * `month` undefined is the month holding `now`. `now` is the clock unless a
31 * test pins it.
32 */
33 export function readStatement(
34 app: App,
35 actor: User | undefined,
36 repo: Repo,
37 month: unknown,
38 now: string = new Date().toISOString(),
39 ): Result<Statement, StatementError> {
40 if (!mayReadStatement(app, repo, actor)) return err("not_found");
41 const m = month === undefined ? monthOf(now) : parseMonth(month);
42 if (m === undefined) return err("bad_month");
43 const range = monthRange(m);
44 const meter = usageBetween(app.db, range.from, range.to, now).find((u) => u.repoId === repo.id);
45 // The ledger's `until` is inclusive; the month is half-open, so stop a millisecond short.
46 const until = new Date(Date.parse(range.to) - 1).toISOString();
47 const crew = usageTotals(app.db, "repo", { since: range.from, until, repoId: repo.id });
48 const prices = getPrices(app.db);
49 return ok(
50 statementFor(
51 {
52 app: `${repo.ownerName}/${repo.name}`,
53 awakeSeconds: meter?.awakeSeconds ?? 0,
54 gbHours: meter?.gbHours ?? 0,
55 crewUsd: crew.reduce((sum, t) => sum + t.costUsd, 0),
56 },
57 m,
58 {
59 awakePriceHourCents: prices.awakePriceHourCents,
60 storagePriceGbMonthCents: prices.storagePriceGbMonthCents,
61 marginPct: getEconomics(app.db).marginPct,
62 },
63 ),
64 );
65 }
src/contract/openapi/routes/getApiV1ReposOwnerNameStatement.ts added +24
@@ -0,0 +1,24 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/statement`,
6 summary:
7 "The app's statement for one UTC month, what the forge would charge (docs/PROFIT.md): lines { what, quantity, unit, price, amount } for awake time, storage, crew spend at cost and the margin on it, and a total, in US dollars rounded once to the cent; month defaults to this one; format=csv (or accept: text/csv) gives the same lines as CSV; for the repository's admins and forge admins, anyone else a 404; needs a token",
8 auth: true,
9 query: { month: "string", format: "string" },
10 reply: {
11 app: "string",
12 month: "string",
13 from: "string",
14 to: "string",
15 currency: "string",
16 marginPct: "number",
17 lines: "array",
18 total: "number",
19 },
20 refusals: {
21 bad_month: "month is not YYYY-MM from 2000-01 (422)",
22 invalid_format: "format is not json or csv (422)",
23 },
24 };
src/contract/openapi/routes/index.ts +1
@@ -159,6 +159,7 @@ export { route as getApiV1ReposOwnerNameRoadmap } from "./getApiV1ReposOwnerName
159159 export { route as getApiV1ReposOwnerNameRuns } from "./getApiV1ReposOwnerNameRuns.ts";
160160 export { route as getApiV1ReposOwnerNameRunsId } from "./getApiV1ReposOwnerNameRunsId.ts";
161161 export { route as getApiV1ReposOwnerNameRunsIdLog } from "./getApiV1ReposOwnerNameRunsIdLog.ts";
162 export { route as getApiV1ReposOwnerNameStatement } from "./getApiV1ReposOwnerNameStatement.ts";
162163 export { route as getApiV1ReposOwnerNameTags } from "./getApiV1ReposOwnerNameTags.ts";
163164 export { route as getApiV1ReposOwnerNameUpstream } from "./getApiV1ReposOwnerNameUpstream.ts";
164165 export { route as getApiV1ReposOwnerNameUsage } from "./getApiV1ReposOwnerNameUsage.ts";
src/contract/openapi/routes/putApiV1ForgeEconomics.ts +3 −1
@@ -3,7 +3,7 @@ import type { Route } from "../shared.ts";
33 export const route: Route = {
44 method: "put",
55 path: "/api/v1/forge/economics",
6 summary: "Set the profit slider's position, the margin on crew spend, the label that selects owned nodes and/or the operator's prices (ownedMonthUsd, ownedSlots, cloudHourUsd, storageGbMonthUsd: US dollars, at most two decimals, non-negative); a field left out keeps its value, each change is kept (a person admin's token, admin scope)",
6 summary: "Set the profit slider's position, the margin on crew spend, the label that selects owned nodes and/or the operator's prices (ownedMonthUsd, ownedSlots, cloudHourUsd, storageGbMonthUsd; and the list prices awakePriceHourUsd, storagePriceGbMonthUsd: US dollars, at most two decimals, non-negative); a field left out keeps its value, each change is kept (a person admin's token, admin scope)",
77 auth: true,
88 body: {
99 position: "whole number 0-100",
@@ -13,6 +13,8 @@ export const route: Route = {
1313 ownedSlots: "whole number of app-slots the owned box holds, 0-10000",
1414 cloudHourUsd: "US dollars per awake-hour on cloud, 0-1000000, at most two decimals",
1515 storageGbMonthUsd: "US dollars per GB-month kept, 0-1000000, at most two decimals",
16 awakePriceHourUsd: "list price a customer pays, US dollars per awake-hour, 0-1000000, at most two decimals",
17 storagePriceGbMonthUsd: "list price a customer pays, US dollars per GB-month kept, 0-1000000, at most two decimals",
1618 },
1719 refusals: {
1820 not_found: "the token is not a person who administers the forge (404)",
src/core/revenue.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * What the forge would charge one app for one UTC calendar month — the
3 * statement billing reads (docs/PROFIT.md, "Units and formulas"; #508).
4 * No payment processor: every line is quantity × price, checkable by hand.
5 *
6 * awake = awake-seconds / 3600 × list $/awake-hour
7 * (every environment, owned or cloud: a customer pays for
8 * time awake, not for where it ran)
9 * storage = GB-hours / 730 × list $/GB-month
10 * (730 h = 8760 h / 12, as `core/cost.ts`)
11 * crew = crew `costUsd` × 1 (list price, at cost)
12 * margin = crew `costUsd` × marginPct / 100
13 * total = awake + storage + crew + margin
14 *
15 * The margin applies to crew spend only. Everything is summed in cents
16 * unrounded; each line's amount and the total are rounded once, at the end,
17 * to the cent (half away from zero for the non-negative amounts here); the
18 * total is the unrounded sum, rounded — so it can differ from the sum of
19 * the rounded lines by at most a cent per line, as on any bill.
20 *
21 * Worked example (test/revenue.test.ts): 100 awake-hours at $0.10, 3
22 * GB-months at $0.25 and $57.10 of crew at a 20% margin:
23 * 10.00 + 0.75 + 57.10 + 11.42 = $79.27.
24 *
25 * Pure: plain data in, plain data out.
26 */
27
28 import { defect } from "../kernel/error.ts";
29 import { HOURS_PER_GB_MONTH, type Range } from "./cost.ts";
30
31 /** What one app used in the month: the meter's sums and the crew ledger's. */
32 export interface AppMonthUse {
33 /** "owner/name", printed on the statement. */
34 readonly app: string;
35 /** Seconds awake, every environment and placement summed. */
36 readonly awakeSeconds: number;
37 /** (data + repository bytes) × hours / 1e9. */
38 readonly gbHours: number;
39 /** Crew spend at list price, US dollars. */
40 readonly crewUsd: number;
41 }
42
43 /** What the forge charges: list prices in whole US cents, the margin in %. */
44 export interface Rates {
45 readonly awakePriceHourCents: number;
46 readonly storagePriceGbMonthCents: number;
47 readonly marginPct: number;
48 }
49
50 export interface StatementLine {
51 readonly what: string;
52 /** In `unit`, to six decimals. */
53 readonly quantity: number;
54 readonly unit: string;
55 /** US dollars per `unit`. */
56 readonly price: number;
57 /** US dollars, rounded once to the cent. */
58 readonly amount: number;
59 }
60
61 export interface Statement {
62 readonly app: string;
63 /** "YYYY-MM", UTC. */
64 readonly month: string;
65 readonly from: string;
66 readonly to: string;
67 readonly currency: "USD";
68 readonly marginPct: number;
69 readonly lines: readonly StatementLine[];
70 /** The lines' unrounded sum, rounded to the cent. */
71 readonly total: number;
72 }
73
74 const MONTH = /^(\d{4})-(0[1-9]|1[0-2])$/;
75
76 /** "YYYY-MM" from 2000-01 to 9999-12, or undefined. */
77 export function parseMonth(input: unknown): string | undefined {
78 if (typeof input !== "string") return undefined;
79 const m = MONTH.exec(input);
80 return m !== null && Number(m[1]) >= 2000 ? input : undefined;
81 }
82
83 /** The UTC month holding an instant, "YYYY-MM". */
84 export function monthOf(at: string): string {
85 const ms = Date.parse(at);
86 if (!Number.isFinite(ms)) defect("revenue.bad_instant", `not an instant: ${at}`);
87 return new Date(ms).toISOString().slice(0, 7);
88 }
89
90 /** The month as a half-open range: its first instant to the next month's. */
91 export function monthRange(month: string): Range {
92 const m = MONTH.exec(month);
93 if (m === null) defect("revenue.bad_month", `not a month: ${month}`);
94 const y = Number(m[1]);
95 const i = Number(m[2]) - 1;
96 return {
97 from: new Date(Date.UTC(y, i, 1)).toISOString(),
98 to: new Date(Date.UTC(y, i + 1, 1)).toISOString(),
99 };
100 }
101
102 /** A count that is not a finite non-negative number counts as 0. */
103 const amount = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0);
104 const six = (n: number): number => Math.round(n * 1e6) / 1e6;
105 const dollars = (cents: number): number => Math.round(cents) / 100;
106
107 export function statementFor(use: AppMonthUse, month: string, rates: Rates): Statement {
108 const range = monthRange(month);
109 const hours = amount(use.awakeSeconds) / 3600;
110 const gbMonths = amount(use.gbHours) / HOURS_PER_GB_MONTH;
111 const crew = amount(use.crewUsd);
112 const margin = amount(rates.marginPct);
113 const awakeCents = hours * rates.awakePriceHourCents;
114 const storageCents = gbMonths * rates.storagePriceGbMonthCents;
115 const crewCents = crew * 100;
116 const marginCents = (crew * 100 * margin) / 100;
117 const lines: StatementLine[] = [
118 { what: "Awake time", quantity: six(hours), unit: "awake-hour", price: rates.awakePriceHourCents / 100, amount: dollars(awakeCents) },
119 { what: "Storage", quantity: six(gbMonths), unit: "GB-month", price: rates.storagePriceGbMonthCents / 100, amount: dollars(storageCents) },
120 { what: "Crew spend at cost", quantity: six(crew), unit: "USD of crew spend", price: 1, amount: dollars(crewCents) },
121 { what: `Margin on crew spend (${margin}%)`, quantity: six(crew), unit: "USD of crew spend", price: margin / 100, amount: dollars(marginCents) },
122 ];
123 return {
124 app: use.app,
125 month,
126 ...range,
127 currency: "USD",
128 marginPct: margin,
129 lines,
130 total: dollars(awakeCents + storageCents + crewCents + marginCents),
131 };
132 }
133
134 /** A CSV field: quoted when it holds a comma, quote or line break; a leading =+-@ is defused. */
135 function cell(value: string | number): string {
136 let s = String(value);
137 if (/^[=+\-@\t\r]/.test(s) && typeof value === "string") s = `'${s}`;
138 return /[",\r\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
139 }
140
141 /** Money with two decimals; a quantity or price as the number it is. */
142 const cents2 = (n: number): string => n.toFixed(2);
143
144 /**
145 * The same statement as a table file: one header, one row per line, then
146 * the total; amounts with two decimals. RFC 4180 line endings.
147 */
148 export function statementCsv(s: Statement): string {
149 const rows: (string | number)[][] = [["app", "month", "what", "quantity", "unit", "price_usd", "amount_usd"]];
150 for (const l of s.lines) rows.push([s.app, s.month, l.what, l.quantity, l.unit, l.price, cents2(l.amount)]);
151 rows.push([s.app, s.month, "Total", "", "", "", cents2(s.total)]);
152 return rows.map((r) => r.map(cell).join(",")).join("\r\n") + "\r\n";
153 }
src/core/slider.ts +12
@@ -170,12 +170,20 @@ export function policyWords(policy: Policy): string {
170170 * - `ownedSlots`: how many apps the box holds awake at once (app-slots);
171171 * - `cloudHourCents`: US cents per awake-hour on a cloud;
172172 * - `storageGbMonthCents`: US cents per GB-month (decimal GB) kept.
173 *
174 * Beside them, the list prices a customer pays (#508, `core/revenue.ts`):
175 * - `awakePriceHourCents`: US cents per awake-hour, wherever it ran;
176 * - `storagePriceGbMonthCents`: US cents per GB-month kept.
177 * Crew spend is billed at cost × (1 + margin%), the margin set beside the
178 * slider, so it has no list price of its own.
173179 */
174180 export interface Prices {
175181 readonly ownedMonthCents: number;
176182 readonly ownedSlots: number;
177183 readonly cloudHourCents: number;
178184 readonly storageGbMonthCents: number;
185 readonly awakePriceHourCents: number;
186 readonly storagePriceGbMonthCents: number;
179187 }
180188
181189 export type PriceKey = keyof Prices;
@@ -184,6 +192,8 @@ export const PRICE_KEYS: readonly PriceKey[] = [
184192 "ownedSlots",
185193 "cloudHourCents",
186194 "storageGbMonthCents",
195 "awakePriceHourCents",
196 "storagePriceGbMonthCents",
187197 ];
188198
189199 /** Nothing priced until the operator says: an unpriced forge costs $0. */
@@ -192,6 +202,8 @@ export const PRICES_DEFAULT: Prices = {
192202 ownedSlots: 0,
193203 cloudHourCents: 0,
194204 storageGbMonthCents: 0,
205 awakePriceHourCents: 0,
206 storagePriceGbMonthCents: 0,
195207 };
196208
197209 /** $1,000,000.00: far above any one box, low enough to stay a safe integer. */
src/http/api.ts +2
@@ -353,6 +353,7 @@ import { displayRoutes } from "./api/displays.ts";
353353 import { followingRoutes } from "./api/following.ts";
354354 import { labelsRoutes } from "./api/labels.ts";
355355 import { dataRoutes } from "./api/data.ts";
356 import { statementRoutes } from "./api/statement.ts";
356357 import { visionRoutes } from "./api/vision.ts";
357358 import { businessPlanRoutes } from "./api/business_plan.ts";
358359 import { tokenRoutes } from "./api/tokens.ts";
@@ -2095,6 +2096,7 @@ export function apiRoutes(router: Router<Handler>): void {
20952096 labelsRoutes(router);
20962097 visionRoutes(router);
20972098 dataRoutes(router);
2099 statementRoutes(router);
20982100 businessPlanRoutes(router);
20992101 router.add(
21002102 "PUT",
src/http/api/economics.ts +5
@@ -28,6 +28,9 @@ export function wire(e: EconomicsView) {
2828 ownedSlots: e.prices.ownedSlots,
2929 cloudHourUsd: e.prices.cloudHourCents / 100,
3030 storageGbMonthUsd: e.prices.storageGbMonthCents / 100,
31 // List prices a customer pays (#508); crew is cost × (1 + marginPct%).
32 awakePriceHourUsd: e.prices.awakePriceHourCents / 100,
33 storagePriceGbMonthUsd: e.prices.storagePriceGbMonthCents / 100,
3134 },
3235 month: e.month,
3336 };
@@ -54,6 +57,8 @@ export function economicsRoutes(router: Router<Handler>): void {
5457 ownedSlots: input.ownedSlots,
5558 cloudHourUsd: input.cloudHourUsd,
5659 storageGbMonthUsd: input.storageGbMonthUsd,
60 awakePriceHourUsd: input.awakePriceHourUsd,
61 storagePriceGbMonthUsd: input.storagePriceGbMonthUsd,
5762 });
5863 if (changed.ok) return json(wire(changed.value));
5964 if (changed.error === "not_found") return notFound();
src/http/api/statement.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * GET /api/v1/repos/:owner/:name/statement?month=YYYY-MM[&format=csv]: one
3 * app's statement for one UTC month (#508, `core/revenue.ts`) — awake time,
4 * storage, crew spend and the margin on it, each line quantity × price, in
5 * US dollars rounded once to the cent. JSON by default; `format=csv` (or an
6 * `accept: text/csv`) is the same lines as a table file. For the
7 * repository's admins and forge admins; anyone else is told 404.
8 */
9
10 import { STATEMENT_WORDS, readStatement } from "../../actions/statement.ts";
11 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
12 import { statementCsv } from "../../core/revenue.ts";
13 import { loadRepo } from "../api.ts";
14 import type { Handler } from "../context.ts";
15 import { apiError, json, notFound, requireScope } from "../respond.ts";
16 import type { Router } from "../router.ts";
17
18 const PRIVATE = { "cache-control": "private, no-store", vary: "authorization, cookie, accept" };
19
20 export function statementRoutes(router: Router<Handler>): void {
21 router.add("GET", "/api/v1/repos/:owner/:name/statement", (ctx) => {
22 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
23 if (ctx.auth.user === undefined) return notFound();
24 const denied = requireScope(ctx, "ro");
25 if (denied) return denied;
26 const repo = loadRepo(ctx);
27 if (!repo) return notFound();
28 const asked = ctx.url.searchParams.get("format");
29 const format = asked ?? ((ctx.request.headers.get("accept") ?? "").includes("text/csv") ? "csv" : "json");
30 if (format !== "json" && format !== "csv") return apiError("invalid_format", 422, "format must be json or csv");
31 const month = ctx.url.searchParams.get("month") ?? undefined;
32 const read = readStatement(ctx.app, ctx.auth.user, repo, month);
33 if (!read.ok) return read.error === "not_found" ? notFound() : apiError(read.error, 422, STATEMENT_WORDS[read.error]);
34 const s = read.value;
35 if (format === "json") return json(s, 200, PRIVATE);
36 return new Response(statementCsv(s), {
37 headers: {
38 ...PRIVATE,
39 "content-type": "text/csv; charset=utf-8",
40 "content-disposition": `attachment; filename="${repo.ownerName}-${repo.name}-${s.month}.csv"`,
41 },
42 });
43 });
44 }
src/http/mcp/tools/economics.ts +5 −3
@@ -6,12 +6,12 @@ import { scopeAllows } from "../../../core/tokens.ts";
66 import { good, bad, type Tool } from "../shared.ts";
77
88 /** Every field the tool can set, as `PUT /api/v1/forge/economics` takes them. */
9 const FIELDS = ["position", "marginPct", "ownedNodeLabel", "ownedMonthUsd", "ownedSlots", "cloudHourUsd", "storageGbMonthUsd"] as const;
9 const FIELDS = ["position", "marginPct", "ownedNodeLabel", "ownedMonthUsd", "ownedSlots", "cloudHourUsd", "storageGbMonthUsd", "awakePriceHourUsd", "storagePriceGbMonthUsd"] as const;
1010 type Field = (typeof FIELDS)[number];
1111
1212 export const tool: Tool = {
1313 name: "economics",
14 doc: "`{ position?, marginPct?, ownedNodeLabel?, ownedMonthUsd?, ownedSlots?, cloudHourUsd?, storageGbMonthUsd?, dryRun? }`: with nothing, the profit slider's `position` (0 to 100), `marginPct` (0 to 500), the `policy` and its `words`, `placement` (whether a cloud body takes bursts, the `ownedNodeLabel` that selects owned nodes), the operator's `prices` (US dollars) and this UTC `month`'s cost, as `GET /api/v1/forge/economics` answers; with any field, set it (prices in US dollars with at most two decimals, non-negative; `ownedSlots` a whole number of app-slots; a label as `key=value`, blank for the default `plat.sh/site=vxrail`) with an admin token, a forge admin who is a person, never an agent, planned first (`dryRun` is true unless `false`); anyone else is told `not_found`",
14 doc: "`{ position?, marginPct?, ownedNodeLabel?, ownedMonthUsd?, ownedSlots?, cloudHourUsd?, storageGbMonthUsd?, awakePriceHourUsd?, storagePriceGbMonthUsd?, dryRun? }`: with nothing, the profit slider's `position` (0 to 100), `marginPct` (0 to 500), the `policy` and its `words`, `placement` (whether a cloud body takes bursts, the `ownedNodeLabel` that selects owned nodes), the operator's `prices` and the list prices a customer pays (`awakePriceHourUsd`, `storagePriceGbMonthUsd`; US dollars) and this UTC `month`'s cost, as `GET /api/v1/forge/economics` answers; with any field, set it (prices in US dollars with at most two decimals, non-negative; `ownedSlots` a whole number of app-slots; a label as `key=value`, blank for the default `plat.sh/site=vxrail`) with an admin token, a forge admin who is a person, never an agent, planned first (`dryRun` is true unless `false`); anyone else is told `not_found`",
1515 description: "Read the profit slider, its policy, placement, the prices and the month's cost, or set them (admin).",
1616 inputSchema: {
1717 type: "object",
@@ -23,6 +23,8 @@ export const tool: Tool = {
2323 ownedSlots: { type: "integer", minimum: 0 },
2424 cloudHourUsd: { type: "number", minimum: 0 },
2525 storageGbMonthUsd: { type: "number", minimum: 0 },
26 awakePriceHourUsd: { type: "number", minimum: 0 },
27 storagePriceGbMonthUsd: { type: "number", minimum: 0 },
2628 dryRun: { type: "boolean" },
2729 },
2830 },
@@ -66,7 +68,7 @@ function checkOnly(given: Partial<Record<Field, unknown>>): Exclude<EconomicsErr
6668 if (given.position !== undefined && parsePosition(given.position) === undefined) return "bad_position";
6769 if (given.marginPct !== undefined && parseMarginPct(given.marginPct) === undefined) return "bad_margin";
6870 if (given.ownedNodeLabel !== undefined && labelInput(given.ownedNodeLabel) === undefined) return "bad_label";
69 for (const f of ["ownedMonthUsd", "cloudHourUsd", "storageGbMonthUsd"] as const)
71 for (const f of ["ownedMonthUsd", "cloudHourUsd", "storageGbMonthUsd", "awakePriceHourUsd", "storagePriceGbMonthUsd"] as const)
7072 if (given[f] !== undefined && parseUsdCents(given[f]) === undefined) return "bad_price";
7173 if (given.ownedSlots !== undefined && parseSlots(given.ownedSlots) === undefined) return "bad_slots";
7274 return undefined;
src/http/server.ts +2
@@ -493,6 +493,7 @@ import { startFeatureSchedule } from "../apps/feature_schedule.ts";
493493 import { startPullSchedule } from "../apps/pull_schedule.ts";
494494 import { startGithubPull } from "../perf/github_traffic.ts";
495495 import { appSourcesPage } from "../web/pages/sources.ts";
496 import { statementPage } from "../web/pages/statement.ts";
496497 import { createViewWriter } from "../perf/views_store.ts";
497498 import { createReadWriter, readOf } from "../perf/reads.ts";
498499 import { parseServerTiming } from "../perf/record.ts";
@@ -1457,6 +1458,7 @@ export function buildRouter(): Router<Handler> {
14571458 );
14581459 router.add("GET", "/:owner/:name/settings/app", appSettingsPage);
14591460 router.add("GET", "/:owner/:name/settings/app/traffic", appSourcesPage);
1461 router.add("GET", "/:owner/:name/settings/app/statement", statementPage);
14601462 router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage);
14611463 // The shell page; an upgrade at the same path is the socket (`machines/shell.ts`).
14621464 router.add("GET", "/:owner/:name/machines/:machine/shell", machineShellPage);
src/kernel/why_now.ts deleted −37
@@ -1,37 +0,0 @@
1 /**
2 * Retired 2026-11-09: the direction no longer puts the pipeline first —
3 * order is set by the pillars and the captain's ranking — so the gate no
4 * longer asks (`scripts/gate.ts`). A finished, approved pull closed unmerged
5 * on this rule (#10317); nothing reads it now. Kept with its test until the
6 * next sweep of unused modules.
7 *
8 * The direction issue put the pipeline first (#8839), so a new feature
9 * said why it went ahead of it. A feature is a file a pull adds, at any
10 * depth, under an action, a page, an API route, an MCP tool or a terminal
11 * screen; the pull's commits must then say either `Pipeline: #N` (it is
12 * pipeline work) or `Why now: <reason>` (it goes ahead, and why). The check
13 * cannot read the pull description, so the answer lives in a commit
14 * message. It is a nudge, not a proof: it checks a reason was written, not
15 * that it is a good one.
16 */
17
18 import type { Change } from "./pin_edits.ts";
19
20 const FEATURE = /^src\/(actions|web\/pages|http\/api|http\/mcp\/tools|tui)\/.+\.ts$/;
21 const PIPELINE = /^Pipeline:\s*#\d+/im;
22 const WHY_NOW = /^Why now:\s*\S[^\n]{14,}/im;
23
24 /** The files a pull adds that make a new feature. */
25 export function featuresAdded(changes: readonly Change[]): string[] {
26 return changes.filter((c) => c.status.startsWith("A") && FEATURE.test(c.path)).map((c) => c.path);
27 }
28
29 /** One line per problem: none when no feature is added or a commit answers the direction. */
30 export function whyNowProblems(changes: readonly Change[], messages: readonly string[]): string[] {
31 const added = featuresAdded(changes);
32 if (added.length === 0) return [];
33 if (messages.some((m) => PIPELINE.test(m) || WHY_NOW.test(m))) return [];
34 return [
35 `rule "say why it goes ahead of the pipeline": this pull adds ${added.join(", ")} but no commit message has a "Why now: <reason>" line (15+ characters) or a "Pipeline: #N" line`,
36 ];
37 }
src/settings/economics.ts +5 −1
@@ -44,6 +44,8 @@ const STORE: Record<EconomicsKey, { key: string; min: number; max: number; fallb
4444 ownedSlots: { key: "economics.owned_slots", min: 0, max: SLOTS_MAX, fallback: PRICES_DEFAULT.ownedSlots },
4545 cloudHourCents: { key: "economics.cloud_hour_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.cloudHourCents },
4646 storageGbMonthCents: { key: "economics.storage_gb_month_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.storageGbMonthCents },
47 awakePriceHourCents: { key: "economics.awake_price_hour_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.awakePriceHourCents },
48 storagePriceGbMonthCents: { key: "economics.storage_price_gb_month_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.storagePriceGbMonthCents },
4749 };
4850
4951 const KEYS = Object.keys(STORE) as EconomicsKey[];
@@ -77,13 +79,15 @@ export function setEconomics(db: Database, next: Economics, actorId: string): vo
7779 }
7880 }
7981
80 /** The operator's prices as they stand now: four point reads. */
82 /** The operator's prices and list prices as they stand now: six point reads. */
8183 export function getPrices(db: Database): Prices {
8284 return {
8385 ownedMonthCents: stored(db, "ownedMonthCents"),
8486 ownedSlots: stored(db, "ownedSlots"),
8587 cloudHourCents: stored(db, "cloudHourCents"),
8688 storageGbMonthCents: stored(db, "storageGbMonthCents"),
89 awakePriceHourCents: stored(db, "awakePriceHourCents"),
90 storagePriceGbMonthCents: stored(db, "storagePriceGbMonthCents"),
8791 };
8892 }
8993
src/tui/screens/economics.ts +2
@@ -107,6 +107,8 @@ const LEVERS: readonly Lever[] = [
107107 },
108108 price("cloudHourUsd", (e) => e.prices.cloudHourCents, "$/awake-hour on cloud"),
109109 price("storageGbMonthUsd", (e) => e.prices.storageGbMonthCents, "$/GB-month kept"),
110 price("awakePriceHourUsd", (e) => e.prices.awakePriceHourCents, "list $/awake-hour a customer pays"),
111 price("storagePriceGbMonthUsd", (e) => e.prices.storagePriceGbMonthCents, "list $/GB-month a customer pays"),
110112 ];
111113
112114 /** "This month so far: owned $60.00 · cloud $0.00 · … · total $61.50". */
src/tui/screens/statement.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * An app's statement in the terminal (#508): the page's lines and total
3 * (`web/pages/statement.ts`) from the same read, `actions/statement.ts`
4 * `readStatement`, so the same gate holds — the repository's admins and
5 * forge admins. Offered from a repository's `:` palette to them alone;
6 * anyone else, were the screen reached, sees no lines.
7 *
8 * `m` types a month (YYYY-MM, UTC), `[` and `]` step a month back and
9 * forward. Read-only; amounts in US dollars.
10 */
11
12 import { STATEMENT_WORDS, mayReadStatement, readStatement } from "../../actions/statement.ts";
13 import { monthOf, monthRange, parseMonth, type Statement } from "../../core/revenue.ts";
14 import type { Repo } from "../../core/repos.ts";
15 import type { Ctx, Screen } from "../app/app.ts";
16 import { binding } from "../app/keys.ts";
17 import type { Command } from "../app/palette.ts";
18 import { style } from "../term/style.ts";
19 import { truncate } from "../term/width.ts";
20 import type { Session } from "../session.ts";
21 import { fill, len, split } from "../ui/layout.ts";
22 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
23
24 /** A quantity or price as its plain digits, never an exponent. */
25 const plain = (n: number): string =>
26 n.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 6 });
27 const usd = (n: number): string => `$${n.toFixed(2)}`;
28
29 /** The month before or after "YYYY-MM"; undefined before 2000-01. */
30 export function stepMonth(month: string, by: -1 | 1): string | undefined {
31 const { from, to } = monthRange(month);
32 const at = by === 1 ? to : new Date(Date.parse(from) - 1).toISOString();
33 return parseMonth(monthOf(at));
34 }
35
36 /** One row per line, quantity × price = amount, then the total. */
37 export function statementRows(s: Statement): ListRow[] {
38 return [
39 ...s.lines.map((l) => ({
40 title: l.what,
41 meta: `${plain(l.quantity)} ${l.unit} × $${plain(l.price)} = ${usd(l.amount)}`,
42 })),
43 { title: "Total", meta: usd(s.total) },
44 ];
45 }
46
47 export function statementScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
48 const state = new ListState();
49 let month: string | undefined;
50 const read = () => readStatement(f.app, f.user, repo, month);
51 const screen: Screen = {
52 title: `${repo.ownerName}/${repo.name} — Statement`,
53 keys: [
54 binding(["j", "k"], "move cursor"),
55 binding(["m"], "month"),
56 binding(["[", "]"], "previous/next month"),
57 ],
58 render(area, s) {
59 const c = ctx.theme.c;
60 const r = read();
61 const [head, list] = split(area, "v", [len(1), fill(1)]);
62 const said = r.ok ? `${r.value.month} (UTC) · USD` : r.error === "bad_month" ? STATEMENT_WORDS.bad_month : "";
63 s.text(head!.x, head!.y, truncate(said, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
64 drawList(s, list!, c, state, r.ok ? statementRows(r.value) : []);
65 },
66 key(e) {
67 const r = read();
68 if (!r.ok && r.error === "not_found") return false;
69 if (state.key(e, r.ok ? r.value.lines.length + 1 : 0)) return true;
70 if ((e.key === "[" || e.key === "]") && r.ok) {
71 const next = stepMonth(r.value.month, e.key === "]" ? 1 : -1);
72 if (next !== undefined) month = next;
73 return true;
74 }
75 if (e.key === "m") {
76 ctx.push(
77 promptScreen(ctx, screen, {
78 title: "Statement month",
79 label: "Month (UTC), YYYY-MM",
80 placeholder: r.ok ? r.value.month : "2026-10",
81 onSubmit: (value) => {
82 if (parseMonth(value.trim()) === undefined) return `The ${STATEMENT_WORDS.bad_month}.`;
83 month = value.trim();
84 return undefined;
85 },
86 }),
87 );
88 return true;
89 }
90 return false;
91 },
92 };
93 return screen;
94 }
95
96 /** The palette's "Statement" while a repository is open, to its admins and forge admins. */
97 export function statementCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] {
98 if (repo === undefined || !mayReadStatement(f.app, repo, f.user)) return [];
99 return [
100 {
101 id: "app-statement",
102 title: `${repo.ownerName}/${repo.name} — Statement`,
103 hint: "what the forge would charge this app, a month at a time",
104 run: () => ctx.push(statementScreen(f, ctx, repo)),
105 },
106 ];
107 }
src/tui/screens/workspace.ts +2
@@ -149,6 +149,7 @@ import { labelsCommands } from "./labels.ts";
149149 import { appEnvCommands } from "./appenv.ts";
150150 import { autonomyCommands } from "./autonomy.ts";
151151 import { trafficCommands } from "./trafficview.ts";
152 import { statementCommands } from "./statement.ts";
152153 import { adapterCommands } from "./adapter.ts";
153154 import { atlasCommands } from "./atlas.ts";
154155 import { externalEditor, editorHint, hasExternalEditor } from "./external.ts";
@@ -1576,6 +1577,7 @@ export function workspaceScreen(
15761577 ...appEnvCommands(f, ctx, repo),
15771578 ...autonomyCommands(f, ctx, repo),
15781579 ...trafficCommands(f, ctx, repo),
1580 ...statementCommands(f, ctx, repo),
15791581 ...adapterCommands(f, ctx, repo),
15801582 ...atlasCommands(f, ctx, repo),
15811583 ],
src/web/pages/economics.ts +9 −1
@@ -6,7 +6,8 @@
66 * label set here, and whether a cloud body takes bursts — without one the
77 * page says "no cloud body: bursts queue" (#509).
88 * The operator's prices, each with its unit, and what this UTC month has
9 * cost so far at them, line by line (`core/cost.ts`).
9 * cost so far at them, line by line (`core/cost.ts`); beside them the list
10 * prices a customer pays (`core/revenue.ts`).
1011 * Server-rendered; the forms are same-origin POSTs (`readForm`); a field a
1112 * form does not carry keeps its value. 50 is today's behaviour.
1213 * Anyone else gets the not-found page.
@@ -127,6 +128,11 @@ export function economicsPage(ctx: Ctx): Response {
127128 ${priceField("storageGbMonthUsd", "Storage ($/GB-month)", p.storageGbMonthCents)}
128129 ${btn("Save", { size: "sm", variant: "primary", label: "Save prices" })}
129130 </form>
131 <form class="row-fields" method="post" action="${PATH}">
132 ${priceField("awakePriceHourUsd", "List: awake ($/hour)", p.awakePriceHourCents)}
133 ${priceField("storagePriceGbMonthUsd", "List: storage ($/GB-month)", p.storagePriceGbMonthCents)}
134 ${btn("Save", { size: "sm", variant: "primary", label: "Save list prices" })}
135 </form>
130136 ${perSlot === undefined
131137 ? null
132138 : html`<dl class="stats" data-prices>
@@ -176,6 +182,8 @@ export async function economicsSet(ctx: Ctx): Promise<Response> {
176182 ownedSlots: given("ownedSlots"),
177183 cloudHourUsd: given("cloudHourUsd"),
178184 storageGbMonthUsd: given("storageGbMonthUsd"),
185 awakePriceHourUsd: given("awakePriceHourUsd"),
186 storagePriceGbMonthUsd: given("storagePriceGbMonthUsd"),
179187 });
180188 if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx);
181189 return flashTo(
src/web/pages/settings.ts +7 −1
@@ -800,7 +800,12 @@ function appSettingsView(
800800 </div>
801801 </form>
802802 </section>`;
803 if (record === undefined) return form;
803 // What the forge would charge (#508): crew spend counts before the repository runs as an app.
804 const statement = html`<section class="settings-block" id="statement">
805 <h2>Statement</h2>
806 <p><a href="${base}/settings/app/statement">This month's statement</a></p>
807 </section>`;
808 if (record === undefined) return html`${form}<div class="mt">${statement}</div>`;
804809 const domains = timePhase(ctx.request, "domains", () =>
805810 listAppDomains(ctx.app, ctx.auth.user, repo),
806811 );
@@ -810,6 +815,7 @@ function appSettingsView(
810815 return html`${form}
811816 ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)}
812817 <div class="mt">${appVisitorsView(vitals)}</div>
818 <div class="mt">${statement}</div>
813819 <div class="mt">
814820 ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))}
815821 </div>
src/web/pages/statement.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * GET /:owner/:name/settings/app/statement?month=YYYY-MM — the app's
3 * statement for one UTC month (#508, `core/revenue.ts`), as the API's
4 * `…/statement` answers it: one row per line, quantity × price = amount, and
5 * the total, in US dollars. Printing the page prints the statement alone:
6 * the shared print rules (`style/base.ts`) hide the navigation and picker. For the
7 * repository's admins and forge admins; anyone else gets the not-found page.
8 */
9
10 import { STATEMENT_WORDS, mayReadStatement, readStatement } from "../../actions/statement.ts";
11 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { loadRepo, repoNotFound } from "../chrome.ts";
14 import { html } from "../html.ts";
15 import { settingsPageResponse } from "./settings.ts";
16
17 /** A quantity or price as its plain digits, never an exponent. */
18 const plain = (n: number): string =>
19 n.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 6 });
20
21 export function statementPage(ctx: Ctx): Response {
22 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
23 const repo = timePhase(ctx.request, "repo", () => {
24 const r = loadRepo(ctx);
25 return r !== undefined && mayReadStatement(ctx.app, r, ctx.auth.user) ? r : undefined;
26 });
27 if (!repo) return repoNotFound(ctx);
28 const asked = ctx.url.searchParams.get("month");
29 const read = timePhase(ctx.request, "statement", () =>
30 readStatement(ctx.app, ctx.auth.user, repo, asked === null || asked === "" ? undefined : asked),
31 );
32 if (!read.ok && read.error === "not_found") return repoNotFound(ctx);
33 const rendering = performance.now();
34 const api = `/api/v1/repos/${encodeURIComponent(repo.ownerName)}/${encodeURIComponent(repo.name)}/statement`;
35 const picker = (month: string) => html`<form class="row-fields" method="get" data-statement-pick data-print-hide>
36 <label class="small"
37 >Month (UTC)
38 <input class="input mono" type="month" name="month" min="2000-01" value="${month}" required
39 /></label>
40 <button class="btn" data-size="sm" type="submit">Show</button>
41 </form>`;
42 let body;
43 if (!read.ok) {
44 body = html`<section class="settings-block">
45 <h2>Statement</h2>
46 ${picker("")}
47 <p role="alert">${STATEMENT_WORDS.bad_month}.</p>
48 </section>`;
49 } else {
50 const s = read.value;
51 const usd = (n: number) => `$${n.toFixed(2)}`;
52 const q = `?month=${s.month}`;
53 body = html`<section class="settings-block" data-statement data-print-alone>
54 <h2>Statement for ${s.app}, ${s.month} (UTC)</h2>
55 ${picker(s.month)}
56 <div class="table-scroll mt">
57 <table class="table">
58 <thead>
59 <tr><th>What</th><th class="num">Quantity</th><th>Unit</th><th class="num">Price (USD)</th><th class="num">Amount (USD)</th></tr>
60 </thead>
61 <tbody>
62 ${s.lines.map(
63 (l) => html`<tr>
64 <td>${l.what}</td>
65 <td class="num mono">${plain(l.quantity)}</td>
66 <td>${l.unit}</td>
67 <td class="num mono">${plain(l.price)}</td>
68 <td class="num mono">${usd(l.amount)}</td>
69 </tr>`,
70 )}
71 </tbody>
72 <tfoot>
73 <tr><th colspan="4">Total</th><th class="num mono" data-total>${usd(s.total)}</th></tr>
74 </tfoot>
75 </table>
76 </div>
77 <p class="small mt" data-statement-pick data-print-hide><a href="${api}${q}&format=csv">CSV</a> · <a href="${api}${q}">JSON</a></p>
78 </section>`;
79 }
80 const response = settingsPageResponse(ctx, repo, "app", "Statement", body, undefined, read.ok ? 200 : 422);
81 notePhase(ctx.request, "render", performance.now() - rendering);
82 return response;
83 }
src/web/style/base.ts +3 −1
@@ -227,8 +227,10 @@ export const utilities = /* css */ `
227227 }
228228
229229 @media print {
230 .topbar, .foot, .live-news, .sticky-action, .toast { display: none !important; }
230 .topbar, .foot, .live-news, .sticky-action, .toast, [data-print-hide] { display: none !important; }
231231 .card { box-shadow: none; }
232 /* A page that prints alone (an app's statement) drops the repository's chrome. */
233 body:has([data-print-alone]) :is(.repo-nav, .settings-jump, .settings-nav) { display: none !important; }
232234 }
233235 }
234236 `;
test/cost.test.ts +2
@@ -12,6 +12,8 @@ const prices: Prices = {
1212 ownedSlots: 40,
1313 cloudHourCents: 5, // $0.05 an awake-hour
1414 storageGbMonthCents: 2, // $0.02 a GB-month
15 awakePriceHourCents: 0, // list prices do not enter cost
16 storagePriceGbMonthCents: 0,
1517 };
1618 const november = { from: "2026-11-01T00:00:00.000Z", to: "2026-12-01T00:00:00.000Z" };
1719
test/economics.test.ts +11 −3
@@ -177,7 +177,7 @@ describe("economics", () => {
177177
178178 test("prices: the form and the API set them in dollars, refuse a third decimal or a sign, and the page costs the month", async () => {
179179 const cookie = await login("alice");
180 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 0, ownedSlots: 0, cloudHourCents: 0, storageGbMonthCents: 0 });
180 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 0, ownedSlots: 0, cloudHourCents: 0, storageGbMonthCents: 0, awakePriceHourCents: 0, storagePriceGbMonthCents: 0 });
181181 const prices = { ownedMonthUsd: "300", ownedSlots: "40", cloudHourUsd: "0.05", storageGbMonthUsd: "0.02" };
182182 expect((await post(cookie, prices, "https://evil.example")).status).toBe(403);
183183 expect(getPrices(app.db).ownedMonthCents).toBe(0);
@@ -185,7 +185,7 @@ describe("economics", () => {
185185 // The prices form carries no position: it keeps what stands.
186186 const before = getEconomics(app.db);
187187 expect((await post(cookie, prices)).status).toBe(303);
188 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 30_000, ownedSlots: 40, cloudHourCents: 5, storageGbMonthCents: 2 });
188 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 30_000, ownedSlots: 40, cloudHourCents: 5, storageGbMonthCents: 2, awakePriceHourCents: 0, storagePriceGbMonthCents: 0 });
189189 expect(getEconomics(app.db)).toEqual(before);
190190 expect(economicsChanges(app.db).filter((c) => c.key === "ownedMonthCents").map((c) => [c.from, c.to])).toEqual([["0", "30000"]]);
191191
@@ -212,7 +212,7 @@ describe("economics", () => {
212212 prices: Record<string, number>;
213213 month: { from: string; to: string; cost: { owned: number; cloud: number; total: number } };
214214 };
215 expect(got.prices).toEqual({ ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02 });
215 expect(got.prices).toEqual({ ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02, awakePriceHourUsd: 0, storagePriceGbMonthUsd: 0 });
216216 expect(got.month.cost.cloud).toBe(0);
217217 expect(got.month.cost.owned).toBeGreaterThan(0);
218218 expect(got.month.cost.owned).toBeLessThanOrEqual(300);
@@ -224,6 +224,14 @@ describe("economics", () => {
224224 expect(((await (await api(token, "PUT", { ownedSlots: -1 })).json()) as { error: string }).error).toBe("bad_slots");
225225 expect((await api(token, "PUT", { cloudHourUsd: 0.1 })).status).toBe(200);
226226 expect(getPrices(app.db).cloudHourCents).toBe(10);
227
228 // The list prices a customer pays (#508) sit beside the costs, same rules.
229 expect((await api(token, "PUT", { awakePriceHourUsd: "0.12", storagePriceGbMonthUsd: 0.3 })).status).toBe(200);
230 expect(getPrices(app.db)).toMatchObject({ awakePriceHourCents: 12, storagePriceGbMonthCents: 30, cloudHourCents: 10 });
231 expect(((await (await api(token, "PUT", { awakePriceHourUsd: -1 })).json()) as { error: string }).error).toBe("bad_price");
232 const listed = await (await fetch(`${servers.url}/settings/economics`, { headers: { cookie: await login("alice") } })).text();
233 expect(listed).toContain('name="awakePriceHourUsd"');
234 expect(listed).toContain('value="0.30"');
227235 });
228236
229237 test("the owned-node label is a setting: shown with the queue line, set by API and form, validated (#509)", async () => {
test/mcp-economics.test.ts +2 −2
@@ -110,9 +110,9 @@ describe("MCP economics", () => {
110110
111111 const set = await call(admin, { ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02, dryRun: false });
112112 expect(set.error).toBe(false);
113 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 30_000, ownedSlots: 40, cloudHourCents: 5, storageGbMonthCents: 2 });
113 expect(getPrices(app.db)).toEqual({ ownedMonthCents: 30_000, ownedSlots: 40, cloudHourCents: 5, storageGbMonthCents: 2, awakePriceHourCents: 0, storagePriceGbMonthCents: 0 });
114114 const read = JSON.parse((await call(admin, {})).text);
115 expect(read.prices).toEqual({ ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02 });
115 expect(read.prices).toEqual({ ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02, awakePriceHourUsd: 0, storagePriceGbMonthUsd: 0 });
116116 expect(read.month.cost.owned).toBeGreaterThan(0);
117117 expect(read.month.cost.owned).toBeLessThanOrEqual(300);
118118 });
test/revenue.test.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * The revenue statement (#508, `core/revenue.ts`): a hand-built month
3 * matches to the cent, margin 0 bills crew spend at cost, the margin touches
4 * crew spend only, rounding happens once at the end, and the CSV carries the
5 * same lines and total as the JSON.
6 */
7
8 import { describe, expect, test } from "bun:test";
9 import { monthOf, monthRange, parseMonth, statementCsv, statementFor } from "../src/core/revenue.ts";
10
11 // 100 awake-hours, 3 GB-months (3 × 730 GB-hours), $57.10 of crew.
12 const october = { app: "ada/notes", awakeSeconds: 100 * 3600, gbHours: 3 * 730, crewUsd: 57.1 };
13 const rates = { awakePriceHourCents: 10, storagePriceGbMonthCents: 25, marginPct: 20 };
14
15 describe("statementFor", () => {
16 test("a hand-built month matches to the cent", () => {
17 // By hand: 100 h × $0.10 = $10.00; 3 GB-months × $0.25 = $0.75;
18 // crew $57.10 at cost; margin 20% × $57.10 = $11.42. Total $79.27.
19 const s = statementFor(october, "2026-10", rates);
20 expect(s.lines).toEqual([
21 { what: "Awake time", quantity: 100, unit: "awake-hour", price: 0.1, amount: 10 },
22 { what: "Storage", quantity: 3, unit: "GB-month", price: 0.25, amount: 0.75 },
23 { what: "Crew spend at cost", quantity: 57.1, unit: "USD of crew spend", price: 1, amount: 57.1 },
24 { what: "Margin on crew spend (20%)", quantity: 57.1, unit: "USD of crew spend", price: 0.2, amount: 11.42 },
25 ]);
26 expect(s.total).toBe(79.27);
27 expect(s).toMatchObject({
28 app: "ada/notes",
29 month: "2026-10",
30 from: "2026-10-01T00:00:00.000Z",
31 to: "2026-11-01T00:00:00.000Z",
32 currency: "USD",
33 marginPct: 20,
34 });
35 });
36
37 test("margin 0 bills crew spend at cost", () => {
38 const s = statementFor(october, "2026-10", { ...rates, marginPct: 0 });
39 expect(s.lines[2]!.amount).toBe(57.1);
40 expect(s.lines[3]!.amount).toBe(0);
41 expect(s.total).toBe(67.85);
42 });
43
44 test("the margin applies to crew spend only", () => {
45 const noCrew = statementFor({ ...october, crewUsd: 0 }, "2026-10", { ...rates, marginPct: 500 });
46 expect(noCrew.total).toBe(10.75);
47 });
48
49 test("rounding is once, at the end: three thirds of a cent are a cent", () => {
50 // 1200 s = 1/3 h at 1¢ is a third of a cent (rounds to $0.00); 730/3
51 // GB-hours = 1/3 GB-month at 2¢ is two thirds (rounds to $0.01). The
52 // total is the unrounded sum, 1/3 + 2/3 = 1¢, rounded once.
53 const s = statementFor(
54 { app: "a/b", awakeSeconds: 1200, gbHours: 730 / 3, crewUsd: 0 },
55 "2026-10",
56 { awakePriceHourCents: 1, storagePriceGbMonthCents: 2, marginPct: 0 },
57 );
58 expect(s.lines[0]!.amount).toBe(0);
59 expect(s.lines[1]!.amount).toBe(0.01);
60 expect(s.total).toBe(0.01);
61 expect(s.lines[0]!.quantity).toBe(0.333333);
62 });
63
64 test("nothing used and nothing priced is a statement of zeros", () => {
65 const s = statementFor({ app: "a/b", awakeSeconds: NaN, gbHours: -1, crewUsd: 0 }, "2026-02", {
66 awakePriceHourCents: 0,
67 storagePriceGbMonthCents: 0,
68 marginPct: 0,
69 });
70 expect(s.total).toBe(0);
71 expect(s.lines.every((l) => l.amount === 0 && l.quantity === 0)).toBe(true);
72 expect(s.to).toBe("2026-03-01T00:00:00.000Z");
73 });
74 });
75
76 describe("months", () => {
77 test("YYYY-MM only, from 2000-01", () => {
78 expect(parseMonth("2026-10")).toBe("2026-10");
79 for (const bad of ["2026-13", "2026-1", "1999-12", "2026-10-01", "", 202610, null, " 2026-10"])
80 expect(parseMonth(bad)).toBeUndefined();
81 });
82
83 test("a month is half-open, December runs into the next year", () => {
84 expect(monthRange("2026-12")).toEqual({ from: "2026-12-01T00:00:00.000Z", to: "2027-01-01T00:00:00.000Z" });
85 expect(monthOf("2026-11-09T12:00:00.000Z")).toBe("2026-11");
86 });
87 });
88
89 describe("statementCsv", () => {
90 test("the CSV and the JSON agree, line by line and in total", () => {
91 const s = statementFor(october, "2026-10", rates);
92 const csv = statementCsv(s);
93 expect(csv.endsWith("\r\n")).toBe(true);
94 const rows = csv.trimEnd().split("\r\n").map((r) => r.split(","));
95 expect(rows[0]).toEqual(["app", "month", "what", "quantity", "unit", "price_usd", "amount_usd"]);
96 s.lines.forEach((l, i) => {
97 const r = rows[i + 1]!;
98 expect(r).toEqual(["ada/notes", "2026-10", l.what, String(l.quantity), l.unit, String(l.price), l.amount.toFixed(2)]);
99 });
100 expect(rows.at(-1)).toEqual(["ada/notes", "2026-10", "Total", "", "", "", "79.27"]);
101 });
102
103 test("a field with a comma or quote is quoted, a leading = is defused", () => {
104 const s = statementFor({ ...october, app: '=x,"y"' }, "2026-10", rates);
105 expect(statementCsv(s).split("\r\n")[1]!.startsWith(`"'=x,""y"""`)).toBe(true);
106 });
107 });
test/statement.test.ts added +207
@@ -0,0 +1,207 @@
1 /**
2 * GET /api/v1/repos/:owner/:name/statement and the print page under the
3 * app's settings (#508): a month built from the meter, the storage samples
4 * and the crew ledger is billed at the list prices and margin; the JSON and
5 * the CSV agree; another repository's use never shows; someone who does not
6 * administer the repository is told 404; a month not YYYY-MM is refused.
7 */
8
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { createRepo } from "../src/actions/repos.ts";
15 import { closeAwake, openAwake, recordStorage } from "../src/core/meter.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createToken } from "../src/core/tokens.ts";
18 import { recordUsage } from "../src/core/usage.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { serve, type Servers } from "../src/http/server.ts";
21 import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts";
22 import { utilities } from "../src/web/style/base.ts";
23
24 describe("statement", () => {
25 let dataDir: string;
26 let app: App;
27 let servers: Servers;
28 let alice: User;
29 let bob: User;
30 let notes: Repo;
31 let other: Repo;
32
33 const login = async (username: string) => {
34 const res = await fetch(`${servers.url}/signin`, {
35 method: "POST",
36 redirect: "manual",
37 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
38 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
39 });
40 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
41 };
42 const get = (token: string, query: string, repo = "notes", owner = "alice") =>
43 fetch(`${servers.url}/api/v1/repos/${owner}/${repo}/statement${query}`, {
44 headers: { authorization: `Bearer ${token}` },
45 });
46 const spend = (repo: Repo, usd: number, at: string) =>
47 recordUsage(app.db, {
48 runId: null,
49 repoId: repo.id,
50 issueId: null,
51 agentId: null,
52 subscriptionId: null,
53 tokensIn: 1,
54 tokensOut: 1,
55 costUsd: usd,
56 wallMs: 1,
57 outcome: "ok",
58 at,
59 } as never);
60
61 beforeAll(async () => {
62 dataDir = await mkdtemp(join(tmpdir(), "forge-statement-"));
63 app = createApp({ dataDir, port: 0 });
64 servers = await serve(app);
65 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
66 const made = await createUser(app.db, {
67 origin: "seed",
68 trusted: true,
69 username,
70 email: `${username}@forge.local`,
71 password: "a-good-passphrase",
72 adminIfFirst: first,
73 });
74 if (!made.ok) throw new Error(made.error);
75 if (username === "alice") alice = made.value;
76 else bob = made.value;
77 }
78 const a = await createRepo(app, alice, { name: "notes", visibility: "public" });
79 const b = await createRepo(app, alice, { name: "other", visibility: "public" });
80 if (!a.ok || !b.ok) throw new Error("repo");
81 notes = a.value;
82 other = b.value;
83
84 // October 2026, by hand: 100 awake-hours over two environments, one of
85 // them on cloud; 3 GB-months (3 GB held for 730 h); $57.10 of crew.
86 openAwake(app.db, { repoId: notes.id, env: "production", placement: "owned", at: "2026-10-02T00:00:00.000Z" });
87 closeAwake(app.db, { repoId: notes.id, env: "production", at: "2026-10-05T12:00:00.000Z" }); // 84 h
88 openAwake(app.db, { repoId: notes.id, env: "pr-1", placement: "cloud", at: "2026-10-31T08:00:00.000Z" });
89 closeAwake(app.db, { repoId: notes.id, env: "pr-1", at: "2026-11-01T02:00:00.000Z" }); // 16 h in October, 2 in November
90 for (let h = 0; h < 730; h++)
91 recordStorage(app.db, {
92 repoId: notes.id,
93 dataBytes: 2e9,
94 repoBytes: 1e9,
95 at: new Date(Date.parse("2026-10-01T00:00:00.000Z") + h * 3_600_000).toISOString(),
96 });
97 spend(notes, 50, "2026-10-10T00:00:00.000Z");
98 spend(notes, 7.1, "2026-10-31T23:59:59.000Z");
99 spend(notes, 99, "2026-11-01T00:00:00.000Z"); // November's
100 spend(other, 1000, "2026-10-10T00:00:00.000Z"); // another app's
101 openAwake(app.db, { repoId: other.id, env: "production", placement: "owned", at: "2026-10-01T00:00:00.000Z" });
102 closeAwake(app.db, { repoId: other.id, env: "production", at: "2026-10-20T00:00:00.000Z" });
103
104 setPrices(app.db, { ...getPrices(app.db), awakePriceHourCents: 10, storagePriceGbMonthCents: 25, cloudHourCents: 999 }, alice.id);
105 setEconomics(app.db, { position: 50, marginPct: 20 }, alice.id);
106 });
107 afterAll(async () => {
108 await servers.stop();
109 await rm(dataDir, { recursive: true, force: true });
110 });
111
112 test("a hand-built month matches to the cent, and the CSV agrees with the JSON", async () => {
113 const token = createToken(app.db, alice.id, "t", "ro").token;
114 const res = await get(token, "?month=2026-10");
115 expect(res.status).toBe(200);
116 const s = (await res.json()) as {
117 month: string;
118 total: number;
119 lines: { what: string; quantity: number; unit: string; price: number; amount: number }[];
120 };
121 expect(s.month).toBe("2026-10");
122 // 100 h × $0.10 + 3 GB-months × $0.25 + $57.10 + 20% × $57.10 = 10 + 0.75 + 57.10 + 11.42.
123 expect(s.lines.map((l) => [l.quantity, l.price, l.amount])).toEqual([
124 [100, 0.1, 10],
125 [3, 0.25, 0.75],
126 [57.1, 1, 57.1],
127 [57.1, 0.2, 11.42],
128 ]);
129 expect(s.total).toBe(79.27);
130
131 const csv = await get(token, "?month=2026-10&format=csv");
132 expect(csv.status).toBe(200);
133 expect(csv.headers.get("content-type")).toContain("text/csv");
134 expect(csv.headers.get("content-disposition")).toContain("alice-notes-2026-10.csv");
135 const rows = (await csv.text()).trimEnd().split("\r\n").map((r) => r.split(","));
136 expect(rows.length).toBe(s.lines.length + 2);
137 s.lines.forEach((l, i) => {
138 const r = rows[i + 1]!;
139 expect([r[2], Number(r[3]), r[4], Number(r[5]), Number(r[6])]).toEqual([l.what, l.quantity, l.unit, l.price, l.amount]);
140 });
141 expect(Number(rows.at(-1)![6])).toBe(s.total);
142
143 const byAccept = await fetch(`${servers.url}/api/v1/repos/alice/notes/statement?month=2026-10`, {
144 headers: { authorization: `Bearer ${token}`, accept: "text/csv" },
145 });
146 expect(byAccept.headers.get("content-type")).toContain("text/csv");
147 });
148
149 test("margin 0 bills crew spend at cost", async () => {
150 setEconomics(app.db, { position: 50, marginPct: 0 }, alice.id);
151 try {
152 const token = createToken(app.db, alice.id, "t", "ro").token;
153 const s = (await (await get(token, "?month=2026-10")).json()) as { total: number; lines: { amount: number }[] };
154 expect(s.lines[2]!.amount).toBe(57.1);
155 expect(s.lines[3]!.amount).toBe(0);
156 expect(s.total).toBe(67.85);
157 } finally {
158 setEconomics(app.db, { position: 50, marginPct: 20 }, alice.id);
159 }
160 });
161
162 test("someone who does not administer the repository is told 404, and no token is 404", async () => {
163 const token = createToken(app.db, bob.id, "t", "ro").token;
164 expect((await get(token, "?month=2026-10")).status).toBe(404);
165 expect((await fetch(`${servers.url}/api/v1/repos/alice/notes/statement`)).status).toBe(404);
166 const page = await fetch(`${servers.url}/alice/notes/settings/app/statement`, { headers: { cookie: await login("bob") } });
167 expect(page.status).toBe(404);
168 });
169
170 test("a month that is not YYYY-MM, or a format not json or csv, is refused", async () => {
171 const token = createToken(app.db, alice.id, "t", "ro").token;
172 for (const q of ["?month=2026-13", "?month=26-10", "?month=2026-10-01"]) {
173 const res = await get(token, q);
174 expect(res.status).toBe(422);
175 expect(((await res.json()) as { error: string }).error).toBe("bad_month");
176 }
177 expect((await get(token, "?format=xml")).status).toBe(422);
178 });
179
180 test("the print page shows the lines and the total, escaped, with the security floor", async () => {
181 const res = await fetch(`${servers.url}/alice/notes/settings/app/statement?month=2026-10`, {
182 headers: { cookie: await login("alice") },
183 });
184 expect(res.status).toBe(200);
185 expect(res.headers.get("content-security-policy")).toBeTruthy();
186 const text = await res.text();
187 expect(text).toContain("Statement for alice/notes, 2026-10");
188 expect(text).toContain("Margin on crew spend (20%)");
189 expect(text).toMatch(/data-total>\$79\.27</);
190 // Printing alone is the shared stylesheet's rule, not page-inline CSS.
191 expect(text).toContain("data-print-alone");
192 expect(text).not.toMatch(/<style[^>]*>[^<]*settings-nav/);
193 expect(utilities).toMatch(/@media print[\s\S]*body:has\(\[data-print-alone\]\)[^{]*\.settings-nav/);
194 expect(text).toContain("/api/v1/repos/alice/notes/statement?month=2026-10&format=csv");
195 const bad = await fetch(`${servers.url}/alice/notes/settings/app/statement?month=%3Cb%3E`, {
196 headers: { cookie: await login("alice") },
197 });
198 expect(bad.status).toBe(422);
199 expect(await bad.text()).not.toContain("<b>");
200 });
201
202 test("the app's settings link to the statement", async () => {
203 const res = await fetch(`${servers.url}/alice/notes/settings/app`, { headers: { cookie: await login("alice") } });
204 expect(res.status).toBe(200);
205 expect(await res.text()).toContain('href="/alice/notes/settings/app/statement"');
206 });
207 });
test/tui-statement.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The terminal's statement screen (src/tui/screens/statement.ts, #508):
3 * from a repository's `:` palette its admin reads a month's lines and total,
4 * the same figures the API answers (test/statement.test.ts, by hand); `m`
5 * takes a month and refuses one that is not YYYY-MM; `[` and `]` step.
6 * Someone who does not administer the repository, or a guest, is offered
7 * nothing and, were the screen reached, sees no lines — the gate is
8 * `readStatement`'s.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { closeAwake, openAwake } from "../src/core/meter.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { recordUsage } from "../src/core/usage.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20 import { getPrices, setEconomics, setPrices } from "../src/settings/economics.ts";
21 import { createForgeApp } from "../src/tui/forge.ts";
22 import { repoScreen } from "../src/tui/screens/repo.ts";
23 import { statementCommands, statementScreen, stepMonth } from "../src/tui/screens/statement.ts";
24 import { local } from "../src/tui/screens/where.ts";
25 import { headlessTerminal } from "../src/tui/term/terminal.ts";
26 import { settleApp } from "./helpers/tui.ts";
27
28 const open = [":", "Statement", "\r"];
29
30 let dir: string;
31 let app: App;
32 let admin: User;
33 let bob: User;
34 let notes: Repo;
35
36 async function drive(user: User | undefined, keys: string[], direct = false): Promise<string> {
37 const terminal = headlessTerminal(120, 40);
38 const { app: tui, session } = createForgeApp(
39 app,
40 { sshUsername: user?.username ?? "guest", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
41 { terminal, exitHooks: false },
42 );
43 tui.start();
44 tui.ctx.push(direct ? statementScreen(session, tui.ctx, notes) : repoScreen(session, tui.ctx, local(session, notes)));
45 await settleApp(tui);
46 for (const k of keys) {
47 terminal.feed(k);
48 await settleApp(tui);
49 }
50 const text = tui.screen.toText();
51 tui.stop?.();
52 return text;
53 }
54
55 beforeAll(async () => {
56 dir = await mkdtemp(join(tmpdir(), "forge-tui-statement-"));
57 app = createApp({ dataDir: dir, port: 0 });
58 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
59 const made = await createUser(app.db, {
60 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
61 password: "a-good-passphrase", adminIfFirst: first,
62 });
63 if (!made.ok) throw new Error(made.error);
64 if (username === "alice") admin = made.value;
65 else bob = made.value;
66 }
67 const made = await createRepo(app, admin, { name: "notes", visibility: "public" });
68 if (!made.ok) throw new Error("repo");
69 notes = made.value;
70 // October 2026: 84 awake-hours and $50 of crew.
71 openAwake(app.db, { repoId: notes.id, env: "production", placement: "owned", at: "2026-10-02T00:00:00.000Z" });
72 closeAwake(app.db, { repoId: notes.id, env: "production", at: "2026-10-05T12:00:00.000Z" });
73 recordUsage(app.db, {
74 runId: null, repoId: notes.id, issueId: null, agentId: null, subscriptionId: null,
75 tokensIn: 1, tokensOut: 1, costUsd: 50, wallMs: 1, outcome: "ok", at: "2026-10-10T00:00:00.000Z",
76 } as never);
77 setPrices(app.db, { ...getPrices(app.db), awakePriceHourCents: 10, storagePriceGbMonthCents: 25 }, admin.id);
78 setEconomics(app.db, { position: 50, marginPct: 20 }, admin.id);
79 });
80 afterAll(async () => {
81 app.db.close();
82 await rm(dir, { recursive: true, force: true });
83 });
84
85 describe("the terminal's statement screen", () => {
86 test("an admin reads October's lines and total, matching the hand sum", async () => {
87 const text = await drive(admin, [...open, "m", "2026-10", "\r"]);
88 expect(text).toContain("2026-10 (UTC) · USD");
89 // 84 h × $0.10 = $8.40; $50 at cost; 20% × $50 = $10; total $68.40.
90 expect(text).toContain("84 awake-hour × $0.1 = $8.40");
91 expect(text).toContain("50 USD of crew spend × $1 = $50.00");
92 expect(text).toContain("50 USD of crew spend × $0.2 = $10.00");
93 expect(text).toContain("$68.40");
94 });
95
96 test("[ and ] step a month; a month not YYYY-MM is refused", async () => {
97 const back = await drive(admin, [...open, "m", "2026-11", "\r", "["]);
98 expect(back).toContain("2026-10 (UTC)");
99 expect(back).toContain("$68.40");
100 const bad = await drive(admin, [...open, "m", "2026-13", "\r"]);
101 expect(bad).toContain("month is YYYY-MM");
102 expect(stepMonth("2026-12", 1)).toBe("2027-01");
103 expect(stepMonth("2026-01", -1)).toBe("2025-12");
104 expect(stepMonth("2000-01", -1)).toBeUndefined();
105 });
106
107 test("a non-admin and a guest are offered nothing and see no lines", async () => {
108 expect(await drive(admin, [":", "Statement"])).toContain("— Statement");
109 expect(await drive(bob, [":", "Statement"])).not.toContain("— Statement");
110 const ctx = {} as never;
111 expect(statementCommands({ app, user: bob } as never, ctx, notes)).toEqual([]);
112 expect(statementCommands({ app, user: undefined } as never, ctx, notes)).toEqual([]);
113 for (const who of [bob, undefined]) {
114 const text = await drive(who, ["m", "2026-10", "\r"], true);
115 expect(text).not.toContain("Total");
116 expect(text).not.toContain("$68.40");
117 expect(text).not.toContain("Awake time");
118 }
119 });
120 });
test/why-now.test.ts deleted −28
@@ -1,28 +0,0 @@
1 /** Retired 2026-11-09 with the pipeline-first direction: the gate no longer asks (`scripts/gate.ts`); the pure rule keeps its tests until the module goes. */
2 import { expect, test } from "bun:test";
3 import { featuresAdded, whyNowProblems } from "../src/kernel/why_now.ts";
4
5 const add = (path: string) => ({ status: "A", path });
6
7 test("a new action, page or route is a feature; edits, tests and other files are not", () => {
8 expect(featuresAdded([add("src/actions/x.ts"), add("src/web/pages/y.ts"), add("src/http/api/z.ts")])).toHaveLength(3);
9 expect(featuresAdded([add("src/http/mcp/tools/x.ts"), add("src/tui/app/screen.ts"), add("src/http/api/v1/y.ts")])).toHaveLength(3);
10 expect(featuresAdded([{ status: "M", path: "src/actions/x.ts" }, add("test/x.test.ts"), add("src/core/x.ts")])).toEqual([]);
11 });
12
13 test("a feature with no answer to the direction is refused, naming the rule and the file", () => {
14 const [p] = whyNowProblems([add("src/actions/x.ts")], ["Add x\n\nIt does x."]);
15 expect(p).toContain("say why it goes ahead of the pipeline");
16 expect(p).toContain("src/actions/x.ts");
17 });
18
19 test("a Why now line or a Pipeline line answers it; a thin reason does not", () => {
20 const changes = [add("src/actions/x.ts")];
21 expect(whyNowProblems(changes, ["Add x\n\nWhy now: the owner asked for it in #1234 this week"])).toEqual([]);
22 expect(whyNowProblems(changes, ["Add x\n\nPipeline: #6534"])).toEqual([]);
23 expect(whyNowProblems(changes, ["Add x\n\nWhy now: soon"])).toHaveLength(1);
24 });
25
26 test("a pull that adds no feature is never asked", () => {
27 expect(whyNowProblems([add("test/a.test.ts")], ["Fix a"])).toEqual([]);
28 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs