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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/508-revenue-awake-time-storage-and-crew-spen → main 8 commits fork
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, | ||
| 64 | 64 | 40 h of cloud at $0.05, 12 GB-months at $0.02 and $57.10 of crew spend |
| 65 | 65 | cost $359.34. |
| 66 | 66 | |
| 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 | ||
| 67 | 80 | ## The order (what waits for what) |
| 68 | 81 | |
| 69 | 82 | | 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. | ||
| 48 | 48 | | `cloudHourUsd` | US dollars per awake-hour on cloud | 0–1,000,000.00 | |
| 49 | 49 | | `storageGbMonthUsd` | US dollars per GB-month kept (decimal GB) | 0–1,000,000.00 | |
| 50 | 50 | |
| 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 | ||
| 51 | 57 | Prices take at most two decimals and cannot be negative. Anything else is |
| 52 | 58 | refused as `bad_price` (or `bad_slots`) and nothing changes. |
| 53 | 59 |
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 { | ||
| 119 | 119 | ownedSlots?: unknown; |
| 120 | 120 | cloudHourUsd?: unknown; |
| 121 | 121 | 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; | |
| 122 | 126 | } |
| 123 | 127 | |
| 124 | 128 | 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 | ||
| 126 | 130 | const ownedMonthCents = usd(input.ownedMonthUsd, before.ownedMonthCents); |
| 127 | 131 | const cloudHourCents = usd(input.cloudHourUsd, before.cloudHourCents); |
| 128 | 132 | 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 | ) | |
| 130 | 142 | return err("bad_price"); |
| 131 | 143 | const ownedSlots = input.ownedSlots === undefined ? before.ownedSlots : parseSlots(input.ownedSlots); |
| 132 | 144 | if (ownedSlots === undefined) return err("bad_slots"); |
| 133 | return ok({ ownedMonthCents, ownedSlots, cloudHourCents, storageGbMonthCents }); | |
| 145 | return ok({ ownedMonthCents, ownedSlots, cloudHourCents, storageGbMonthCents, awakePriceHourCents, storagePriceGbMonthCents }); | |
| 134 | 146 | } |
| 135 | 147 | |
| 136 | 148 | /** |
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 | ||
| 159 | 159 | export { route as getApiV1ReposOwnerNameRuns } from "./getApiV1ReposOwnerNameRuns.ts"; |
| 160 | 160 | export { route as getApiV1ReposOwnerNameRunsId } from "./getApiV1ReposOwnerNameRunsId.ts"; |
| 161 | 161 | export { route as getApiV1ReposOwnerNameRunsIdLog } from "./getApiV1ReposOwnerNameRunsIdLog.ts"; |
| 162 | export { route as getApiV1ReposOwnerNameStatement } from "./getApiV1ReposOwnerNameStatement.ts"; | |
| 162 | 163 | export { route as getApiV1ReposOwnerNameTags } from "./getApiV1ReposOwnerNameTags.ts"; |
| 163 | 164 | export { route as getApiV1ReposOwnerNameUpstream } from "./getApiV1ReposOwnerNameUpstream.ts"; |
| 164 | 165 | 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"; | ||
| 3 | 3 | export const route: Route = { |
| 4 | 4 | method: "put", |
| 5 | 5 | 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)", | |
| 7 | 7 | auth: true, |
| 8 | 8 | body: { |
| 9 | 9 | position: "whole number 0-100", |
| @@ -13,6 +13,8 @@ export const route: Route = { | ||
| 13 | 13 | ownedSlots: "whole number of app-slots the owned box holds, 0-10000", |
| 14 | 14 | cloudHourUsd: "US dollars per awake-hour on cloud, 0-1000000, at most two decimals", |
| 15 | 15 | 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", | |
| 16 | 18 | }, |
| 17 | 19 | refusals: { |
| 18 | 20 | 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 { | ||
| 170 | 170 | * - `ownedSlots`: how many apps the box holds awake at once (app-slots); |
| 171 | 171 | * - `cloudHourCents`: US cents per awake-hour on a cloud; |
| 172 | 172 | * - `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. | |
| 173 | 179 | */ |
| 174 | 180 | export interface Prices { |
| 175 | 181 | readonly ownedMonthCents: number; |
| 176 | 182 | readonly ownedSlots: number; |
| 177 | 183 | readonly cloudHourCents: number; |
| 178 | 184 | readonly storageGbMonthCents: number; |
| 185 | readonly awakePriceHourCents: number; | |
| 186 | readonly storagePriceGbMonthCents: number; | |
| 179 | 187 | } |
| 180 | 188 | |
| 181 | 189 | export type PriceKey = keyof Prices; |
| @@ -184,6 +192,8 @@ export const PRICE_KEYS: readonly PriceKey[] = [ | ||
| 184 | 192 | "ownedSlots", |
| 185 | 193 | "cloudHourCents", |
| 186 | 194 | "storageGbMonthCents", |
| 195 | "awakePriceHourCents", | |
| 196 | "storagePriceGbMonthCents", | |
| 187 | 197 | ]; |
| 188 | 198 | |
| 189 | 199 | /** Nothing priced until the operator says: an unpriced forge costs $0. */ |
| @@ -192,6 +202,8 @@ export const PRICES_DEFAULT: Prices = { | ||
| 192 | 202 | ownedSlots: 0, |
| 193 | 203 | cloudHourCents: 0, |
| 194 | 204 | storageGbMonthCents: 0, |
| 205 | awakePriceHourCents: 0, | |
| 206 | storagePriceGbMonthCents: 0, | |
| 195 | 207 | }; |
| 196 | 208 | |
| 197 | 209 | /** $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"; | ||
| 353 | 353 | import { followingRoutes } from "./api/following.ts"; |
| 354 | 354 | import { labelsRoutes } from "./api/labels.ts"; |
| 355 | 355 | import { dataRoutes } from "./api/data.ts"; |
| 356 | import { statementRoutes } from "./api/statement.ts"; | |
| 356 | 357 | import { visionRoutes } from "./api/vision.ts"; |
| 357 | 358 | import { businessPlanRoutes } from "./api/business_plan.ts"; |
| 358 | 359 | import { tokenRoutes } from "./api/tokens.ts"; |
| @@ -2095,6 +2096,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2095 | 2096 | labelsRoutes(router); |
| 2096 | 2097 | visionRoutes(router); |
| 2097 | 2098 | dataRoutes(router); |
| 2099 | statementRoutes(router); | |
| 2098 | 2100 | businessPlanRoutes(router); |
| 2099 | 2101 | router.add( |
| 2100 | 2102 | "PUT", |
src/http/api/economics.ts +5
| @@ -28,6 +28,9 @@ export function wire(e: EconomicsView) { | ||
| 28 | 28 | ownedSlots: e.prices.ownedSlots, |
| 29 | 29 | cloudHourUsd: e.prices.cloudHourCents / 100, |
| 30 | 30 | 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, | |
| 31 | 34 | }, |
| 32 | 35 | month: e.month, |
| 33 | 36 | }; |
| @@ -54,6 +57,8 @@ export function economicsRoutes(router: Router<Handler>): void { | ||
| 54 | 57 | ownedSlots: input.ownedSlots, |
| 55 | 58 | cloudHourUsd: input.cloudHourUsd, |
| 56 | 59 | storageGbMonthUsd: input.storageGbMonthUsd, |
| 60 | awakePriceHourUsd: input.awakePriceHourUsd, | |
| 61 | storagePriceGbMonthUsd: input.storagePriceGbMonthUsd, | |
| 57 | 62 | }); |
| 58 | 63 | if (changed.ok) return json(wire(changed.value)); |
| 59 | 64 | 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"; | ||
| 6 | 6 | import { good, bad, type Tool } from "../shared.ts"; |
| 7 | 7 | |
| 8 | 8 | /** 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; | |
| 10 | 10 | type Field = (typeof FIELDS)[number]; |
| 11 | 11 | |
| 12 | 12 | export const tool: Tool = { |
| 13 | 13 | 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`", | |
| 15 | 15 | description: "Read the profit slider, its policy, placement, the prices and the month's cost, or set them (admin).", |
| 16 | 16 | inputSchema: { |
| 17 | 17 | type: "object", |
| @@ -23,6 +23,8 @@ export const tool: Tool = { | ||
| 23 | 23 | ownedSlots: { type: "integer", minimum: 0 }, |
| 24 | 24 | cloudHourUsd: { type: "number", minimum: 0 }, |
| 25 | 25 | storageGbMonthUsd: { type: "number", minimum: 0 }, |
| 26 | awakePriceHourUsd: { type: "number", minimum: 0 }, | |
| 27 | storagePriceGbMonthUsd: { type: "number", minimum: 0 }, | |
| 26 | 28 | dryRun: { type: "boolean" }, |
| 27 | 29 | }, |
| 28 | 30 | }, |
| @@ -66,7 +68,7 @@ function checkOnly(given: Partial<Record<Field, unknown>>): Exclude<EconomicsErr | ||
| 66 | 68 | if (given.position !== undefined && parsePosition(given.position) === undefined) return "bad_position"; |
| 67 | 69 | if (given.marginPct !== undefined && parseMarginPct(given.marginPct) === undefined) return "bad_margin"; |
| 68 | 70 | 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) | |
| 70 | 72 | if (given[f] !== undefined && parseUsdCents(given[f]) === undefined) return "bad_price"; |
| 71 | 73 | if (given.ownedSlots !== undefined && parseSlots(given.ownedSlots) === undefined) return "bad_slots"; |
| 72 | 74 | return undefined; |
src/http/server.ts +2
| @@ -493,6 +493,7 @@ import { startFeatureSchedule } from "../apps/feature_schedule.ts"; | ||
| 493 | 493 | import { startPullSchedule } from "../apps/pull_schedule.ts"; |
| 494 | 494 | import { startGithubPull } from "../perf/github_traffic.ts"; |
| 495 | 495 | import { appSourcesPage } from "../web/pages/sources.ts"; |
| 496 | import { statementPage } from "../web/pages/statement.ts"; | |
| 496 | 497 | import { createViewWriter } from "../perf/views_store.ts"; |
| 497 | 498 | import { createReadWriter, readOf } from "../perf/reads.ts"; |
| 498 | 499 | import { parseServerTiming } from "../perf/record.ts"; |
| @@ -1457,6 +1458,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1457 | 1458 | ); |
| 1458 | 1459 | router.add("GET", "/:owner/:name/settings/app", appSettingsPage); |
| 1459 | 1460 | router.add("GET", "/:owner/:name/settings/app/traffic", appSourcesPage); |
| 1461 | router.add("GET", "/:owner/:name/settings/app/statement", statementPage); | |
| 1460 | 1462 | router.add("GET", "/:owner/:name/settings/machines", machinesSettingsPage); |
| 1461 | 1463 | // The shell page; an upgrade at the same path is the socket (`machines/shell.ts`). |
| 1462 | 1464 | 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 | ||
| 44 | 44 | ownedSlots: { key: "economics.owned_slots", min: 0, max: SLOTS_MAX, fallback: PRICES_DEFAULT.ownedSlots }, |
| 45 | 45 | cloudHourCents: { key: "economics.cloud_hour_cents", min: 0, max: PRICE_MAX_CENTS, fallback: PRICES_DEFAULT.cloudHourCents }, |
| 46 | 46 | 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 }, | |
| 47 | 49 | }; |
| 48 | 50 | |
| 49 | 51 | const KEYS = Object.keys(STORE) as EconomicsKey[]; |
| @@ -77,13 +79,15 @@ export function setEconomics(db: Database, next: Economics, actorId: string): vo | ||
| 77 | 79 | } |
| 78 | 80 | } |
| 79 | 81 | |
| 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. */ | |
| 81 | 83 | export function getPrices(db: Database): Prices { |
| 82 | 84 | return { |
| 83 | 85 | ownedMonthCents: stored(db, "ownedMonthCents"), |
| 84 | 86 | ownedSlots: stored(db, "ownedSlots"), |
| 85 | 87 | cloudHourCents: stored(db, "cloudHourCents"), |
| 86 | 88 | storageGbMonthCents: stored(db, "storageGbMonthCents"), |
| 89 | awakePriceHourCents: stored(db, "awakePriceHourCents"), | |
| 90 | storagePriceGbMonthCents: stored(db, "storagePriceGbMonthCents"), | |
| 87 | 91 | }; |
| 88 | 92 | } |
| 89 | 93 |
src/tui/screens/economics.ts +2
| @@ -107,6 +107,8 @@ const LEVERS: readonly Lever[] = [ | ||
| 107 | 107 | }, |
| 108 | 108 | price("cloudHourUsd", (e) => e.prices.cloudHourCents, "$/awake-hour on cloud"), |
| 109 | 109 | 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"), | |
| 110 | 112 | ]; |
| 111 | 113 | |
| 112 | 114 | /** "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"; | ||
| 149 | 149 | import { appEnvCommands } from "./appenv.ts"; |
| 150 | 150 | import { autonomyCommands } from "./autonomy.ts"; |
| 151 | 151 | import { trafficCommands } from "./trafficview.ts"; |
| 152 | import { statementCommands } from "./statement.ts"; | |
| 152 | 153 | import { adapterCommands } from "./adapter.ts"; |
| 153 | 154 | import { atlasCommands } from "./atlas.ts"; |
| 154 | 155 | import { externalEditor, editorHint, hasExternalEditor } from "./external.ts"; |
| @@ -1576,6 +1577,7 @@ export function workspaceScreen( | ||
| 1576 | 1577 | ...appEnvCommands(f, ctx, repo), |
| 1577 | 1578 | ...autonomyCommands(f, ctx, repo), |
| 1578 | 1579 | ...trafficCommands(f, ctx, repo), |
| 1580 | ...statementCommands(f, ctx, repo), | |
| 1579 | 1581 | ...adapterCommands(f, ctx, repo), |
| 1580 | 1582 | ...atlasCommands(f, ctx, repo), |
| 1581 | 1583 | ], |
src/web/pages/economics.ts +9 −1
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * label set here, and whether a cloud body takes bursts — without one the |
| 7 | 7 | * page says "no cloud body: bursts queue" (#509). |
| 8 | 8 | * 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`). | |
| 10 | 11 | * Server-rendered; the forms are same-origin POSTs (`readForm`); a field a |
| 11 | 12 | * form does not carry keeps its value. 50 is today's behaviour. |
| 12 | 13 | * Anyone else gets the not-found page. |
| @@ -127,6 +128,11 @@ export function economicsPage(ctx: Ctx): Response { | ||
| 127 | 128 | ${priceField("storageGbMonthUsd", "Storage ($/GB-month)", p.storageGbMonthCents)} |
| 128 | 129 | ${btn("Save", { size: "sm", variant: "primary", label: "Save prices" })} |
| 129 | 130 | </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> | |
| 130 | 136 | ${perSlot === undefined |
| 131 | 137 | ? null |
| 132 | 138 | : html`<dl class="stats" data-prices> |
| @@ -176,6 +182,8 @@ export async function economicsSet(ctx: Ctx): Promise<Response> { | ||
| 176 | 182 | ownedSlots: given("ownedSlots"), |
| 177 | 183 | cloudHourUsd: given("cloudHourUsd"), |
| 178 | 184 | storageGbMonthUsd: given("storageGbMonthUsd"), |
| 185 | awakePriceHourUsd: given("awakePriceHourUsd"), | |
| 186 | storagePriceGbMonthUsd: given("storagePriceGbMonthUsd"), | |
| 179 | 187 | }); |
| 180 | 188 | if (!changed.ok && changed.error === "not_found") return notFoundPage(ctx); |
| 181 | 189 | return flashTo( |
src/web/pages/settings.ts +7 −1
| @@ -800,7 +800,12 @@ function appSettingsView( | ||
| 800 | 800 | </div> |
| 801 | 801 | </form> |
| 802 | 802 | </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>`; | |
| 804 | 809 | const domains = timePhase(ctx.request, "domains", () => |
| 805 | 810 | listAppDomains(ctx.app, ctx.auth.user, repo), |
| 806 | 811 | ); |
| @@ -810,6 +815,7 @@ function appSettingsView( | ||
| 810 | 815 | return html`${form} |
| 811 | 816 | ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)} |
| 812 | 817 | <div class="mt">${appVisitorsView(vitals)}</div> |
| 818 | <div class="mt">${statement}</div> | |
| 813 | 819 | <div class="mt"> |
| 814 | 820 | ${appEnvView(base, listAppEnvNames(ctx.app.db, record.id))} |
| 815 | 821 | </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 */ ` | ||
| 227 | 227 | } |
| 228 | 228 | |
| 229 | 229 | @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; } | |
| 231 | 231 | .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; } | |
| 232 | 234 | } |
| 233 | 235 | } |
| 234 | 236 | `; |
test/cost.test.ts +2
| @@ -12,6 +12,8 @@ const prices: Prices = { | ||
| 12 | 12 | ownedSlots: 40, |
| 13 | 13 | cloudHourCents: 5, // $0.05 an awake-hour |
| 14 | 14 | storageGbMonthCents: 2, // $0.02 a GB-month |
| 15 | awakePriceHourCents: 0, // list prices do not enter cost | |
| 16 | storagePriceGbMonthCents: 0, | |
| 15 | 17 | }; |
| 16 | 18 | const november = { from: "2026-11-01T00:00:00.000Z", to: "2026-12-01T00:00:00.000Z" }; |
| 17 | 19 |
test/economics.test.ts +11 −3
| @@ -177,7 +177,7 @@ describe("economics", () => { | ||
| 177 | 177 | |
| 178 | 178 | 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 () => { |
| 179 | 179 | 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 }); | |
| 181 | 181 | const prices = { ownedMonthUsd: "300", ownedSlots: "40", cloudHourUsd: "0.05", storageGbMonthUsd: "0.02" }; |
| 182 | 182 | expect((await post(cookie, prices, "https://evil.example")).status).toBe(403); |
| 183 | 183 | expect(getPrices(app.db).ownedMonthCents).toBe(0); |
| @@ -185,7 +185,7 @@ describe("economics", () => { | ||
| 185 | 185 | // The prices form carries no position: it keeps what stands. |
| 186 | 186 | const before = getEconomics(app.db); |
| 187 | 187 | 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 }); | |
| 189 | 189 | expect(getEconomics(app.db)).toEqual(before); |
| 190 | 190 | expect(economicsChanges(app.db).filter((c) => c.key === "ownedMonthCents").map((c) => [c.from, c.to])).toEqual([["0", "30000"]]); |
| 191 | 191 | |
| @@ -212,7 +212,7 @@ describe("economics", () => { | ||
| 212 | 212 | prices: Record<string, number>; |
| 213 | 213 | month: { from: string; to: string; cost: { owned: number; cloud: number; total: number } }; |
| 214 | 214 | }; |
| 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 }); | |
| 216 | 216 | expect(got.month.cost.cloud).toBe(0); |
| 217 | 217 | expect(got.month.cost.owned).toBeGreaterThan(0); |
| 218 | 218 | expect(got.month.cost.owned).toBeLessThanOrEqual(300); |
| @@ -224,6 +224,14 @@ describe("economics", () => { | ||
| 224 | 224 | expect(((await (await api(token, "PUT", { ownedSlots: -1 })).json()) as { error: string }).error).toBe("bad_slots"); |
| 225 | 225 | expect((await api(token, "PUT", { cloudHourUsd: 0.1 })).status).toBe(200); |
| 226 | 226 | 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"'); | |
| 227 | 235 | }); |
| 228 | 236 | |
| 229 | 237 | 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", () => { | ||
| 110 | 110 | |
| 111 | 111 | const set = await call(admin, { ownedMonthUsd: 300, ownedSlots: 40, cloudHourUsd: 0.05, storageGbMonthUsd: 0.02, dryRun: false }); |
| 112 | 112 | 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 }); | |
| 114 | 114 | 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 }); | |
| 116 | 116 | expect(read.month.cost.owned).toBeGreaterThan(0); |
| 117 | 117 | expect(read.month.cost.owned).toBeLessThanOrEqual(300); |
| 118 | 118 | }); |
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 | }); |