openvibers/openvibe.billing Commits
Record usage readings under billing.usage.record (#6)
Changes
@@@ -48,7 +48,7 @@ webhooks land here and nowhere else.- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)- the payment providers whose secrets are configured (none in production today)--- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball++- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)@@@ -57,7 +57,8 @@ webhooks land here and nowhere else.Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,--`billing.entitlement.check` and `billing.ledger.admin`. Callers today are Live (after the cutover),++`billing.entitlement.check`, `billing.ledger.admin` and `billing.usage.record` (usage readings; released in++Contracts v0.85.0). Callers today are Live (after the cutover),Tips (`billing.intent.create`, `billing.transfer.create`) and VIP (`billing.intent.create`,`billing.subscription.manage`, `billing.entitlement.check`).@@@ -150,6 +151,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing || `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, payable_value_cents}` || `GET /api/v1/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` |++| `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing; accepted while frozen); idempotent by the reading's `idempotency_key`: same reading → 200 with the stored row (`Idempotent-Replayed: true`), a different one → 409 `billing.usage_key_reused`; no `Idempotency-Key` header |++| `GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor=` | `billing.ledger.admin` | readings newest first, `from` ≤ `at` < `to`, cursor paging (`limit` ≤ 500) || `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks || `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation || `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report |@@@ -160,11 +163,15 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` || `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |--The capabilities and the service manifest are released in openvibe-contracts (v0.8.0;--`billing.staff.action` since v0.17.0); the drafts they came from stay in--[docs/capabilities-proposal/](docs/capabilities-proposal/) and--[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with--contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).++The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;++`billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is++`platform.usage-sample@1`; output is `billing.usage-record-result@1` =++`{ record: { id, idempotency_key, project|null, subject|null, service, at, received_at, principal, reading } }`,++201 when stored and 200 with `Idempotent-Replayed: true` on a replay; `GET /api/v1/usage` needs++`billing.ledger.admin` and returns `{ records, next_cursor }`. Network grants `billing.usage.record`++(audience `openvibe.billing`) to each producer service in `server/identity/principals.js`, and no service++holds it yet: the first producer gets the grant in the same change that wires its calls. Grants are matched++with contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).### Per-person limitsnnew file mode 100644@@@ -0,0 +1,217 @@++# Cutover runbook — PR #6 (OpenVibe.Billing usage readings)++++PR #6 adds one empty table, `usage_records`, with four indexes, and two HTTP routes:++`POST /api/v1/usage` (idempotent by the reading's own `idempotency_key`) and++`GET /api/v1/usage` (filters by project, subject, service and `[from, to)`,++cursor paging). Nothing existing is renamed, dropped or altered; no ledger column is++the result of this PR; no provider webhook changes; no auth changes. Reads of++`usage_records` are not a money movement.++++## What the migration does++++`migrations/0002_usage.sql` (`phase: expand`) creates the new table only:++++```sql++CREATE TABLE usage_records (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,++ project text COLLATE "C",++ subject text COLLATE "C",++ service text COLLATE "C" NOT NULL,++ at timestamptz NOT NULL,++ reading jsonb NOT NULL,++ reading_hash text COLLATE "C" NOT NULL,++ principal text COLLATE "C" NOT NULL,++ received_at timestamptz NOT NULL++);++CREATE INDEX usage_records_at ON usage_records (at, id);++CREATE INDEX usage_records_project ON usage_records (project, at, id) WHERE project IS NOT NULL;++CREATE INDEX usage_records_subject ON usage_records (subject, at, id) WHERE subject IS NOT NULL;++CREATE INDEX usage_records_service ON usage_records (service, at, id);++```++++The runner is `openvibe-sdk/db`'s `migrate()`: it locks `ov_migrations`, runs++the file inside one transaction (no `no-transaction` header is set, so it is++transactional), records the sha256 of the file's content, and refuses any++later edit of an applied migration. No `ov_migrations` row exists yet for++`0002_usage.sql` on the current production ledger; the runner will add one.++++## What is not changed++++- No existing table is renamed, dropped or altered. The migration is additive.++- No index on an existing table is created, dropped or rebuilt.++- No money-bearing column, account kind, transaction type or ledger enum moves.++- No provider webhook URL moves (the PowerChat webhook, the cutover's step 6,++ is a separate change governed by `docs/live-cutover.md`; this PR is independent).++- No `BILLING_AUTHORITY` change. The cutover's switch flips in another runbook.++- No service-token, capability or grant change. `billing.usage.record` is++ declared in `docs/capabilities-proposal/billing.usage.record.json` but is not++ yet in OpenVibe.Contracts' released capability manifests (v0.84.0); until++ Contracts releases it, the route is reachable only to service tokens the++ operator grants by hand (none today). No call site anywhere in the network++ POSTs to `/api/v1/usage` on the day of this PR's merge.++++## Prerequisites++++1. **Snapshot the ledger.** The new migration only adds an empty table, but++ the pre-merge rule for any data change is a snapshot the operator can roll++ back to. Force the weekly logical dump for the Billing database++ (`pg_dump -Fc` lives at `/var/backups/openvibe/billing/<stamp>/billing.dump`,++ owned `root:root`, 0600; `OpenVibe.Host/docs/backups.md:108-113`):++ ```bash++ sudo ovhost backup --all --logical++ ```++ The recipe verifies `pg_stat_archiver.failed_count` has not increased,++ reads the per-service row counts and writes the dump. Note the stamp++ printed at the end of the run; the rollback section uses it.++2. **Confirm the unit is healthy.** `sudo systemctl status openvibe-billing`++ answers active (running) and `/api/ready` returns 200 with `db: "ok"`.++3. **Confirm no freeze in flight.** `node scripts/freeze.js state` (run from++ `/opt/openvibe.billing`) returns `{"on": false}`. If it is on, find and++ resolve the freeze first; this PR does not interact with the freeze but the++ snapshot step must be taken when writes are healthy.++++## Rehearsal on a snapshot++++Run this on a copy of the production data, **never on the production ledger**;++the rehearsal is green when `ds/deploy/rehearsals/billing-usage-records-001.json`++on the harness says `{"ok": true}`. This section is the rehearsal.++++1. Restore the snapshot taken in prerequisites into a scratch database role++ the operator stands up for the rehearsal (a separate Postgres role on the++ same cluster, isolated from `ov_billing`). `openvibe-sdk/db`'s `migrate()`++ is idempotent across roles; the rehearsal role has its own `ov_migrations`.++2. Run the migration runner against the rehearsal role, with the same++ `migrations/` directory the deployed release ships:++ ```bash++ DATABASE_URL=<rehearsal-url> DATABASE_DIRECT_URL=<rehearsal-direct-url> \++ node -e 'const { openDb } = require("./server/db"); openDb({ db: { url: process.env.DATABASE_URL, directUrl: process.env.DATABASE_DIRECT_URL }, isProduction: false, log: { info: console.log, warn: console.warn, error: console.error } }).then((db) => db.close())'++ ```++ The runner is silent on a clean ledger; a successful run adds one row to++ `ov_migrations` with `id = '0002'`, `name = 'usage'`, `phase = 'expand'`.++3. Verify the schema is exactly what the PR added:++ ```sql++ \d usage_records++ SELECT indexname FROM pg_indexes WHERE tablename = 'usage_records' ORDER BY indexname;++ SELECT id, name, phase FROM ov_migrations WHERE id = '0002';++ ```++ The table has the nine columns listed above; `usage_records.id` is a++ `bigint GENERATED ALWAYS AS IDENTITY`; the four indexes are present;++ `ov_migrations` has one row for `0002_usage`.++4. Boot the rehearsal instance against the rehearsal database:++ `NODE_ENV=production PORT=4601 DATABASE_URL=<rehearsal-url>++ DATABASE_DIRECT_URL=<rehearsal-direct-url> node server/index.js`.++ `/api/ready` returns 200; the boot log prints `migrations: applied 0002_usage++ (expand, NNms)` once and not again on a second boot (the checksum is++ recorded).++5. Run the focused suite on the rehearsal instance:++ `ov test test/usage.test.js test/dependency-pins.test.js`. Both pass.++ No existing test regresses (`test/usage.test.js` is the only test the PR++ adds; `test/dependency-pins.test.js` is the README-pin guard the PR also++ adds).++6. Exercise the routes by hand:++ ```bash++ curl -sS -X POST http://127.0.0.1:4601/api/v1/usage \++ -H "Authorization: Bearer $BILLING_USAGE_TOKEN" \++ -H "Content-Type: application/json" \++ -d '{"id":"use-rhb-1","idempotency_key":"rehearsal:0002:1","service":"media","operation":"deliver","quantity":1,"unit":"GiB","at":"2026-10-03T12:00:00Z","source":"rehearsal"}'++ ```++ A valid `platform.usage-sample@1` body returns 201 with `record` populated;++ the same body again returns 200 with `Idempotent-Replayed: true` and the++ stored row; a body with a different reading under the same key returns 409++ `billing.usage_key_reused`. `GET /api/v1/usage?project=...` lists the++ row newest-first. Reject 422 for an invalid reading; reject 401 without a++ token; reject 403 with a token that lacks `billing.usage.record`.++7. Write the green marker:++ ```bash++ mkdir -p ~/openvibe/agents/ds/deploy/rehearsals++ printf '{"ok": true}\n' > ~/openvibe/agents/ds/deploy/rehearsals/billing-usage-records-001.json++ ```++ The harness reads this file; the deploy is gated on `{"ok": true}`.++++## Cutover++++The merge is automated once the rehearsal is green. The deploy is the standard++`openvibe-billing` deploy:++++1. **Merge the PR.** The PR description carries the cutover manifest below;++ the harness's `data` risk class waits for `ds/deploy/rehearsals/billing-usage-records-001.json`++ to be `{"ok": true}` before letting the deploy start.++2. **Deploy.** `sudo ovhost deploy billing "usage readings (PR #6) — additive++ migration 0002_usage"`. The `git-checkout` recipe fast-forwards++ `/opt/openvibe.billing`, installs on a lockfile change (this PR bumps++ `openvibe-contracts` v0.76.0 → v0.84.0 and `openvibe-shared` v2.3.1 →++ v2.5.0), restarts `openvibe-billing.service`, waits for `/api/ready`.++3. **The boot applies the migration.** The systemd unit runs `node++ server/index.js`; `server/db.js`'s `openDb` runs `owner.migrate({ dir:++ MIGRATIONS, log })` against `DATABASE_DIRECT_URL`. The runner takes the++ `ov_migrations` advisory lock, applies `0002_usage.sql` in one transaction,++ records the checksum, releases the lock. The journal line is++ `migrations: applied 0002_usage (expand, NNms)`. The boot then opens the++ PgBouncer-backed serving handle and the unit is healthy.++4. **Verify on production.**++ - `sudo ovhost access run openvibe-ovh show billing` reads clean: the unit++ is active (running), `/api/ready` is 200 with `db: "ok"`, the boot log++ mentions `0002_usage` exactly once.++ - `psql -d ov_billing -c '\d usage_records'` lists the nine columns and the++ four indexes; `ov_migrations` has the `0002_usage` row.++ - `curl -sS http://127.0.0.1:4600/api/v1/usage` with a token that has++ `billing.usage.record` returns `{ records: [], next_cursor: null }`.++ - `node scripts/reconcile.js` (run from `/opt/openvibe.billing`) prints++ `accounts: N, transactions: N, ledger_entries: N` with the same totals++ as before the deploy. Nothing money-bearing moved.++++The cutover is done when step 4's four lines are clean.++++## Rollback++++Because the migration is additive and the table is empty on the day of the++merge, the rollback is one DDL away. Pick the path that matches the elapsed++time.++++- **No usage row has been written yet (the common case the day of the merge).**++ Roll back the deploy and drop the empty table:++ 1. `sudo ovhost rollback billing` — the recipe takes the unit to the++ previous release (`openvibe-contracts` v0.76.0, `openvibe-shared` v2.3.1,++ no `0002_usage.sql`). The previous release's boot refuses to apply a++ migration it does not know about (the file is absent) and the runner is++ silent on a ledger that already has the `ov_migrations` row from the bad++ release. The unit is healthy on the old code; `/api/ready` is 200.++ 2. `psql -d ov_billing -c 'DROP INDEX IF EXISTS usage_records_at; DROP INDEX IF EXISTS usage_records_project; DROP INDEX IF EXISTS usage_records_subject; DROP INDEX IF EXISTS usage_records_service; DROP TABLE IF EXISTS usage_records; DELETE FROM ov_migrations WHERE id = '\''0002'\'';`++ The rollback unit can also run a `drover` step that wipes the table and++ the `ov_migrations` row in one transaction; either form is fine.++ 3. Verify: `\d usage_records` says "Did not find any relation named++ 'usage_records'"; `ov_migrations` has no `0002` row; `ov test++ test/usage.test.js` is skipped (the previous release has no usage++ code); the focused suite that *did* exist on the previous release++ passes.++- **Usage rows have been written since the merge.** A `DELETE FROM++ usage_records` is destructive (the readings are first-party service++ telemetry; nothing else references them in the journal). If a row was++ written, decide with the owner whether to keep the table (preferred: the++ rows are additive and the table does not block the rollback) or to wipe++ them. If the call is to wipe, run `DELETE FROM usage_records; DELETE FROM++ ov_migrations WHERE id = '0002';` in one transaction. The deploy rollback++ then completes as above. The snapshot taken before the merge++ (`/var/backups/openvibe/billing/<stamp>/billing.dump`) is the last resort++ if a partial state cannot be reasoned about; restore it into a scratch role,++ compare `usage_records` row counts, then act.++- **The deploy itself is broken on the new release.** `sudo ovhost rollback++ billing` takes the unit back to the previous release in one step; the++ migration runner refuses to run `0002_usage.sql` again (the file is not in++ the previous release) and the unit boots on the old code.++++## PR description++++The PR carries this block verbatim:++++````++```cutover++{"runbook": "docs/cutover-pr-openvibe-billing-6.md", "rehearsal": "billing-usage-records-001"}++```++````++++The harness reads the `runbook` and `rehearsal` from this block, refuses the++deploy until `ds/deploy/rehearsals/billing-usage-records-001.json` is++`{"ok": true}`, and otherwise proceeds with the standard `git-checkout`++recipe.\\ No newline at end of filennew file mode 100644@@@ -0,0 +1,24 @@++-- phase: expand++-- Usage readings (platform.usage-sample@1, openvibe-contracts v0.84.0; plan T5 lane F): stored and reported, never++-- charged. A reading's own idempotency_key dedupes retries across every caller; the indexed fields are columns and++-- the whole reading, exactly as accepted, is `reading`. `at` is the reading's time, normalised to UTC milliseconds++-- (the cursor's precision); the original string stays in `reading`. reading_hash is the sha256 of the reading's++-- canonical JSON (sorted keys), compared on a replay. Additive only: no existing table changes.++++CREATE TABLE usage_records (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ idempotency_key text COLLATE "C" NOT NULL UNIQUE,++ project text COLLATE "C",++ subject text COLLATE "C",++ service text COLLATE "C" NOT NULL,++ at timestamptz NOT NULL,++ reading jsonb NOT NULL,++ reading_hash text COLLATE "C" NOT NULL,++ principal text COLLATE "C" NOT NULL,++ received_at timestamptz NOT NULL++);++++CREATE INDEX usage_records_at ON usage_records (at, id);++CREATE INDEX usage_records_project ON usage_records (project, at, id) WHERE project IS NOT NULL;++CREATE INDEX usage_records_subject ON usage_records (subject, at, id) WHERE subject IS NOT NULL;++CREATE INDEX usage_records_service ON usage_records (service, at, id);@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0","pg": "^8.23.0"@@@ -1059,9 +1059,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.76.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",++ "version": "0.85.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",++ "integrity": "sha512-Xo54hG4kiL1kOTcxwbnH8cmt2zZhvp/gqCs+egPBLLSBg+SjVuhXIPaX8EJPQux5x1YxwO7wjDDywcHvIaGoqA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0","pg": "^8.23.0"@@@ -25,6 +25,7 @@ const admin = require('../ops/admin');const providers = require('../providers');const { reconcile } = require('../reconcile');const external = require('../ops/external');++const usage = require('../ops/usage');const CAP = {intent: 'billing.intent.create',@@@ -35,6 +36,7 @@ const CAP = {subscription: 'billing.subscription.manage',entitlement: 'billing.entitlement.check',admin: 'billing.ledger.admin',++ usage: 'billing.usage.record',};function v1Router({ ctx, auth, adapters, limits }) {@@@ -245,6 +247,16 @@ function v1Router({ ctx, auth, adapters, limits }) {res.json({ transaction: presentTxn(t), reversed_by: reversals });}));++ // ── Usage readings (platform.usage-sample@1; ops/usage.js) ──++ // Stored and reported, never charged. Not a money movement, so accepted while frozen; idempotent by the++ // reading's own idempotency_key (no Idempotency-Key header): a replay is 200 with the stored row, a new one 201.++ r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {++ const out = await usage.record(ctx, req.body, { principal: req.principal.sub });++ if (out.replayed) res.setHeader('Idempotent-Replayed', 'true');++ res.status(out.replayed ? 200 : 201).json({ record: out.record });++ }));++ r.get('/usage', ...read(CAP.admin, async (req, res) => res.json(await usage.list(db, req.query))));++// ── Admin (billing.ledger.admin) ─────────────────────────r.get('/admin/freeze', ...read(CAP.admin, async (req, res) => res.json(await admin.freezeState(db))));r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++++/**++ * Usage readings (platform.usage-sample@1, plan T5 lane F): a service reports what was used — by which project and++ * subject, on which provider, how much, and, once rated, the free allowance it consumed and the Vibes charged — and++ * Billing keeps it. Storing a reading charges nothing and moves no balance: `vibes_charged` records a charge already++ * made in the ledger, it is never a request to make one.++ *++ * Idempotent by the reading's own idempotency_key (the contract's dedupe key, stable across retries, unique across++ * every caller): the first reading under a key is stored; the same reading again returns the stored row (replay);++ * a different reading under the same key is refused (409 billing.usage_key_reused) and the stored one is kept.++ * "The same" is the canonical JSON (sorted keys), so key order and whitespace don't matter.++ */++const crypto = require('crypto');++const { validate } = require('openvibe-contracts');++const { stable } = require('../api/idempotency');++const { fail, positiveInt } = require('./common');++++const CONTRACT = 'platform.usage-sample@1';++const MAX_PAGE = 500;++++const hashOf = (reading) => crypto.createHash('sha256').update(stable(reading)).digest('hex');++++function present(row) {++ return {++ id: String(row.id), idempotency_key: row.idempotency_key, project: row.project, subject: row.subject, service: row.service,++ at: row.at, received_at: row.received_at, principal: row.principal,++ reading: typeof row.reading === 'string' ? JSON.parse(row.reading) : row.reading,++ };++}++++/** Store one reading. Returns { record, replayed }. */++async function record(ctx, reading, { principal }) {++ const v = validate('platform.usage-sample@1', reading);++ if (!v.valid) fail(422, 'billing.invalid_input', `not a ${CONTRACT}: ${v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`, v.errors);++ const atMs = Date.parse(reading.at);++ if (!Number.isFinite(atMs)) fail(422, 'billing.invalid_input', `at ${reading.at} is not a time Billing can store`);++ if (reading.free_allowance_used != null && reading.free_allowance_used > reading.quantity) {++ fail(422, 'billing.invalid_input', 'free_allowance_used is more than quantity');++ }++ const { db } = ctx;++ const hash = hashOf(reading);++ const row = await db.prepare(`INSERT INTO usage_records (idempotency_key, project, subject, service, at, reading, reading_hash, principal, received_at)++ VALUES (?, ?, ?, ?, ?::timestamptz, ?::jsonb, ?, ?, ?::timestamptz)++ ON CONFLICT (idempotency_key) DO NOTHING RETURNING *`)++ .get(reading.idempotency_key, reading.project || null, reading.subject || null, reading.service, new Date(atMs).toISOString(),++ JSON.stringify(reading), hash, principal, new Date(ctx.now()).toISOString());++ if (row) return { record: present(row), replayed: false };++ const prev = await db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(reading.idempotency_key);++ if (prev.reading_hash !== hash) {++ fail(409, 'billing.usage_key_reused', `a different reading is already stored under idempotency_key ${reading.idempotency_key}`);++ }++ return { record: present(prev), replayed: true };++}++++const time = (v, field) => {++ const ms = Date.parse(String(v));++ if (!Number.isFinite(ms)) fail(422, 'billing.invalid_input', `${field} must be an ISO-8601 date-time`);++ return new Date(ms).toISOString();++};++++/**++ * Readings, newest first (by `at`, then id), filtered by any of project, subject, service and the time range++ * from ≤ at < to. Cursor paging: next_cursor continues after the last row of this page, null on the last page.++ */++async function list(db, q = {}) {++ const where = [];++ const args = {};++ for (const f of ['project', 'subject', 'service']) {++ if (q[f] != null && q[f] !== '') { where.push(`${f} = @${f}`); args[f] = String(q[f]); }++ }++ if (q.from) { where.push('at >= @from::timestamptz'); args.from = time(q.from, 'from'); }++ if (q.to) { where.push('at < @to::timestamptz'); args.to = time(q.to, 'to'); }++ if (q.cursor) {++ let cur;++ try { cur = JSON.parse(Buffer.from(String(q.cursor), 'base64url').toString('utf8')); } catch { /* below */ }++ if (!Array.isArray(cur) || cur.length !== 2 || !Number.isFinite(Date.parse(cur[0])) || !/^\d{1,18}$/.test(String(cur[1]))) fail(422, 'billing.invalid_input', 'bad cursor');++ where.push('(at < @c_at::timestamptz OR (at = @c_at::timestamptz AND id < @c_id::bigint))');++ args.c_at = cur[0]; args.c_id = String(cur[1]);++ }++ const limit = Math.min(MAX_PAGE, positiveInt(q.limit || 100, 'limit'));++ args.n = limit + 1;++ const rows = await db.prepare(`SELECT * FROM usage_records ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY at DESC, id DESC LIMIT @n`).all(args);++ const page = rows.slice(0, limit);++ const last = page[page.length - 1];++ const next = rows.length > limit ? Buffer.from(JSON.stringify([last.at, String(last.id)])).toString('base64url') : null;++ return { records: page.map(present), next_cursor: next };++}++++module.exports = { record, list, present, CONTRACT };nnew file mode 100644@@@ -0,0 +1,178 @@++'use strict';++/**++ * Usage readings (platform.usage-sample@1; ops/usage.js, migrations/0002_usage.sql): POST /api/v1/usage validates++ * and stores a reading — the indexed fields as columns, the whole reading as jsonb — idempotently by its own++ * idempotency_key (a replay returns the stored row, a different reading under the key is 409); GET /api/v1/usage++ * filters by project, subject, service and time and pages with a cursor. Service tokens only. Nothing is charged:++ * the journal is untouched.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/app');++++const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';++let n = 0;++const reading = (over = {}) => {++ n++;++ return {++ id: `use-${n}`, idempotency_key: `media:delivery:${n}`, service: 'media', project: PRJ, subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ',++ resource: 'object-1', provider: 'local', operation: 'deliver', quantity: 1.5, unit: 'GiB', at: '2026-09-30T10:00:00Z', source: 'media.egress',++ ...over,++ };++};++const REC = ['billing.usage.record'];++const counts = async (db) => ({++ usage: (await db.prepare('SELECT COUNT(*) AS n FROM usage_records').get()).n,++ txns: (await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,++ entries: (await db.prepare('SELECT COUNT(*) AS n FROM ledger_entries').get()).n,++});++++/** A real user access token from the Network stub (authorization code + PKCE). */++async function userAccessToken(t) {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const code = t.network.authorize({ subject_id: t.user(900).id, challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), redirect_uri: 'http://x/cb' });++ const res = await fetch(`${t.network.url}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: 'http://x/cb', client_id: 'billing', client_secret: 'shh' }).toString(),++ });++ return (await res.json()).access_token;++}++++(async () => {++ console.log('usage readings');++ const t = await boot();++ const post = (body, opts = {}) => t.call('POST', '/api/v1/usage', { body, cap: REC, key: null, sub: 'svc:media', ...opts });++ const get = (qs, opts = {}) => t.call('GET', `/api/v1/usage${qs ? `?${qs}` : ''}`, { cap: REC, ...opts });++++ await check('a full reading (all 15 plan fields) is stored: columns plus the reading as jsonb; nothing is charged', async () => {++ const before = await counts(t.db);++ const body = reading({ node: 'node-1', cell: 'cell-west', region: 'us-west', cost_estimate: 0.03, free_allowance_used: 1, vibes_charged: 250, route_epoch: 7, trace_id: '0af7651916cd43dd8448eb211c80319c' });++ const r = await post(body);++ assert.strictEqual(r.status, 201, r.text);++ const rec = r.json.record;++ assert.deepStrictEqual(rec.reading, body);++ assert.strictEqual(rec.idempotency_key, body.idempotency_key);++ assert.strictEqual(rec.project, PRJ);++ assert.strictEqual(rec.subject, body.subject);++ assert.strictEqual(rec.service, 'media');++ assert.strictEqual(rec.at, '2026-09-30T10:00:00.000Z');++ assert.strictEqual(rec.principal, 'svc:media');++ const row = await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(body.idempotency_key);++ assert.deepStrictEqual(row.reading, body);++ assert.strictEqual(row.reading.vibes_charged, 250);++ const after = await counts(t.db);++ assert.deepStrictEqual(after, { ...before, usage: before.usage + 1 });++ });++++ await check('a reading with only the required fields is stored (project and subject null)', async () => {++ const { project, subject, resource, provider, ...minimal } = reading();++ const r = await post(minimal);++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.record.project, null);++ assert.strictEqual(r.json.record.subject, null);++ });++++ await check('an invalid reading is a 422 problem and nothing is stored', async () => {++ const before = (await counts(t.db)).usage;++ const { quantity, ...noQuantity } = reading();++ for (const bad of [noQuantity, reading({ extra: 1 }), reading({ project: 'prj_nope' }), reading({ quantity: -1 }), reading({ vibes_charged: 1.5 }),++ reading({ vibes_charged: -1 }), reading({ at: 'yesterday' }), reading({ quantity: 1, free_allowance_used: 2 }), []]) {++ const r = await post(bad);++ assert.strictEqual(r.status, 422, `${JSON.stringify(bad)} → ${r.status} ${r.text}`);++ assert.strictEqual(r.json.code, 'billing.invalid_input');++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);++ }++ assert.strictEqual((await counts(t.db)).usage, before);++ });++++ await check('a replay of the same reading returns the stored row (key order does not matter); a different one under the key is 409', async () => {++ const body = reading();++ const first = await post(body);++ assert.strictEqual(first.status, 201, first.text);++ const reordered = Object.fromEntries(Object.entries(body).reverse());++ const again = await post(reordered, { sub: 'svc:other' });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.headers.get('idempotent-replayed'), 'true');++ assert.deepStrictEqual(again.json.record, first.json.record);++ const changed = await post({ ...body, quantity: 2 });++ assert.strictEqual(changed.status, 409, changed.text);++ assert.strictEqual(changed.json.code, 'billing.usage_key_reused');++ const rows = await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').all(body.idempotency_key);++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(rows[0].reading.quantity, 1.5);++ });++++ await check('service tokens with billing.usage.record only: no token 401, a user token 401, another capability 403', async () => {++ assert.strictEqual((await post(reading(), { token: null })).status, 401);++ const user = await userAccessToken(t);++ assert.ok(user);++ assert.strictEqual((await post(reading(), { token: user })).status, 401);++ assert.strictEqual((await get('', { token: user })).status, 401);++ const denied = await post(reading(), { cap: ['billing.transfer.create'] });++ assert.strictEqual(denied.status, 403);++ assert.strictEqual(denied.json.code, 'capability.denied');++ assert.strictEqual((await post(reading(), { cap: ['billing.ledger.admin'] })).status, 403, 'admin reads readings, does not record them');++ assert.strictEqual((await get('')).status, 403, 'recording access cannot list other projects or subjects');++ assert.strictEqual((await get(`project=${PRJ}`)).status, 403, 'a query filter does not authorize a reading');++ assert.strictEqual((await get('', { cap: ['billing.ledger.admin'] })).status, 200);++ assert.strictEqual((await get('', { cap: ['billing.balance.read'] })).status, 403);++ });++++ await check('readings are accepted while the economy is frozen (they move no money)', async () => {++ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { body: { on: true, reason: 'test' } })).status, 200);++ try {++ const r = await post(reading());++ assert.strictEqual(r.status, 201, r.text);++ } finally {++ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { body: { on: false } })).status, 200);++ }++ });++ await t.close();++++ // ── Listing: filters and paging, on a fresh database ──++ const u = await boot();++ const uPost = (body) => u.call('POST', '/api/v1/usage', { body, cap: REC, key: null });++ const uGet = async (qs) => { const r = await u.call('GET', `/api/v1/usage?${qs}`, { cap: ['billing.ledger.admin'] }); assert.strictEqual(r.status, 200, r.text); return r.json; };++ const seed = [++ reading({ project: PRJ, subject: 'user:a', service: 'media', at: '2026-09-01T00:00:00Z' }),++ reading({ project: PRJ, subject: 'user:b', service: 'media', at: '2026-09-02T00:00:00Z' }),++ reading({ project: PRJ2, subject: 'user:a', service: 'compute', at: '2026-09-03T00:00:00Z' }),++ reading({ project: PRJ2, subject: 'user:b', service: 'media', at: '2026-09-04T00:00:00+02:00' }),++ reading({ project: PRJ, subject: 'user:a', service: 'compute', at: '2026-09-05T00:00:00Z' }),++ ];++ for (const s of seed) assert.strictEqual((await uPost(s)).status, 201);++ const keys = (out) => out.records.map((r) => r.idempotency_key);++ const k = (i) => seed[i].idempotency_key;++++ await check('GET filters by project, subject, service and the time range [from, to), newest first', async () => {++ assert.deepStrictEqual(keys(await uGet(`project=${PRJ}`)), [k(4), k(1), k(0)]);++ assert.deepStrictEqual(keys(await uGet('subject=user:a')), [k(4), k(2), k(0)]);++ assert.deepStrictEqual(keys(await uGet('service=compute')), [k(4), k(2)]);++ assert.deepStrictEqual(keys(await uGet(`project=${PRJ2}&service=media`)), [k(3)]);++ assert.deepStrictEqual(keys(await uGet('from=2026-09-02T00:00:00Z&to=2026-09-05T00:00:00Z')), [k(3), k(2), k(1)]);++ // 2026-09-04T00:00:00+02:00 is 2026-09-03T22:00Z: compared as a time, not as text.++ assert.deepStrictEqual(keys(await uGet('from=2026-09-03T21:00:00Z&to=2026-09-03T23:00:00Z')), [k(3)]);++ assert.deepStrictEqual(keys(await uGet('subject=user:nobody')), []);++ });++++ await check('GET pages with a cursor until next_cursor is null; a bad cursor, limit or time is 422', async () => {++ const seen = [];++ let cursor = null;++ let pages = 0;++ do {++ const out = await uGet(`limit=2${cursor ? `&cursor=${cursor}` : ''}`);++ seen.push(...keys(out));++ cursor = out.next_cursor;++ pages++;++ } while (cursor && pages < 10);++ assert.strictEqual(pages, 3);++ assert.deepStrictEqual(seen, [k(4), k(3), k(2), k(1), k(0)]);++ for (const qs of ['cursor=nope', 'limit=0', 'from=yesterday', 'to=2026-13-40']) {++ const r = await u.call('GET', `/api/v1/usage?${qs}`, { cap: ['billing.ledger.admin'] });++ assert.strictEqual(r.status, 422, `${qs} → ${r.status} ${r.text}`);++ }++ });++ await u.close();++ done();++})();
@@ -48,7 +48,7 @@ webhooks land here and nowhere else.
@@ -48,7 +48,7 @@ webhooks land here and nowhere else.
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import)
- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)
- OpenVibe.Events (the outbox relay, only when `EVENTS_URL` is set)
- the payment providers whose secrets are configured (none in production today)
- the payment providers whose secrets are configured (none in production today)
-- `openvibe-contracts` v0.76.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball
+- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.21.2 (service tokens, per-person limits, `openvibe-sdk/db`), `openvibe-shared` v2.5.0, pinned by release tarball
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
`openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process)
@@ -57,7 +57,8 @@ webhooks land here and nowhere else.
@@ -57,7 +57,8 @@ webhooks land here and nowhere else.
Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under
Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under
[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,
[API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`,
`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,
`billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`,
-`billing.entitlement.check` and `billing.ledger.admin`. Callers today are Live (after the cutover),
+`billing.entitlement.check`, `billing.ledger.admin` and `billing.usage.record` (usage readings; released in
+Contracts v0.85.0). Callers today are Live (after the cutover),
Tips (`billing.intent.create`, `billing.transfer.create`) and VIP (`billing.intent.create`,
Tips (`billing.intent.create`, `billing.transfer.create`) and VIP (`billing.intent.create`,
`billing.subscription.manage`, `billing.entitlement.check`).
`billing.subscription.manage`, `billing.entitlement.check`).
@@ -150,6 +151,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
@@ -150,6 +151,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |
| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, payable_value_cents}` |
| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, payable_value_cents}` |
| `GET /api/v1/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` |
| `GET /api/v1/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` |
+| `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing; accepted while frozen); idempotent by the reading's `idempotency_key`: same reading → 200 with the stored row (`Idempotent-Replayed: true`), a different one → 409 `billing.usage_key_reused`; no `Idempotency-Key` header |
+| `GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor=` | `billing.ledger.admin` | readings newest first, `from` ≤ `at` < `to`, cursor paging (`limit` ≤ 500) |
| `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks |
| `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks |
| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
| `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report |
| `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report |
@@ -160,11 +163,15 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
@@ -160,11 +163,15 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
| `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |
| `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host |
-The capabilities and the service manifest are released in openvibe-contracts (v0.8.0;
-`billing.staff.action` since v0.17.0); the drafts they came from stay in
-[docs/capabilities-proposal/](docs/capabilities-proposal/) and
-[docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Grants are matched with
-contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
+The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;
+`billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is
+`platform.usage-sample@1`; output is `billing.usage-record-result@1` =
+`{ record: { id, idempotency_key, project|null, subject|null, service, at, received_at, principal, reading } }`,
+201 when stored and 200 with `Idempotent-Replayed: true` on a replay; `GET /api/v1/usage` needs
+`billing.ledger.admin` and returns `{ records, next_cursor }`. Network grants `billing.usage.record`
+(audience `openvibe.billing`) to each producer service in `server/identity/principals.js`, and no service
+holds it yet: the first producer gets the grant in the same change that wires its calls. Grants are matched
+with contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
### Per-person limits
### Per-person limits
new file mode 100644
new file mode 100644
@@ -0,0 +1,217 @@
@@ -0,0 +1,217 @@
+# Cutover runbook — PR #6 (OpenVibe.Billing usage readings)
+
+PR #6 adds one empty table, `usage_records`, with four indexes, and two HTTP routes:
+`POST /api/v1/usage` (idempotent by the reading's own `idempotency_key`) and
+`GET /api/v1/usage` (filters by project, subject, service and `[from, to)`,
+cursor paging). Nothing existing is renamed, dropped or altered; no ledger column is
+the result of this PR; no provider webhook changes; no auth changes. Reads of
+`usage_records` are not a money movement.
+
+## What the migration does
+
+`migrations/0002_usage.sql` (`phase: expand`) creates the new table only:
+
+```sql
+CREATE TABLE usage_records (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
+ project text COLLATE "C",
+ subject text COLLATE "C",
+ service text COLLATE "C" NOT NULL,
+ at timestamptz NOT NULL,
+ reading jsonb NOT NULL,
+ reading_hash text COLLATE "C" NOT NULL,
+ principal text COLLATE "C" NOT NULL,
+ received_at timestamptz NOT NULL
+);
+CREATE INDEX usage_records_at ON usage_records (at, id);
+CREATE INDEX usage_records_project ON usage_records (project, at, id) WHERE project IS NOT NULL;
+CREATE INDEX usage_records_subject ON usage_records (subject, at, id) WHERE subject IS NOT NULL;
+CREATE INDEX usage_records_service ON usage_records (service, at, id);
+```
+
+The runner is `openvibe-sdk/db`'s `migrate()`: it locks `ov_migrations`, runs
+the file inside one transaction (no `no-transaction` header is set, so it is
+transactional), records the sha256 of the file's content, and refuses any
+later edit of an applied migration. No `ov_migrations` row exists yet for
+`0002_usage.sql` on the current production ledger; the runner will add one.
+
+## What is not changed
+
+- No existing table is renamed, dropped or altered. The migration is additive.
+- No index on an existing table is created, dropped or rebuilt.
+- No money-bearing column, account kind, transaction type or ledger enum moves.
+- No provider webhook URL moves (the PowerChat webhook, the cutover's step 6,
+ is a separate change governed by `docs/live-cutover.md`; this PR is independent).
+- No `BILLING_AUTHORITY` change. The cutover's switch flips in another runbook.
+- No service-token, capability or grant change. `billing.usage.record` is
+ declared in `docs/capabilities-proposal/billing.usage.record.json` but is not
+ yet in OpenVibe.Contracts' released capability manifests (v0.84.0); until
+ Contracts releases it, the route is reachable only to service tokens the
+ operator grants by hand (none today). No call site anywhere in the network
+ POSTs to `/api/v1/usage` on the day of this PR's merge.
+
+## Prerequisites
+
+1. **Snapshot the ledger.** The new migration only adds an empty table, but
+ the pre-merge rule for any data change is a snapshot the operator can roll
+ back to. Force the weekly logical dump for the Billing database
+ (`pg_dump -Fc` lives at `/var/backups/openvibe/billing/<stamp>/billing.dump`,
+ owned `root:root`, 0600; `OpenVibe.Host/docs/backups.md:108-113`):
+ ```bash
+ sudo ovhost backup --all --logical
+ ```
+ The recipe verifies `pg_stat_archiver.failed_count` has not increased,
+ reads the per-service row counts and writes the dump. Note the stamp
+ printed at the end of the run; the rollback section uses it.
+2. **Confirm the unit is healthy.** `sudo systemctl status openvibe-billing`
+ answers active (running) and `/api/ready` returns 200 with `db: "ok"`.
+3. **Confirm no freeze in flight.** `node scripts/freeze.js state` (run from
+ `/opt/openvibe.billing`) returns `{"on": false}`. If it is on, find and
+ resolve the freeze first; this PR does not interact with the freeze but the
+ snapshot step must be taken when writes are healthy.
+
+## Rehearsal on a snapshot
+
+Run this on a copy of the production data, **never on the production ledger**;
+the rehearsal is green when `ds/deploy/rehearsals/billing-usage-records-001.json`
+on the harness says `{"ok": true}`. This section is the rehearsal.
+
+1. Restore the snapshot taken in prerequisites into a scratch database role
+ the operator stands up for the rehearsal (a separate Postgres role on the
+ same cluster, isolated from `ov_billing`). `openvibe-sdk/db`'s `migrate()`
+ is idempotent across roles; the rehearsal role has its own `ov_migrations`.
+2. Run the migration runner against the rehearsal role, with the same
+ `migrations/` directory the deployed release ships:
+ ```bash
+ DATABASE_URL=<rehearsal-url> DATABASE_DIRECT_URL=<rehearsal-direct-url> \
+ node -e 'const { openDb } = require("./server/db"); openDb({ db: { url: process.env.DATABASE_URL, directUrl: process.env.DATABASE_DIRECT_URL }, isProduction: false, log: { info: console.log, warn: console.warn, error: console.error } }).then((db) => db.close())'
+ ```
+ The runner is silent on a clean ledger; a successful run adds one row to
+ `ov_migrations` with `id = '0002'`, `name = 'usage'`, `phase = 'expand'`.
+3. Verify the schema is exactly what the PR added:
+ ```sql
+ \d usage_records
+ SELECT indexname FROM pg_indexes WHERE tablename = 'usage_records' ORDER BY indexname;
+ SELECT id, name, phase FROM ov_migrations WHERE id = '0002';
+ ```
+ The table has the nine columns listed above; `usage_records.id` is a
+ `bigint GENERATED ALWAYS AS IDENTITY`; the four indexes are present;
+ `ov_migrations` has one row for `0002_usage`.
+4. Boot the rehearsal instance against the rehearsal database:
+ `NODE_ENV=production PORT=4601 DATABASE_URL=<rehearsal-url>
+ DATABASE_DIRECT_URL=<rehearsal-direct-url> node server/index.js`.
+ `/api/ready` returns 200; the boot log prints `migrations: applied 0002_usage
+ (expand, NNms)` once and not again on a second boot (the checksum is
+ recorded).
+5. Run the focused suite on the rehearsal instance:
+ `ov test test/usage.test.js test/dependency-pins.test.js`. Both pass.
+ No existing test regresses (`test/usage.test.js` is the only test the PR
+ adds; `test/dependency-pins.test.js` is the README-pin guard the PR also
+ adds).
+6. Exercise the routes by hand:
+ ```bash
+ curl -sS -X POST http://127.0.0.1:4601/api/v1/usage \
+ -H "Authorization: Bearer $BILLING_USAGE_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"id":"use-rhb-1","idempotency_key":"rehearsal:0002:1","service":"media","operation":"deliver","quantity":1,"unit":"GiB","at":"2026-10-03T12:00:00Z","source":"rehearsal"}'
+ ```
+ A valid `platform.usage-sample@1` body returns 201 with `record` populated;
+ the same body again returns 200 with `Idempotent-Replayed: true` and the
+ stored row; a body with a different reading under the same key returns 409
+ `billing.usage_key_reused`. `GET /api/v1/usage?project=...` lists the
+ row newest-first. Reject 422 for an invalid reading; reject 401 without a
+ token; reject 403 with a token that lacks `billing.usage.record`.
+7. Write the green marker:
+ ```bash
+ mkdir -p ~/openvibe/agents/ds/deploy/rehearsals
+ printf '{"ok": true}\n' > ~/openvibe/agents/ds/deploy/rehearsals/billing-usage-records-001.json
+ ```
+ The harness reads this file; the deploy is gated on `{"ok": true}`.
+
+## Cutover
+
+The merge is automated once the rehearsal is green. The deploy is the standard
+`openvibe-billing` deploy:
+
+1. **Merge the PR.** The PR description carries the cutover manifest below;
+ the harness's `data` risk class waits for `ds/deploy/rehearsals/billing-usage-records-001.json`
+ to be `{"ok": true}` before letting the deploy start.
+2. **Deploy.** `sudo ovhost deploy billing "usage readings (PR #6) — additive
+ migration 0002_usage"`. The `git-checkout` recipe fast-forwards
+ `/opt/openvibe.billing`, installs on a lockfile change (this PR bumps
+ `openvibe-contracts` v0.76.0 → v0.84.0 and `openvibe-shared` v2.3.1 →
+ v2.5.0), restarts `openvibe-billing.service`, waits for `/api/ready`.
+3. **The boot applies the migration.** The systemd unit runs `node
+ server/index.js`; `server/db.js`'s `openDb` runs `owner.migrate({ dir:
+ MIGRATIONS, log })` against `DATABASE_DIRECT_URL`. The runner takes the
+ `ov_migrations` advisory lock, applies `0002_usage.sql` in one transaction,
+ records the checksum, releases the lock. The journal line is
+ `migrations: applied 0002_usage (expand, NNms)`. The boot then opens the
+ PgBouncer-backed serving handle and the unit is healthy.
+4. **Verify on production.**
+ - `sudo ovhost access run openvibe-ovh show billing` reads clean: the unit
+ is active (running), `/api/ready` is 200 with `db: "ok"`, the boot log
+ mentions `0002_usage` exactly once.
+ - `psql -d ov_billing -c '\d usage_records'` lists the nine columns and the
+ four indexes; `ov_migrations` has the `0002_usage` row.
+ - `curl -sS http://127.0.0.1:4600/api/v1/usage` with a token that has
+ `billing.usage.record` returns `{ records: [], next_cursor: null }`.
+ - `node scripts/reconcile.js` (run from `/opt/openvibe.billing`) prints
+ `accounts: N, transactions: N, ledger_entries: N` with the same totals
+ as before the deploy. Nothing money-bearing moved.
+
+The cutover is done when step 4's four lines are clean.
+
+## Rollback
+
+Because the migration is additive and the table is empty on the day of the
+merge, the rollback is one DDL away. Pick the path that matches the elapsed
+time.
+
+- **No usage row has been written yet (the common case the day of the merge).**
+ Roll back the deploy and drop the empty table:
+ 1. `sudo ovhost rollback billing` — the recipe takes the unit to the
+ previous release (`openvibe-contracts` v0.76.0, `openvibe-shared` v2.3.1,
+ no `0002_usage.sql`). The previous release's boot refuses to apply a
+ migration it does not know about (the file is absent) and the runner is
+ silent on a ledger that already has the `ov_migrations` row from the bad
+ release. The unit is healthy on the old code; `/api/ready` is 200.
+ 2. `psql -d ov_billing -c 'DROP INDEX IF EXISTS usage_records_at; DROP INDEX IF EXISTS usage_records_project; DROP INDEX IF EXISTS usage_records_subject; DROP INDEX IF EXISTS usage_records_service; DROP TABLE IF EXISTS usage_records; DELETE FROM ov_migrations WHERE id = '\''0002'\'';`
+ The rollback unit can also run a `drover` step that wipes the table and
+ the `ov_migrations` row in one transaction; either form is fine.
+ 3. Verify: `\d usage_records` says "Did not find any relation named
+ 'usage_records'"; `ov_migrations` has no `0002` row; `ov test
+ test/usage.test.js` is skipped (the previous release has no usage
+ code); the focused suite that *did* exist on the previous release
+ passes.
+- **Usage rows have been written since the merge.** A `DELETE FROM
+ usage_records` is destructive (the readings are first-party service
+ telemetry; nothing else references them in the journal). If a row was
+ written, decide with the owner whether to keep the table (preferred: the
+ rows are additive and the table does not block the rollback) or to wipe
+ them. If the call is to wipe, run `DELETE FROM usage_records; DELETE FROM
+ ov_migrations WHERE id = '0002';` in one transaction. The deploy rollback
+ then completes as above. The snapshot taken before the merge
+ (`/var/backups/openvibe/billing/<stamp>/billing.dump`) is the last resort
+ if a partial state cannot be reasoned about; restore it into a scratch role,
+ compare `usage_records` row counts, then act.
+- **The deploy itself is broken on the new release.** `sudo ovhost rollback
+ billing` takes the unit back to the previous release in one step; the
+ migration runner refuses to run `0002_usage.sql` again (the file is not in
+ the previous release) and the unit boots on the old code.
+
+## PR description
+
+The PR carries this block verbatim:
+
+````
+```cutover
+{"runbook": "docs/cutover-pr-openvibe-billing-6.md", "rehearsal": "billing-usage-records-001"}
+```
+````
+
+The harness reads the `runbook` and `rehearsal` from this block, refuses the
+deploy until `ds/deploy/rehearsals/billing-usage-records-001.json` is
+`{"ok": true}`, and otherwise proceeds with the standard `git-checkout`
+recipe.
\ No newline at end of file
\ No newline at end of file
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+-- phase: expand
+-- Usage readings (platform.usage-sample@1, openvibe-contracts v0.84.0; plan T5 lane F): stored and reported, never
+-- charged. A reading's own idempotency_key dedupes retries across every caller; the indexed fields are columns and
+-- the whole reading, exactly as accepted, is `reading`. `at` is the reading's time, normalised to UTC milliseconds
+-- (the cursor's precision); the original string stays in `reading`. reading_hash is the sha256 of the reading's
+-- canonical JSON (sorted keys), compared on a replay. Additive only: no existing table changes.
+
+CREATE TABLE usage_records (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ idempotency_key text COLLATE "C" NOT NULL UNIQUE,
+ project text COLLATE "C",
+ subject text COLLATE "C",
+ service text COLLATE "C" NOT NULL,
+ at timestamptz NOT NULL,
+ reading jsonb NOT NULL,
+ reading_hash text COLLATE "C" NOT NULL,
+ principal text COLLATE "C" NOT NULL,
+ received_at timestamptz NOT NULL
+);
+
+CREATE INDEX usage_records_at ON usage_records (at, id);
+CREATE INDEX usage_records_project ON usage_records (project, at, id) WHERE project IS NOT NULL;
+CREATE INDEX usage_records_subject ON usage_records (subject, at, id) WHERE subject IS NOT NULL;
+CREATE INDEX usage_records_service ON usage_records (service, at, id);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1059,9 +1059,9 @@
@@ -1059,9 +1059,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.76.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
+ "version": "0.85.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
+ "integrity": "sha512-Xo54hG4kiL1kOTcxwbnH8cmt2zZhvp/gqCs+egPBLLSBg+SjVuhXIPaX8EJPQux5x1YxwO7wjDDywcHvIaGoqA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.21.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -25,6 +25,7 @@ const admin = require('../ops/admin');
@@ -25,6 +25,7 @@ const admin = require('../ops/admin');
const providers = require('../providers');
const providers = require('../providers');
const { reconcile } = require('../reconcile');
const { reconcile } = require('../reconcile');
const external = require('../ops/external');
const external = require('../ops/external');
+const usage = require('../ops/usage');
const CAP = {
const CAP = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
@@ -35,6 +36,7 @@ const CAP = {
@@ -35,6 +36,7 @@ const CAP = {
subscription: 'billing.subscription.manage',
subscription: 'billing.subscription.manage',
entitlement: 'billing.entitlement.check',
entitlement: 'billing.entitlement.check',
admin: 'billing.ledger.admin',
admin: 'billing.ledger.admin',
+ usage: 'billing.usage.record',
};
};
function v1Router({ ctx, auth, adapters, limits }) {
function v1Router({ ctx, auth, adapters, limits }) {
@@ -245,6 +247,16 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -245,6 +247,16 @@ function v1Router({ ctx, auth, adapters, limits }) {
res.json({ transaction: presentTxn(t), reversed_by: reversals });
res.json({ transaction: presentTxn(t), reversed_by: reversals });
}));
}));
+ // ── Usage readings (platform.usage-sample@1; ops/usage.js) ──
+ // Stored and reported, never charged. Not a money movement, so accepted while frozen; idempotent by the
+ // reading's own idempotency_key (no Idempotency-Key header): a replay is 200 with the stored row, a new one 201.
+ r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {
+ const out = await usage.record(ctx, req.body, { principal: req.principal.sub });
+ if (out.replayed) res.setHeader('Idempotent-Replayed', 'true');
+ res.status(out.replayed ? 200 : 201).json({ record: out.record });
+ }));
+ r.get('/usage', ...read(CAP.admin, async (req, res) => res.json(await usage.list(db, req.query))));
+
// ── Admin (billing.ledger.admin) ─────────────────────────
// ── Admin (billing.ledger.admin) ─────────────────────────
r.get('/admin/freeze', ...read(CAP.admin, async (req, res) => res.json(await admin.freezeState(db))));
r.get('/admin/freeze', ...read(CAP.admin, async (req, res) => res.json(await admin.freezeState(db))));
r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {
r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+
+/**
+ * Usage readings (platform.usage-sample@1, plan T5 lane F): a service reports what was used — by which project and
+ * subject, on which provider, how much, and, once rated, the free allowance it consumed and the Vibes charged — and
+ * Billing keeps it. Storing a reading charges nothing and moves no balance: `vibes_charged` records a charge already
+ * made in the ledger, it is never a request to make one.
+ *
+ * Idempotent by the reading's own idempotency_key (the contract's dedupe key, stable across retries, unique across
+ * every caller): the first reading under a key is stored; the same reading again returns the stored row (replay);
+ * a different reading under the same key is refused (409 billing.usage_key_reused) and the stored one is kept.
+ * "The same" is the canonical JSON (sorted keys), so key order and whitespace don't matter.
+ */
+const crypto = require('crypto');
+const { validate } = require('openvibe-contracts');
+const { stable } = require('../api/idempotency');
+const { fail, positiveInt } = require('./common');
+
+const CONTRACT = 'platform.usage-sample@1';
+const MAX_PAGE = 500;
+
+const hashOf = (reading) => crypto.createHash('sha256').update(stable(reading)).digest('hex');
+
+function present(row) {
+ return {
+ id: String(row.id), idempotency_key: row.idempotency_key, project: row.project, subject: row.subject, service: row.service,
+ at: row.at, received_at: row.received_at, principal: row.principal,
+ reading: typeof row.reading === 'string' ? JSON.parse(row.reading) : row.reading,
+ };
+}
+
+/** Store one reading. Returns { record, replayed }. */
+async function record(ctx, reading, { principal }) {
+ const v = validate('platform.usage-sample@1', reading);
+ if (!v.valid) fail(422, 'billing.invalid_input', `not a ${CONTRACT}: ${v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`, v.errors);
+ const atMs = Date.parse(reading.at);
+ if (!Number.isFinite(atMs)) fail(422, 'billing.invalid_input', `at ${reading.at} is not a time Billing can store`);
+ if (reading.free_allowance_used != null && reading.free_allowance_used > reading.quantity) {
+ fail(422, 'billing.invalid_input', 'free_allowance_used is more than quantity');
+ }
+ const { db } = ctx;
+ const hash = hashOf(reading);
+ const row = await db.prepare(`INSERT INTO usage_records (idempotency_key, project, subject, service, at, reading, reading_hash, principal, received_at)
+ VALUES (?, ?, ?, ?, ?::timestamptz, ?::jsonb, ?, ?, ?::timestamptz)
+ ON CONFLICT (idempotency_key) DO NOTHING RETURNING *`)
+ .get(reading.idempotency_key, reading.project || null, reading.subject || null, reading.service, new Date(atMs).toISOString(),
+ JSON.stringify(reading), hash, principal, new Date(ctx.now()).toISOString());
+ if (row) return { record: present(row), replayed: false };
+ const prev = await db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(reading.idempotency_key);
+ if (prev.reading_hash !== hash) {
+ fail(409, 'billing.usage_key_reused', `a different reading is already stored under idempotency_key ${reading.idempotency_key}`);
+ }
+ return { record: present(prev), replayed: true };
+}
+
+const time = (v, field) => {
+ const ms = Date.parse(String(v));
+ if (!Number.isFinite(ms)) fail(422, 'billing.invalid_input', `${field} must be an ISO-8601 date-time`);
+ return new Date(ms).toISOString();
+};
+
+/**
+ * Readings, newest first (by `at`, then id), filtered by any of project, subject, service and the time range
+ * from ≤ at < to. Cursor paging: next_cursor continues after the last row of this page, null on the last page.
+ */
+async function list(db, q = {}) {
+ const where = [];
+ const args = {};
+ for (const f of ['project', 'subject', 'service']) {
+ if (q[f] != null && q[f] !== '') { where.push(`${f} = @${f}`); args[f] = String(q[f]); }
+ }
+ if (q.from) { where.push('at >= @from::timestamptz'); args.from = time(q.from, 'from'); }
+ if (q.to) { where.push('at < @to::timestamptz'); args.to = time(q.to, 'to'); }
+ if (q.cursor) {
+ let cur;
+ try { cur = JSON.parse(Buffer.from(String(q.cursor), 'base64url').toString('utf8')); } catch { /* below */ }
+ if (!Array.isArray(cur) || cur.length !== 2 || !Number.isFinite(Date.parse(cur[0])) || !/^\d{1,18}$/.test(String(cur[1]))) fail(422, 'billing.invalid_input', 'bad cursor');
+ where.push('(at < @c_at::timestamptz OR (at = @c_at::timestamptz AND id < @c_id::bigint))');
+ args.c_at = cur[0]; args.c_id = String(cur[1]);
+ }
+ const limit = Math.min(MAX_PAGE, positiveInt(q.limit || 100, 'limit'));
+ args.n = limit + 1;
+ const rows = await db.prepare(`SELECT * FROM usage_records ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY at DESC, id DESC LIMIT @n`).all(args);
+ const page = rows.slice(0, limit);
+ const last = page[page.length - 1];
+ const next = rows.length > limit ? Buffer.from(JSON.stringify([last.at, String(last.id)])).toString('base64url') : null;
+ return { records: page.map(present), next_cursor: next };
+}
+
+module.exports = { record, list, present, CONTRACT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,178 @@
@@ -0,0 +1,178 @@
+'use strict';
+/**
+ * Usage readings (platform.usage-sample@1; ops/usage.js, migrations/0002_usage.sql): POST /api/v1/usage validates
+ * and stores a reading — the indexed fields as columns, the whole reading as jsonb — idempotently by its own
+ * idempotency_key (a replay returns the stored row, a different reading under the key is 409); GET /api/v1/usage
+ * filters by project, subject, service and time and pages with a cursor. Service tokens only. Nothing is charged:
+ * the journal is untouched.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/app');
+
+const PRJ = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const PRJ2 = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
+let n = 0;
+const reading = (over = {}) => {
+ n++;
+ return {
+ id: `use-${n}`, idempotency_key: `media:delivery:${n}`, service: 'media', project: PRJ, subject: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPQ',
+ resource: 'object-1', provider: 'local', operation: 'deliver', quantity: 1.5, unit: 'GiB', at: '2026-09-30T10:00:00Z', source: 'media.egress',
+ ...over,
+ };
+};
+const REC = ['billing.usage.record'];
+const counts = async (db) => ({
+ usage: (await db.prepare('SELECT COUNT(*) AS n FROM usage_records').get()).n,
+ txns: (await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,
+ entries: (await db.prepare('SELECT COUNT(*) AS n FROM ledger_entries').get()).n,
+});
+
+/** A real user access token from the Network stub (authorization code + PKCE). */
+async function userAccessToken(t) {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const code = t.network.authorize({ subject_id: t.user(900).id, challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), redirect_uri: 'http://x/cb' });
+ const res = await fetch(`${t.network.url}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: 'http://x/cb', client_id: 'billing', client_secret: 'shh' }).toString(),
+ });
+ return (await res.json()).access_token;
+}
+
+(async () => {
+ console.log('usage readings');
+ const t = await boot();
+ const post = (body, opts = {}) => t.call('POST', '/api/v1/usage', { body, cap: REC, key: null, sub: 'svc:media', ...opts });
+ const get = (qs, opts = {}) => t.call('GET', `/api/v1/usage${qs ? `?${qs}` : ''}`, { cap: REC, ...opts });
+
+ await check('a full reading (all 15 plan fields) is stored: columns plus the reading as jsonb; nothing is charged', async () => {
+ const before = await counts(t.db);
+ const body = reading({ node: 'node-1', cell: 'cell-west', region: 'us-west', cost_estimate: 0.03, free_allowance_used: 1, vibes_charged: 250, route_epoch: 7, trace_id: '0af7651916cd43dd8448eb211c80319c' });
+ const r = await post(body);
+ assert.strictEqual(r.status, 201, r.text);
+ const rec = r.json.record;
+ assert.deepStrictEqual(rec.reading, body);
+ assert.strictEqual(rec.idempotency_key, body.idempotency_key);
+ assert.strictEqual(rec.project, PRJ);
+ assert.strictEqual(rec.subject, body.subject);
+ assert.strictEqual(rec.service, 'media');
+ assert.strictEqual(rec.at, '2026-09-30T10:00:00.000Z');
+ assert.strictEqual(rec.principal, 'svc:media');
+ const row = await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(body.idempotency_key);
+ assert.deepStrictEqual(row.reading, body);
+ assert.strictEqual(row.reading.vibes_charged, 250);
+ const after = await counts(t.db);
+ assert.deepStrictEqual(after, { ...before, usage: before.usage + 1 });
+ });
+
+ await check('a reading with only the required fields is stored (project and subject null)', async () => {
+ const { project, subject, resource, provider, ...minimal } = reading();
+ const r = await post(minimal);
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.record.project, null);
+ assert.strictEqual(r.json.record.subject, null);
+ });
+
+ await check('an invalid reading is a 422 problem and nothing is stored', async () => {
+ const before = (await counts(t.db)).usage;
+ const { quantity, ...noQuantity } = reading();
+ for (const bad of [noQuantity, reading({ extra: 1 }), reading({ project: 'prj_nope' }), reading({ quantity: -1 }), reading({ vibes_charged: 1.5 }),
+ reading({ vibes_charged: -1 }), reading({ at: 'yesterday' }), reading({ quantity: 1, free_allowance_used: 2 }), []]) {
+ const r = await post(bad);
+ assert.strictEqual(r.status, 422, `${JSON.stringify(bad)} → ${r.status} ${r.text}`);
+ assert.strictEqual(r.json.code, 'billing.invalid_input');
+ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
+ }
+ assert.strictEqual((await counts(t.db)).usage, before);
+ });
+
+ await check('a replay of the same reading returns the stored row (key order does not matter); a different one under the key is 409', async () => {
+ const body = reading();
+ const first = await post(body);
+ assert.strictEqual(first.status, 201, first.text);
+ const reordered = Object.fromEntries(Object.entries(body).reverse());
+ const again = await post(reordered, { sub: 'svc:other' });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.headers.get('idempotent-replayed'), 'true');
+ assert.deepStrictEqual(again.json.record, first.json.record);
+ const changed = await post({ ...body, quantity: 2 });
+ assert.strictEqual(changed.status, 409, changed.text);
+ assert.strictEqual(changed.json.code, 'billing.usage_key_reused');
+ const rows = await t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').all(body.idempotency_key);
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(rows[0].reading.quantity, 1.5);
+ });
+
+ await check('service tokens with billing.usage.record only: no token 401, a user token 401, another capability 403', async () => {
+ assert.strictEqual((await post(reading(), { token: null })).status, 401);
+ const user = await userAccessToken(t);
+ assert.ok(user);
+ assert.strictEqual((await post(reading(), { token: user })).status, 401);
+ assert.strictEqual((await get('', { token: user })).status, 401);
+ const denied = await post(reading(), { cap: ['billing.transfer.create'] });
+ assert.strictEqual(denied.status, 403);
+ assert.strictEqual(denied.json.code, 'capability.denied');
+ assert.strictEqual((await post(reading(), { cap: ['billing.ledger.admin'] })).status, 403, 'admin reads readings, does not record them');
+ assert.strictEqual((await get('')).status, 403, 'recording access cannot list other projects or subjects');
+ assert.strictEqual((await get(`project=${PRJ}`)).status, 403, 'a query filter does not authorize a reading');
+ assert.strictEqual((await get('', { cap: ['billing.ledger.admin'] })).status, 200);
+ assert.strictEqual((await get('', { cap: ['billing.balance.read'] })).status, 403);
+ });
+
+ await check('readings are accepted while the economy is frozen (they move no money)', async () => {
+ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { body: { on: true, reason: 'test' } })).status, 200);
+ try {
+ const r = await post(reading());
+ assert.strictEqual(r.status, 201, r.text);
+ } finally {
+ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { body: { on: false } })).status, 200);
+ }
+ });
+ await t.close();
+
+ // ── Listing: filters and paging, on a fresh database ──
+ const u = await boot();
+ const uPost = (body) => u.call('POST', '/api/v1/usage', { body, cap: REC, key: null });
+ const uGet = async (qs) => { const r = await u.call('GET', `/api/v1/usage?${qs}`, { cap: ['billing.ledger.admin'] }); assert.strictEqual(r.status, 200, r.text); return r.json; };
+ const seed = [
+ reading({ project: PRJ, subject: 'user:a', service: 'media', at: '2026-09-01T00:00:00Z' }),
+ reading({ project: PRJ, subject: 'user:b', service: 'media', at: '2026-09-02T00:00:00Z' }),
+ reading({ project: PRJ2, subject: 'user:a', service: 'compute', at: '2026-09-03T00:00:00Z' }),
+ reading({ project: PRJ2, subject: 'user:b', service: 'media', at: '2026-09-04T00:00:00+02:00' }),
+ reading({ project: PRJ, subject: 'user:a', service: 'compute', at: '2026-09-05T00:00:00Z' }),
+ ];
+ for (const s of seed) assert.strictEqual((await uPost(s)).status, 201);
+ const keys = (out) => out.records.map((r) => r.idempotency_key);
+ const k = (i) => seed[i].idempotency_key;
+
+ await check('GET filters by project, subject, service and the time range [from, to), newest first', async () => {
+ assert.deepStrictEqual(keys(await uGet(`project=${PRJ}`)), [k(4), k(1), k(0)]);
+ assert.deepStrictEqual(keys(await uGet('subject=user:a')), [k(4), k(2), k(0)]);
+ assert.deepStrictEqual(keys(await uGet('service=compute')), [k(4), k(2)]);
+ assert.deepStrictEqual(keys(await uGet(`project=${PRJ2}&service=media`)), [k(3)]);
+ assert.deepStrictEqual(keys(await uGet('from=2026-09-02T00:00:00Z&to=2026-09-05T00:00:00Z')), [k(3), k(2), k(1)]);
+ // 2026-09-04T00:00:00+02:00 is 2026-09-03T22:00Z: compared as a time, not as text.
+ assert.deepStrictEqual(keys(await uGet('from=2026-09-03T21:00:00Z&to=2026-09-03T23:00:00Z')), [k(3)]);
+ assert.deepStrictEqual(keys(await uGet('subject=user:nobody')), []);
+ });
+
+ await check('GET pages with a cursor until next_cursor is null; a bad cursor, limit or time is 422', async () => {
+ const seen = [];
+ let cursor = null;
+ let pages = 0;
+ do {
+ const out = await uGet(`limit=2${cursor ? `&cursor=${cursor}` : ''}`);
+ seen.push(...keys(out));
+ cursor = out.next_cursor;
+ pages++;
+ } while (cursor && pages < 10);
+ assert.strictEqual(pages, 3);
+ assert.deepStrictEqual(seen, [k(4), k(3), k(2), k(1), k(0)]);
+ for (const qs of ['cursor=nope', 'limit=0', 'from=yesterday', 'to=2026-13-40']) {
+ const r = await u.call('GET', `/api/v1/usage?${qs}`, { cap: ['billing.ledger.admin'] });
+ assert.strictEqual(r.status, 422, `${qs} → ${r.status} ${r.text}`);
+ }
+ });
+ await u.close();
+ done();
+})();