openvibers/openvibe.billing Commits

Rate stored usage readings into the ledger under hard budgets

OP OpenVibers committed 4 Oct 2026 · 6bebb8f37a · 14 files, +973 −5

Asked: T5 step 6 — OpenVibe.Billing: rate stored usage readings into the ledger (free allowance first, then Vibes, under hard budgets). Money: security-critical. ONE pull request from current origin/main (git fetch first; main has PR #6 usage intake, migrations 0002_usage and step 5's 0003 promo ledger wi…
Harness job billing-t5-step-6-openvibe-billing-135446 (plan T5).

Changes

ddiff --git a/.env.example b/.env.example
iindex 0f250d8..c456db4 100644
---- a/.env.example
++++ b/.env.example
@@@ -120,3 +120,9 @@ POWERCHAT_SITE_USERNAME=
#BILLING_RECONCILE_KEEP_DAYS=30
# A provider receipt unprocessed this long (economy open) fails reconciliation (receipts.stale).
#BILLING_RECONCILE_STALE_RECEIPT_MIN=60
++# Usage rating (README "Rates"): the sweep is off until this is set (0 = off); nothing is charged without a
++# rate card, and cards are loaded only by review: OV_RATE_CARDS + node scripts/load-rate-cards.js.
++#BILLING_RATING_INTERVAL_MS=0
++#BILLING_RATING_BATCH=500
++#BILLING_RATING_RETRY_MS=3600000
++#OV_RATE_CARDS=/etc/openvibe/rate-cards.json
ddiff --git a/README.md b/README.md
iindex 23bdcc0..529169e 100644
---- a/README.md
++++ b/README.md
@@@ -137,6 +137,43 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
review and announced when reprocessed after mapping. Under `live` it is recorded, not announced —
Live's own webhook announces it, so a tip is never celebrated twice.
++## Rates (usage rating)
++
++Billing turns stored usage readings (`POST /api/v1/usage`) into charges in a **background sweep**
++([server/ops/rating.js](server/ops/rating.js)), never inside `POST /api/v1/usage`: a producer never drives a
++synchronous debit. A reading without `vibes_charged` in `GET /api/v1/usage` is simply not rated yet.
++
++- **Two gates, both closed by default.** The sweep runs only when `BILLING_RATING_INTERVAL_MS` is set (default
++ `0` = off; `BILLING_RATING_BATCH` readings per pass, 500), and **nothing is charged without a rate card**.
++ Rate cards are loaded **only by review**: `OV_RATE_CARDS` (a JSON array of `platform.rate-card@1`, inline or
++ a file path) + `node scripts/load-rate-cards.js [--dry-run]` (`npm run load-rate-cards`). Every card is
++ validated against the contract first and one bad card loads nothing; no request and no AI writes a card.
++- **Card match.** A reading's `provider` and `resource` are the card's `provider` and `metric`; the card's
++ region (none = every region) and effective dates (`effective_from` ≤ the reading's UTC day <
++ `effective_until`) must fit. Its `unit` must be the one the metric names (the metric itself or its leading
++ word: `GiB` for `gib-delivered`, `requests` for `request`); no unit is converted.
++- **The charge.** `quantity / unit_size × unit_price_usd × bits_per_usd`, computed exactly and rounded up once
++ to whole vibes-bits. The subject's promo allowance (`promo_credit`, step 5) covers it first; the rest is
++ **one `usage` transaction per reading**, `user_credit:<subject>` → `platform_revenue` (vibes-bits), idempotency
++ key `usage:<reading idempotency_key>`. The allowance draw is a promo `adjustment` (`promo_credit` →
++ `promo_reserve`) in the same database transaction, so promo bits never reach `platform_revenue` or creator
++ earnings. The reading records `vibes_charged` (bits), `promo_bits`, `free_allowance_used` (in the reading's
++ own unit), `rated_at` and `txn_id` (the `usage` transaction, or the promo draw when nothing was charged). A
++ card's own `free_allowance` (the provider's free tier) is stored with the card, not applied per person.
++- **Hard budgets** (`usage_budgets`, `POST /api/v1/admin/budgets`): per subject + service (`'*'` = every
++ service) + window (`day`, `month`, `none`). A charge that would take an open budget's `spent_bits` past
++ `budget_bits` is **refused whole** (`billing.budget_exceeded`): nothing is charged, no allowance drawn, the
++ reading stays unrated. `spent_bits` grows in the same transaction as the ledger post. A window that ends opens
++ the next with the same budget. **With no budget row only the spendable balance limits the charge**
++ (`billing.insufficient_funds`). `budget_bits: 0` stops all further charges.
++- **Unrated, never partly charged.** The funds check, the budget check, the allowance draw, the post and the
++ "rated" mark are one serializable transaction that claims the reading's row (`FOR UPDATE SKIP LOCKED`), so
++ two sweeps, or a sweep and `POST /api/v1/admin/rate`, never charge or draw twice. `rating_error` says why a
++ reading waits: `billing.insufficient_funds` / `billing.budget_exceeded` are tried again after
++ `BILLING_RATING_RETRY_MS` (1 h); `billing.no_rate_card` / `billing.unit_mismatch` wait until a matching card
++ is loaded (the loader makes them due again); `billing.no_subject` (no `user:usr_…` subject, e.g. an app's
++ job) is never charged. Nothing is rated while the economy is frozen.
++
## API
All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`) and are
@@@ -166,7 +203,7 @@ 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, promo_bits, payable_value_cents}` (`promo_bits`: unused free allowance, never transferable) |
| `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 |
++| `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing here: the rating sweep does, see Rates; 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 |
@@@ -174,6 +211,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `GET\|POST /api/v1/admin/provider-accounts` | `billing.ledger.admin` | list / map `{provider, username, account_id?, subject}` a provider account to its creator (allowed while frozen) |
| `POST /api/v1/admin/adjustments` | `billing.ledger.admin` | `{from: account, to: account, amount, reason, relates_to?}` (same currency; a promo account only against the other promo kind) |
| `POST /api/v1/admin/promo/grant` | `billing.ledger.admin` | `{subject, bits, service? ('*'), period? (day \| month \| none; month)}` → 201 `{allowance, transaction}`: tops up the current window (a second grant in it adds); idempotent by `Idempotency-Key` |
++| `POST /api/v1/admin/rate` | `billing.ledger.admin` | one rating pass now `{batch?}` → `{rated, skipped, busy, failed}` (refused while frozen; no `Idempotency-Key` needed: rating is idempotent per reading) |
++| `GET /api/v1/admin/budgets?subject=`, `POST /api/v1/admin/budgets` | `billing.ledger.admin` | hard usage budgets; set `{subject, service? ('*'), period? (day \| month \| none; month), budget_bits}` for the window open now (keeps what it has spent) |
| `GET /api/v1/admin/provider-events[?pending=1]`, `POST …/:id/reprocess` | `billing.ledger.admin` | receipts; retry an unprocessed/rejected one |
| `POST /api/v1/admin/sweep`, `GET /api/v1/admin/import-holds` | `billing.ledger.admin` | renewal sweep now; unmapped import users |
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
ddiff --git a/docs/cutover-usage-rating.md b/docs/cutover-usage-rating.md
nnew file mode 100644
iindex 0000000..88abf3d
---- /dev/null
++++ b/docs/cutover-usage-rating.md
@@@ -0,0 +1,150 @@
++# Cutover runbook — usage rating (OpenVibe.Billing, plan T5 step 6)
++
++This change rates stored usage readings into the ledger: a background sweep (`server/ops/rating.js`) turns each
++reading into at most one `usage` transaction, `user_credit:<subject>` → `platform_revenue` (vibes-bits), after the
++promo allowance (step 5) has covered what it can, under hard budgets. **Both gates ship closed:** the sweep is off
++(`BILLING_RATING_INTERVAL_MS` defaults to `0`) and no rate card is loaded (cards come only from
++`scripts/load-rate-cards.js` with a reviewed `OV_RATE_CARDS`), so **the deploy charges nothing**. New routes
++(`billing.ledger.admin`): `POST /api/v1/admin/rate`, `GET|POST /api/v1/admin/budgets`. No provider webhook, auth,
++capability, Contracts or importer change.
++
++## What the migration does
++
++`migrations/0004_rating.sql` (`phase: expand`), in one transaction — additive only:
++
++```sql
++ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;
++ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',
++ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage'));
++CREATE TABLE rate_cards (
++ id text COLLATE "C" PRIMARY KEY,
++ provider text COLLATE "C" NOT NULL,
++ metric text COLLATE "C" NOT NULL,
++ region text COLLATE "C",
++ unit_size numeric NOT NULL CHECK (unit_size > 0),
++ unit_price_usd numeric NOT NULL CHECK (unit_price_usd >= 0),
++ free_allowance numeric NOT NULL DEFAULT 0 CHECK (free_allowance >= 0),
++ reset_period text COLLATE "C" NOT NULL CHECK (reset_period IN ('day', 'month', 'none')),
++ effective_from date NOT NULL,
++ effective_until date CHECK (effective_until > effective_from),
++ source text COLLATE "C" NOT NULL,
++ verified_at date NOT NULL,
++ card jsonb NOT NULL,
++ loaded_at timestamptz NOT NULL
++);
++CREATE INDEX rate_cards_lookup ON rate_cards (provider, metric, effective_from DESC);
++CREATE TABLE usage_budgets (
++ subject text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ reset_period text COLLATE "C" NOT NULL CHECK (reset_period IN ('day', 'month', 'none')),
++ budget_bits bigint NOT NULL CHECK (budget_bits >= 0),
++ spent_bits bigint NOT NULL DEFAULT 0 CHECK (spent_bits >= 0),
++ window_start timestamptz NOT NULL,
++ window_end timestamptz NOT NULL CHECK (window_end > window_start),
++ updated_at timestamptz NOT NULL,
++ PRIMARY KEY (subject, service, window_start)
++);
++ALTER TABLE usage_records
++ ADD COLUMN rated_at timestamptz,
++ ADD COLUMN txn_id text COLLATE "C" REFERENCES transactions (id),
++ ADD COLUMN promo_bits bigint CHECK (promo_bits >= 0),
++ ADD COLUMN free_allowance_used numeric CHECK (free_allowance_used >= 0),
++ ADD COLUMN vibes_charged bigint CHECK (vibes_charged >= 0),
++ ADD COLUMN rating_error text COLLATE "C",
++ ADD COLUMN rating_due_at timestamptz DEFAULT 'epoch';
++CREATE INDEX usage_records_rating_due ON usage_records (at, id) WHERE rating_due_at IS NOT NULL;
++```
++
++`transactions_type_check` is the name PostgreSQL gave 0001's inline CHECK on `transactions.type` (checked on a
++scratch database: `SELECT conname FROM pg_constraint WHERE conrelid = 'transactions'::regclass AND contype = 'c'`);
++the new CHECK is the old list plus `usage`, so every existing row satisfies it and the `ADD CONSTRAINT` validation
++scan only reads `transactions` (a few hundred rows in production). The new `usage_records` columns are nullable or
++carry a constant default (`rating_due_at` = `'epoch'`), so PostgreSQL adds them without rewriting the table; the
++`txn_id` foreign key is validated over an all-NULL column. `rate_cards` and `usage_budgets` start empty.
++
++Every reading already stored (PR #6) becomes **due** (`rating_due_at` = epoch) but is charged only once the sweep
++is switched on *and* a card matching its provider and resource is loaded; without a card it is marked
++`billing.no_rate_card` and waits.
++
++## What is not changed
++
++- No ledger row (`transactions`, `ledger_entries`, `account_balances`) and no promo allowance is written, updated
++ or deleted by the migration or the deploy.
++- No existing column, index, trigger or account kind changes (`accounts_kind_check` is untouched).
++- `POST /api/v1/usage` still only stores: it never charges.
++
++## Prerequisites
++
++1. **Snapshot the ledger:** `sudo ovhost backup --all --logical`; note the stamp
++ (`/var/backups/openvibe/billing/<stamp>/billing.dump`).
++2. **Confirm the unit is healthy:** `sudo systemctl status openvibe-billing` is active (running) and
++ `/api/ready` returns 200 with `db: "ok"`.
++3. **Confirm no freeze in flight:** `node scripts/freeze.js status` (from `/opt/openvibe.billing`) returns
++ `"on": false`.
++4. **Confirm the gates are closed in `/etc/openvibe/billing.env`:** no `BILLING_RATING_INTERVAL_MS` (or `0`), and
++ no `OV_RATE_CARDS` unless a reviewed card file is intended for a later, separate step.
++
++## Rehearsal
++
++`ov rehearse` builds the base from main's migrations (0001–0003), loads the PostgreSQL seed below (a purchase, a tip,
++their accounts and balances, one stored usage reading and one promo allowance), applies 0004, runs the migrations a
++second time (nothing must apply), then the check: the widened CHECK holds `usage`, the five balances, two
++transactions, six entries and the allowance are exactly as seeded, the stored reading is unrated and due, and
++`rate_cards` and `usage_budgets` are empty.
++
++```rehearse
++seed: test/fixtures/usage-rating-seed.sql
++node -e 'const a=require("assert"),{createDb}=require("openvibe-sdk/db");const db=createDb({url:process.env.DATABASE_DIRECT_URL,service:"rehearsal",max:1});(async()=>{const c=await db.prepare("SELECT pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid = ?::regclass AND conname = ?").get("transactions","transactions_type_check");for(const k of ["purchase","donation","import","usage"])a.ok(c.d.includes(k),k);const b=await db.prepare("SELECT a.kind, a.owner_subject AS owner, b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id ORDER BY a.id").all();a.deepStrictEqual(b.map((r)=>[r.kind,r.owner,Number(r.balance)]),[["provider_clearing","powerchat",-1000],["fx_conversion",null,1000],["fx_conversion",null,-1000],["user_credit","usr_01JAB2C3D4E5F6G7H8J9K0SEED",600],["creator_payable","usr_01JAB2C3D4E5F6G7H8J9K0SEEE",400]]);a.strictEqual(Number((await db.prepare("SELECT COUNT(*) AS n FROM ledger_entries").get()).n),6);a.strictEqual(Number((await db.prepare("SELECT COUNT(*) AS n FROM transactions").get()).n),2);const p=await db.prepare("SELECT granted_bits, used_bits FROM promo_allowances").all();a.deepStrictEqual(p.map((r)=>[Number(r.granted_bits),Number(r.used_bits)]),[[100,0]]);const u=await db.prepare("SELECT rated_at, txn_id, vibes_charged, promo_bits, free_allowance_used, rating_error, rating_due_at FROM usage_records").all();a.strictEqual(u.length,1);a.deepStrictEqual([u[0].rated_at,u[0].txn_id,u[0].vibes_charged,u[0].promo_bits,u[0].free_allowance_used,u[0].rating_error],[null,null,null,null,null,null]);a.strictEqual(new Date(u[0].rating_due_at).getTime(),0);for(const t of ["rate_cards","usage_budgets"])a.strictEqual(Number((await db.prepare("SELECT COUNT(*) AS n FROM "+t).get()).n),0,t);console.log("0004: transactions_type_check widened by usage, 5 balances, 2 transactions, 6 entries and the allowance unchanged, the stored reading unrated and due, rate_cards and usage_budgets empty")})().finally(()=>db.close()).catch((e)=>{console.error(e);process.exitCode=1})'
++```
++
++By hand on a restored snapshot (a scratch role, never production): run the migrations with `openDb` as in PR #6's
++runbook, then
++
++```sql
++SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'transactions'::regclass AND conname = 'transactions_type_check';
++SELECT id, name, phase FROM ov_migrations WHERE id = '0004'; -- 0004 | rating | expand
++SELECT COUNT(*) FROM rate_cards; -- 0
++SELECT COUNT(*) FROM usage_records WHERE rated_at IS NOT NULL; -- 0
++```
++
++and compare `node scripts/reconcile.js` before and after: the same account, transaction and entry counts, every
++check ok.
++
++## Cutover
++
++1. **Merge the PR** once the rehearsal is green.
++2. **Deploy:** `sudo ovhost deploy billing "usage rating — migration 0004 (sweep off, no rate cards)"`. No lockfile
++ change. The boot applies `0004_rating.sql` once (`migrations: applied 0004_rating (expand, NNms)`).
++3. **Verify on production:**
++ - `/api/ready` is 200; `ov_migrations` has the `0004` row; `\d usage_records` lists the seven new columns.
++ - The journal shows no `[Billing] rating:` line (the sweep is off); `SELECT COUNT(*) FROM transactions WHERE
++ type = 'usage'` is 0.
++ - `node scripts/reconcile.js` shows the same totals as before the deploy, every check ok.
++4. **Switching rating on is a later, reviewed step, not part of this deploy:** a reviewed card file in
++ `OV_RATE_CARDS` → `node scripts/load-rate-cards.js --dry-run`, then without `--dry-run`; optional budgets
++ (`POST /api/v1/admin/budgets`); then `POST /api/v1/admin/rate` once (or set `BILLING_RATING_INTERVAL_MS` and
++ restart) and reconcile.
++
++## Rollback
++
++- **No reading has been rated (the day of the merge).** `sudo ovhost rollback billing`, then, in one transaction:
++ ```sql
++ BEGIN;
++ DROP INDEX usage_records_rating_due;
++ ALTER TABLE usage_records DROP COLUMN rated_at, DROP COLUMN txn_id, DROP COLUMN promo_bits,
++ DROP COLUMN free_allowance_used, DROP COLUMN vibes_charged, DROP COLUMN rating_error, DROP COLUMN rating_due_at;
++ DROP TABLE usage_budgets;
++ DROP TABLE rate_cards;
++ ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;
++ ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription',
++ 'subscription_share', 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import'));
++ DELETE FROM ov_migrations WHERE id = '0004';
++ COMMIT;
++ ```
++ Leaving them in place is also safe: the previous release never reads the new columns or tables and never
++ writes a `usage` transaction.
++- **Readings have been rated.** The journal is append-only: `usage` transactions cannot be deleted and the narrow
++ CHECK cannot be restored over them. Stop rating first (unset `BILLING_RATING_INTERVAL_MS`, restart), then roll
++ back the code only (`sudo ovhost rollback billing`); the previous release ignores the new columns and tables.
++ A wrong charge is corrected with a reversing `POST /api/v1/admin/adjustments` (`platform_revenue` →
++ `user_credit:<subject>`), never by editing rows; the snapshot is the last resort.
ddiff --git a/migrations/0004_rating.sql b/migrations/0004_rating.sql
nnew file mode 100644
iindex 0000000..2f6f5a4
---- /dev/null
++++ b/migrations/0004_rating.sql
@@@ -0,0 +1,63 @@
++-- phase: expand
++-- Rating (plan T5 step 6): a stored usage reading becomes one `usage` transaction, user_credit:<subject> → platform_revenue
++-- (vibes-bits), after the promo allowance (0003) has covered what it can; ops/rating.js, run by a background sweep that is
++-- off by default. Nothing is charged without a rate card, and rate cards are loaded only by review
++-- (scripts/load-rate-cards.js, platform.rate-card@1). Additive only: the one ALTER of an existing CHECK widens
++-- transactions_type_check (the name PostgreSQL gave 0001's inline CHECK on transactions.type) by 'usage'; every other
++-- change is a new table or a nullable column.
++
++ALTER TABLE transactions DROP CONSTRAINT transactions_type_check;
++ALTER TABLE transactions ADD CONSTRAINT transactions_type_check CHECK (type IN ('purchase', 'donation', 'subscription', 'subscription_share',
++ 'cashout_request', 'cashout_paid', 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage'));
++
++-- One provider price (platform.rate-card@1), as loaded; `card` is the card exactly as reviewed. A reading matches a card
++-- when its provider and resource are the card's provider and metric, its region fits (a card without a region fits
++-- every region) and its day is in [effective_from, effective_until).
++CREATE TABLE rate_cards (
++ id text COLLATE "C" PRIMARY KEY,
++ provider text COLLATE "C" NOT NULL,
++ metric text COLLATE "C" NOT NULL,
++ region text COLLATE "C",
++ unit_size numeric NOT NULL CHECK (unit_size > 0),
++ unit_price_usd numeric NOT NULL CHECK (unit_price_usd >= 0),
++ free_allowance numeric NOT NULL DEFAULT 0 CHECK (free_allowance >= 0),
++ reset_period text COLLATE "C" NOT NULL CHECK (reset_period IN ('day', 'month', 'none')),
++ effective_from date NOT NULL,
++ effective_until date CHECK (effective_until > effective_from),
++ source text COLLATE "C" NOT NULL,
++ verified_at date NOT NULL,
++ card jsonb NOT NULL,
++ loaded_at timestamptz NOT NULL
++);
++CREATE INDEX rate_cards_lookup ON rate_cards (provider, metric, effective_from DESC);
++
++-- Hard budgets: the most a subject's readings of one service ('*' = every service) may draw from their Vibes in one
++-- window. A charge that would take spent_bits past budget_bits is refused whole; spent_bits moves in the transaction
++-- that posts the charge. A subject + service without a row is limited only by their spendable balance.
++CREATE TABLE usage_budgets (
++ subject text COLLATE "C" NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ reset_period text COLLATE "C" NOT NULL CHECK (reset_period IN ('day', 'month', 'none')),
++ budget_bits bigint NOT NULL CHECK (budget_bits >= 0),
++ spent_bits bigint NOT NULL DEFAULT 0 CHECK (spent_bits >= 0),
++ window_start timestamptz NOT NULL,
++ window_end timestamptz NOT NULL CHECK (window_end > window_start),
++ updated_at timestamptz NOT NULL,
++ PRIMARY KEY (subject, service, window_start)
++);
++
++-- A reading's rating. rated_at/txn_id/vibes_charged are set together once it is charged (vibes_charged absent = not
++-- rated yet); promo_bits is the allowance it consumed and free_allowance_used the part of `quantity` that covered, in
++-- the reading's own unit. rating_error says why it is not rated yet; rating_due_at is when the sweep may try it (NULL:
++-- never by itself — rated, not chargeable, or waiting for a rate card, which loading a matching card re-arms). The
++-- constant default makes every reading, stored before or after this migration, due at once (no table rewrite).
++ALTER TABLE usage_records
++ ADD COLUMN rated_at timestamptz,
++ ADD COLUMN txn_id text COLLATE "C" REFERENCES transactions (id),
++ ADD COLUMN promo_bits bigint CHECK (promo_bits >= 0),
++ ADD COLUMN free_allowance_used numeric CHECK (free_allowance_used >= 0),
++ ADD COLUMN vibes_charged bigint CHECK (vibes_charged >= 0),
++ ADD COLUMN rating_error text COLLATE "C",
++ ADD COLUMN rating_due_at timestamptz DEFAULT 'epoch';
++
++CREATE INDEX usage_records_rating_due ON usage_records (at, id) WHERE rating_due_at IS NOT NULL;
ddiff --git a/package.json b/package.json
iindex 79af20b..c00c1ac 100644
---- a/package.json
++++ b/package.json
@@@ -11,6 +11,7 @@
"reconcile": "node scripts/reconcile.js",
"freeze": "node scripts/freeze.js",
"import-live": "node scripts/import-live.js",
++ "load-rate-cards": "node scripts/load-rate-cards.js",
"test:pg": "BILLING_TEST_STORE=pg node test/run.js"
},
"keywords": [
ddiff --git a/scripts/load-rate-cards.js b/scripts/load-rate-cards.js
nnew file mode 100644
iindex 0000000..a92a525
---- /dev/null
++++ b/scripts/load-rate-cards.js
@@@ -0,0 +1,35 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Load reviewed rate cards into Billing (plan T5 step 6). The only way a rate card reaches the ledger: a card is a
++ * price, changed only by review and never written by an AI or a request on its own (platform.rate-card@1).
++ *
++ * OV_RATE_CARDS=/etc/openvibe/rate-cards.json node scripts/load-rate-cards.js [--dry-run]
++ * OV_RATE_CARDS='[{"id": "…", …}]' node scripts/load-rate-cards.js
++ *
++ * Every card is validated against platform.rate-card@1 first; one invalid card loads nothing. A card is upserted by
++ * id. Readings that were left unrated for want of a matching card become due again (the sweep rates them on its
++ * next pass, if BILLING_RATING_INTERVAL_MS is on, or POST /api/v1/admin/rate). Prints { loaded, rearmed }.
++ */
++const rating = require('../server/ops/rating');
++
++/** The command on a database handle (the script opens the service's own; tests pass theirs). */
++async function load({ cards: source, dryRun = false }, { db, now = () => Date.now() }) {
++ const cards = rating.parseCards(source);
++ if (!cards.length) throw Object.assign(new Error('OV_RATE_CARDS is empty: no rate card to load (nothing is ever charged without one)'), { usage: true });
++ return await rating.loadCards({ db, now }, cards, { dryRun });
++}
++
++if (require.main === module) {
++ (async () => {
++ const { loadConfig } = require('../server/config');
++ const { openDb } = require('../server/db');
++ const config = loadConfig();
++ const db = await openDb(config);
++ try {
++ console.log(JSON.stringify(await load({ cards: config.rating.cards, dryRun: process.argv.includes('--dry-run') }, { db })));
++ } finally { await db.close(); }
++ })().catch((err) => { console.error(err.usage ? err.message : (err.detail || err)); process.exit(err.usage ? 2 : 1); });
++}
++
++module.exports = { load };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 18887b8..6cbffce 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -27,6 +27,7 @@ const { reconcile } = require('../reconcile');
const external = require('../ops/external');
const usage = require('../ops/usage');
const promo = require('../ops/promo');
++const rating = require('../ops/rating');
const CAP = {
intent: 'billing.intent.create',
@@@ -250,7 +251,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
// ── 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
++ // Stored and reported; never charged here (the rating sweep, ops/rating.js, charges later). 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 });
@@@ -314,6 +315,21 @@ function v1Router({ ctx, auth, adapters, limits }) {
const out = await promo.grant(ctx, { subject: b.subject, service: b.service, bits: b.bits, period: b.period, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json({ allowance: out.allowance, ...txnOut(out.txn) });
}));
++ // Usage rating (ops/rating.js): one pass now, the same as the background sweep; rating is idempotent per reading.
++ r.post('/admin/rate', auth.needs(CAP.admin), notFrozen, wrap(async (req, res) => {
++ const b = req.body || {};
++ res.json(await rating.sweep(ctx, { batch: b.batch }));
++ }));
++ // Hard budgets on what a subject's metered usage may draw from their Vibes per window.
++ r.get('/admin/budgets', ...read(CAP.admin, async (req, res) => {
++ const subject = pathSubject(req.query.subject);
++ const rows = await db.prepare('SELECT * FROM usage_budgets WHERE subject = ? ORDER BY service, window_start DESC').all(subject);
++ res.json({ budgets: rows.map(rating.presentBudget) });
++ }));
++ r.post('/admin/budgets', ...write(CAP.admin, async (req, res) => {
++ const b = req.body || {};
++ res.status(201).json({ budget: await rating.setBudget(ctx, { subject: b.subject, service: b.service, period: b.period, budget_bits: b.budget_bits }) });
++ }));
r.get('/admin/provider-events', ...read(CAP.admin, async (req, res) => {
const pending = req.query.pending === '1' || req.query.pending === 'true';
const rows = (await db.prepare(`SELECT * FROM provider_events ${pending ? 'WHERE processed_at IS NULL' : ''} ORDER BY id DESC LIMIT 200`).all()).map(providers.parseRow);
ddiff --git a/server/config.js b/server/config.js
iindex 5717fbd..41464eb 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -160,6 +160,16 @@ function loadConfig(env = process.env) {
// runs older than reconcileKeepDays are pruned, failed ones are kept.
reconcileIntervalMs: int(env.BILLING_RECONCILE_INTERVAL_MS, 60 * 60 * 1000),
reconcileKeepDays: int(env.BILLING_RECONCILE_KEEP_DAYS, 30),
++ // Usage rating (ops/rating.js): off by default (0); BILLING_RATING_BATCH readings per pass.
++ ratingIntervalMs: int(env.BILLING_RATING_INTERVAL_MS, 0),
++ ratingBatch: Math.max(1, Math.min(5000, int(env.BILLING_RATING_BATCH, 500))),
++ },
++ rating: {
++ // Reviewed rate cards (platform.rate-card@1): a JSON array, inline or a file path; loaded only by
++ // scripts/load-rate-cards.js, never by the service itself.
++ cards: env.OV_RATE_CARDS || '',
++ // A reading the balance or a hard budget refused is tried again this long after.
++ retryMs: Math.max(60_000, int(env.BILLING_RATING_RETRY_MS, 60 * 60 * 1000)),
},
reconcile: {
// A provider receipt still unprocessed this long after it arrived (economy not frozen)
ddiff --git a/server/db.js b/server/db.js
iindex b3b5afe..56a0bbf 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -23,7 +23,7 @@ const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'p
const PROMO_KINDS = ['promo_credit', 'promo_reserve'];
const CURRENCIES = ['vibes-bits', 'usd-cents'];
const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid',
-- 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import'];
++ 'cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import', 'usage'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
ddiff --git a/server/index.js b/server/index.js
iindex 367fa4a..b43a4e8 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -8,13 +8,15 @@
* retried every minute; the subscription renewal sweep runs hourly; reconciliation runs hourly
* (BILLING_RECONCILE_INTERVAL_MS, 0 = off) and is stored in reconciliation_runs (API
* /api/v1/admin/reconciliations, the console, /metrics); the outbox relay publishes to
-- * OpenVibe.Events when EVENTS_URL is set. While the economy is frozen only the relay and the
++ * OpenVibe.Events when EVENTS_URL is set. Usage rating (ops/rating.js) runs only when
++ * BILLING_RATING_INTERVAL_MS is set (default 0 = off). While the economy is frozen only the relay and the
* reconciliation (it only reads the books) run.
*/
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const providers = require('./providers');
const subscriptions = require('./ops/subscriptions');
++const rating = require('./ops/rating');
const { createRelay } = require('./outbox');
const { isFrozen } = require('./ops/common');
const { runScheduled } = require('./reconcile');
@@@ -43,6 +45,12 @@ const { runScheduled } = require('./reconcile');
first.unref();
timers.push(first);
}
++ if (config.jobs.ratingIntervalMs > 0) {
++ every(config.jobs.ratingIntervalMs, async () => {
++ const out = await rating.sweep(ctx);
++ if (out.rated || out.skipped || out.failed) console.log(`[Billing] rating: ${out.rated} rated, ${out.skipped} left unrated, ${out.failed} failed`);
++ });
++ }
relay.start();
}
ddiff --git a/server/ops/rating.js b/server/ops/rating.js
nnew file mode 100644
iindex 0000000..139debd
---- /dev/null
++++ b/server/ops/rating.js
@@@ -0,0 +1,309 @@
++'use strict';
++
++/**
++ * Rating (plan T5 step 6): a stored usage reading (ops/usage.js) becomes a charge. The reading's provider and resource
++ * pick a rate card (the card's provider and metric); price × quantity is rounded up to whole vibes-bits once; the
++ * subject's promo allowance (ops/promo.js) covers what it can first, and the rest is one `usage` transaction,
++ * user_credit:<subject> → platform_revenue, idempotent by `usage:<reading idempotency_key>`.
++ *
++ * Every reading is rated in one serializable transaction that claims its row (FOR UPDATE SKIP LOCKED: a second sweep
++ * passes it by), re-reads it, draws the promo allowance, checks the spendable balance and the hard budgets, posts, and
++ * marks the row rated — all or nothing, so two sweeps (or a sweep and POST /admin/rate) never charge or draw twice.
++ *
++ * Never charged, left unrated with rating_error set: a reading with no user subject (permanently), with no rate card
++ * or whose unit does not fit the card (until loadCards() loads a card that matches it), that the balance or a hard
++ * budget refuses (the sweep tries it again after BILLING_RATING_RETRY_MS), or that is otherwise refused (e.g. a charge
++ * past the exact integer range; left for review). Nothing is ever partly charged.
++ *
++ * A card's own free_allowance (the provider's free tier) is kept with the card and not applied per reading: a
++ * person's free allowance is their promo allowance.
++ */
++const { validate } = require('openvibe-contracts');
++const { post, requireFunds, iso, money, BillingError } = require('../ledger');
++const { A, BITS, entry, fail, userSubject, positiveInt, isFrozen } = require('./common');
++const promo = require('./promo');
++
++const ACTOR = { type: 'service', id: 'billing' };
++const ERR = {
++ noSubject: 'billing.no_subject',
++ noCard: 'billing.no_rate_card',
++ unit: 'billing.unit_mismatch',
++ funds: 'billing.insufficient_funds',
++ budget: 'billing.budget_exceeded',
++};
++// Refusals that may pass later on their own (a top-up, the next budget window): the sweep retries them.
++const RETRY = [ERR.funds, ERR.budget];
++// Refusals that only a loaded rate card can lift: loadCards() makes matching readings due again.
++const AWAIT_CARD = [ERR.noCard, ERR.unit];
++
++const json = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
++const ms = (v) => new Date(v).getTime();
++
++/** A finite non-negative JS number as an exact fraction [numerator, denominator] of BigInts (its shortest decimal form). */
++function frac(x) {
++ const m = typeof x === 'number' && Number.isFinite(x) ? /^(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$/.exec(String(x)) : null;
++ if (!m) fail(422, 'billing.invalid_amount', `${x} is not a non-negative number`);
++ const digits = BigInt(m[1] + (m[2] || ''));
++ const exp = Number(m[3] || 0) - (m[2] || '').length;
++ return exp >= 0 ? [digits * 10n ** BigInt(exp), 1n] : [digits, 10n ** BigInt(-exp)];
++}
++
++/** Vibes-bits for `quantity` under `card`: quantity / unit_size × unit_price_usd × bitsPerUsd, exact, rounded up once. */
++function chargeFor(card, quantity, { bitsPerUsd }) {
++ const [qn, qd] = frac(quantity);
++ const [pn, pd] = frac(card.unit_price_usd);
++ const [un, ud] = frac(card.unit_size);
++ const [bn, bd] = frac(bitsPerUsd);
++ if (un === 0n) fail(422, 'billing.invalid_rate_card', `rate card ${card.id} has unit_size 0`);
++ const num = qn * pn * bn * ud;
++ const den = qd * pd * bd * un;
++ const bits = (num + den - 1n) / den;
++ if (bits > BigInt(Number.MAX_SAFE_INTEGER)) fail(422, 'billing.amount_overflow', `the charge for ${quantity} under ${card.id} is beyond the exact integer range`);
++ return Number(bits);
++}
++
++/**
++ * The card for a reading, or null: same provider, metric = the reading's resource, region fits (a card without a
++ * region fits every region), and the reading's UTC day in [effective_from, effective_until). A region-specific card
++ * wins over a general one, then the latest effective_from, then the id.
++ */
++function pickCard(cards, r) {
++ const day = new Date(Date.parse(r.at)).toISOString().slice(0, 10);
++ const fit = cards.filter((c) => c.provider === r.provider && c.metric === r.resource && (c.region == null || c.region === r.region)
++ && c.effective_from <= day && (c.effective_until == null || day < c.effective_until));
++ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
++ fit.sort((a, b) => (b.region != null) - (a.region != null) || cmp(b.effective_from, a.effective_from) || cmp(a.id, b.id));
++ return fit[0] || null;
++}
++
++const unitKey = (s) => String(s || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/s$/, '');
++
++/**
++ * Whether a reading's unit is the one its card prices: the card's metric names the unit, so the unit (case, spacing
++ * and a plural s aside) is the metric itself or the metric's leading word ("GiB" fits gib-delivered, "requests" fits
++ * request, "GB" fits gb-month). Anything else (MiB against gib-delivered) is not converted: the reading waits.
++ */
++function unitFits(card, unit) {
++ const u = unitKey(unit);
++ const m = unitKey(card.metric);
++ return !!u && (u === m || m.startsWith(`${u}-`));
++}
++
++/** The chargeable person of a reading ('user:usr_…' or 'usr_…'), or null (no subject, an app or a guest). */
++function subjectOf(s) {
++ if (typeof s !== 'string') return null;
++ const id = s.startsWith('user:') ? s.slice(5) : s;
++ try { return userSubject({ type: 'user', id }); } catch { return null; }
++}
++
++/**
++ * The hard budgets on `subject` for `service` and for every service ('*'): the newest row of each is the policy; its
++ * window rolls over to the one open now (same budget, nothing spent) once it ends. Refuses billing.budget_exceeded
++ * when `bits` would take any of them past budget_bits; otherwise adds `bits` to each. Inside the rating transaction.
++ */
++async function spendBudgets(ctx, subject, service, bits) {
++ const { db } = ctx;
++ const now = ctx.now();
++ const nowIso = iso(now);
++ const rows = [];
++ for (const svc of service === '*' ? ['*'] : [service, '*']) {
++ let row = await db.prepare('SELECT * FROM usage_budgets WHERE subject = ? AND service = ? ORDER BY updated_at DESC, window_start DESC LIMIT 1').get(subject, svc);
++ if (!row) continue;
++ if (ms(row.window_end) <= now) {
++ const { start, end } = promo.windowOf(row.reset_period, now);
++ row = await db.prepare(`INSERT INTO usage_budgets (subject, service, reset_period, budget_bits, spent_bits, window_start, window_end, updated_at)
++ VALUES (?, ?, ?, ?, 0, ?::timestamptz, ?::timestamptz, ?::timestamptz)
++ ON CONFLICT (subject, service, window_start) DO UPDATE SET updated_at = excluded.updated_at RETURNING *`)
++ .get(subject, svc, row.reset_period, row.budget_bits, start, end, nowIso);
++ }
++ if (ms(row.window_start) > now) continue;
++ const spent = Number(row.spent_bits);
++ const budget = Number(row.budget_bits);
++ if (spent + bits > budget) {
++ fail(409, ERR.budget, `the ${svc === '*' ? 'all-services' : svc} budget of ${subject} has ${Math.max(0, budget - spent)} of ${budget} bits left this window; the charge is ${bits}`,
++ { service: svc, budget_bits: budget, spent_bits: spent, required: bits, window_end: new Date(row.window_end).toISOString() });
++ }
++ rows.push(row);
++ }
++ for (const row of rows) {
++ await db.prepare('UPDATE usage_budgets SET spent_bits = spent_bits + ?, updated_at = ?::timestamptz WHERE subject = ? AND service = ? AND window_start = ?::timestamptz')
++ .run(bits, nowIso, row.subject, row.service, new Date(row.window_start).toISOString());
++ }
++}
++
++/** Leave a reading unrated: why, and when the sweep may try it again (null: not by itself). Never touches a rated row. */
++async function leave(ctx, id, why, retry) {
++ const due = retry ? iso(ctx.now() + (ctx.config.rating.retryMs)) : null;
++ await ctx.db.prepare('UPDATE usage_records SET rating_error = ?, rating_due_at = ?::timestamptz WHERE id = ? AND rated_at IS NULL').run(why, due, id);
++}
++
++function outcome(row) {
++ if (row.rated_at) {
++ return { id: String(row.id), rated: true, vibes_charged: Number(row.vibes_charged), promo_bits: Number(row.promo_bits),
++ free_allowance_used: Number(row.free_allowance_used), txn_id: row.txn_id || null };
++ }
++ return { id: String(row.id), rated: false, rating_error: row.rating_error || null };
++}
++
++/**
++ * Rate one usage_records row by id. Returns { rated: true, vibes_charged, promo_bits, free_allowance_used, txn_id,
++ * replay? } | { rated: false, rating_error } | { busy: true } (another rating holds the row). A refusal by the balance
++ * or a budget rolls the whole attempt back (promo draw included) and is then recorded on the row.
++ */
++async function rate(ctx, id) {
++ const { db } = ctx;
++ try {
++ return await money(db, async () => {
++ const row = await db.prepare('SELECT * FROM usage_records WHERE id = ? FOR UPDATE SKIP LOCKED').get(id);
++ if (!row) return { id: String(id), busy: true };
++ if (row.rated_at) return { ...outcome(row), replay: true };
++ if (await isFrozen(db)) fail(503, 'billing.frozen', 'the economy is frozen: nothing is rated');
++ const r = json(row.reading);
++ const subject = subjectOf(r.subject);
++ if (!subject) { await leave(ctx, row.id, ERR.noSubject, false); return { id: String(row.id), rated: false, rating_error: ERR.noSubject }; }
++ const cards = (await db.prepare('SELECT card FROM rate_cards WHERE provider = ? AND metric = ?').all(String(r.provider || ''), String(r.resource || ''))).map((c) => json(c.card));
++ const card = pickCard(cards, r);
++ const unfit = !card ? ERR.noCard : (!unitFits(card, r.unit) ? ERR.unit : null);
++ if (unfit) { await leave(ctx, row.id, unfit, false); return { id: String(row.id), rated: false, rating_error: unfit }; }
++
++ const key = `usage:${r.idempotency_key}`;
++ const gross = chargeFor(card, r.quantity, ctx.rates);
++ const drawn = gross > 0 ? await promo.consume(ctx, { subject, service: r.service, bits: gross, idempotencyKey: key, actor: ACTOR }) : { bits: 0, txn: null };
++ const charge = gross - drawn.bits;
++ let txn = null;
++ if (charge > 0) {
++ await requireFunds(db, A.credit(subject), charge, 'Vibes');
++ await spendBudgets(ctx, subject, r.service, charge);
++ ({ txn } = await post(ctx, {
++ type: 'usage', idempotencyKey: key, actor: ACTOR, fromSubject: subject,
++ entries: [entry(A.credit(subject), -charge), entry(A.revenue(BITS), charge)],
++ metadata: {
++ usage_record: String(row.id), reading: r.idempotency_key, service: r.service, operation: r.operation, resource: r.resource,
++ provider: r.provider, region: r.region || null, quantity: r.quantity, unit: r.unit, at: r.at,
++ rate_card: { id: card.id, unit_size: card.unit_size, unit_price_usd: card.unit_price_usd }, bits_per_usd: ctx.rates.bitsPerUsd,
++ gross_bits: gross, promo_bits: drawn.bits, vibes_charged: charge, promo_txn: drawn.txn ? drawn.txn.id : null,
++ route_epoch: r.route_epoch == null ? null : r.route_epoch, trace_id: r.trace_id || null,
++ },
++ }));
++ }
++ // The part of quantity the allowance paid for, in the reading's own unit.
++ const free = drawn.bits === 0 ? 0 : (drawn.bits === gross ? r.quantity : (r.quantity * drawn.bits) / gross);
++ const txnId = txn ? txn.id : (drawn.txn ? drawn.txn.id : null);
++ const done = await db.prepare(`UPDATE usage_records SET rated_at = ?::timestamptz, txn_id = ?, promo_bits = ?, free_allowance_used = ?, vibes_charged = ?,
++ rating_error = NULL, rating_due_at = NULL WHERE id = ? AND rated_at IS NULL RETURNING *`).get(iso(ctx.now()), txnId, drawn.bits, free, charge, row.id);
++ return outcome(done);
++ });
++ } catch (e) {
++ // Any other refusal of this reading (e.g. a charge past the exact integer range) is recorded and not retried by itself.
++ if (!(e instanceof BillingError) || e.status >= 500 || e.code === 'billing.frozen') throw e;
++ await money(db, async () => leave(ctx, id, e.code, RETRY.includes(e.code)));
++ return { id: String(id), rated: false, rating_error: e.code, detail: e.detail };
++ }
++}
++
++/**
++ * One pass: up to `batch` due readings, oldest first, each in its own transaction. Does nothing while the economy is
++ * frozen. Returns { rated, skipped, busy, failed }.
++ */
++async function sweep(ctx, { batch } = {}) {
++ const { db } = ctx;
++ const out = { rated: 0, skipped: 0, busy: 0, failed: 0, frozen: false };
++ if (await isFrozen(db)) return { ...out, frozen: true };
++ const n = positiveInt(batch || ctx.config.jobs.ratingBatch, 'batch', 5000);
++ const due = await db.prepare(`SELECT id FROM usage_records WHERE rating_due_at IS NOT NULL AND rating_due_at <= ?::timestamptz
++ ORDER BY at, id LIMIT ?`).all(iso(ctx.now()), n);
++ for (const { id } of due) {
++ let res;
++ try { res = await rate(ctx, id); } catch (e) {
++ if (e instanceof BillingError && e.code === 'billing.frozen') return { ...out, frozen: true };
++ out.failed++;
++ (ctx.log || console).warn(`[Billing] rating reading ${id}: ${e.message}`);
++ continue;
++ }
++ if (res.busy) out.busy++;
++ else if (res.rated && !res.replay) out.rated++;
++ else if (!res.rated) out.skipped++;
++ }
++ return out;
++}
++
++/** Cards from OV_RATE_CARDS: a JSON array of platform.rate-card@1, inline or in the file the value names. */
++function parseCards(source, { readFile = (p) => require('fs').readFileSync(p, 'utf8') } = {}) {
++ const raw = String(source || '').trim();
++ if (!raw) return [];
++ const parsed = JSON.parse(raw.startsWith('[') ? raw : readFile(raw));
++ if (!Array.isArray(parsed)) throw new Error('OV_RATE_CARDS must be a JSON array of platform.rate-card@1');
++ return parsed;
++}
++
++/**
++ * Load reviewed rate cards (scripts/load-rate-cards.js; never from a request): every card must be a valid
++ * platform.rate-card@1 with unit_size > 0 and ids unique, or nothing is loaded. A card is upserted by id (the charge
++ * records the price it used). Readings left waiting for a card (no card, or a unit that did not fit) whose provider
++ * and resource match a loaded card become due again. Returns { loaded, rearmed }.
++ */
++async function loadCards(ctx, cards, { dryRun = false } = {}) {
++ const { db } = ctx;
++ const errors = [];
++ const seen = new Set();
++ cards.forEach((c, i) => {
++ const v = validate('platform.rate-card@1', c);
++ if (!v.valid) { errors.push(`card ${i} (${c && c.id}): ${v.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ')}`); return; }
++ if (!(c.unit_size > 0)) errors.push(`card ${c.id}: unit_size must be more than 0`);
++ if (c.effective_until && c.effective_until <= c.effective_from) errors.push(`card ${c.id}: effective_until must be after effective_from`);
++ if (seen.has(c.id)) errors.push(`card ${c.id}: id appears twice`);
++ seen.add(c.id);
++ });
++ if (errors.length) fail(422, 'billing.invalid_rate_card', `rate cards refused, nothing loaded: ${errors.join(' | ')}`, errors);
++ if (dryRun) return { loaded: 0, rearmed: 0, valid: cards.length };
++ return await db.tx(async () => {
++ const now = iso(ctx.now());
++ let rearmed = 0;
++ for (const c of cards) {
++ await db.prepare(`INSERT INTO rate_cards (id, provider, metric, region, unit_size, unit_price_usd, free_allowance, reset_period,
++ effective_from, effective_until, source, verified_at, card, loaded_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?::date, ?::date, ?, ?::date, ?::jsonb, ?::timestamptz)
++ ON CONFLICT (id) DO UPDATE SET provider = excluded.provider, metric = excluded.metric, region = excluded.region,
++ unit_size = excluded.unit_size, unit_price_usd = excluded.unit_price_usd, free_allowance = excluded.free_allowance,
++ reset_period = excluded.reset_period, effective_from = excluded.effective_from, effective_until = excluded.effective_until,
++ source = excluded.source, verified_at = excluded.verified_at, card = excluded.card, loaded_at = excluded.loaded_at`)
++ .run(c.id, c.provider, c.metric, c.region || null, c.unit_size, c.unit_price_usd, c.free_allowance, c.reset_period,
++ c.effective_from, c.effective_until || null, c.source, c.verified_at, JSON.stringify(c), now);
++ const r = await db.prepare(`UPDATE usage_records SET rating_error = NULL, rating_due_at = ?::timestamptz
++ WHERE rated_at IS NULL AND rating_error IN (?, ?) AND reading->>'provider' = ? AND reading->>'resource' = ?`)
++ .run(now, ...AWAIT_CARD, c.provider, c.metric);
++ rearmed += r.changes;
++ }
++ return { loaded: cards.length, rearmed };
++ });
++}
++
++/**
++ * Set the hard budget of `subject` for `service` ('*' = every service) in the window of `period` open now: the window's
++ * row keeps what it has spent and takes the new budget_bits (0 stops every further charge). Returns the row.
++ */
++async function setBudget(ctx, input) {
++ const { db } = ctx;
++ const subject = userSubject(input.subject);
++ const service = input.service == null || input.service === '' ? '*' : String(input.service).trim().slice(0, 100);
++ const period = input.period == null ? 'month' : input.period;
++ if (!promo.PERIODS.includes(period)) fail(422, 'billing.invalid_input', `period must be one of ${promo.PERIODS.join(', ')}`);
++ const bits = Number(input.budget_bits);
++ if (!Number.isSafeInteger(bits) || bits < 0) fail(422, 'billing.invalid_amount', 'budget_bits must be a non-negative integer');
++ const now = iso(ctx.now());
++ const { start, end } = promo.windowOf(period, ctx.now());
++ const row = await money(db, async () => await db.prepare(`INSERT INTO usage_budgets (subject, service, reset_period, budget_bits, spent_bits, window_start, window_end, updated_at)
++ VALUES (?, ?, ?, ?, 0, ?::timestamptz, ?::timestamptz, ?::timestamptz)
++ ON CONFLICT (subject, service, window_start) DO UPDATE SET reset_period = excluded.reset_period, budget_bits = excluded.budget_bits,
++ window_end = excluded.window_end, updated_at = excluded.updated_at RETURNING *`).get(subject, service, period, bits, start, end, now));
++ return presentBudget(row);
++}
++
++function presentBudget(r) {
++ return {
++ subject: { type: 'user', id: r.subject }, service: r.service, reset_period: r.reset_period, budget_bits: Number(r.budget_bits),
++ spent_bits: Number(r.spent_bits), window_start: new Date(r.window_start).toISOString(), window_end: new Date(r.window_end).toISOString(),
++ };
++}
++
++module.exports = { rate, sweep, chargeFor, pickCard, unitFits, subjectOf, parseCards, loadCards, setBudget, presentBudget, ERR };
ddiff --git a/server/ops/usage.js b/server/ops/usage.js
iindex 45556e8..90a2567 100644
---- a/server/ops/usage.js
++++ b/server/ops/usage.js
@@@ -4,7 +4,8 @@
* 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.
++ * made in the ledger, it is never a request to make one. Billing rates stored readings later, in its own background sweep
++ * (ops/rating.js); the reading's own free_allowance_used/vibes_charged are kept as sent and never drive a charge.
*
* 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);
@@@ -26,6 +27,12 @@ function present(row) {
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,
++ // Billing's own rating (ops/rating.js): vibes_charged is absent until the reading is rated.
++ ...(row.rated_at ? {
++ rated_at: new Date(row.rated_at).toISOString(), vibes_charged: Number(row.vibes_charged), promo_bits: Number(row.promo_bits),
++ free_allowance_used: Number(row.free_allowance_used), txn_id: row.txn_id || null,
++ } : {}),
++ ...(row.rating_error ? { rating_error: row.rating_error } : {}),
};
}
ddiff --git a/test/fixtures/usage-rating-seed.sql b/test/fixtures/usage-rating-seed.sql
nnew file mode 100644
iindex 0000000..311245e
---- /dev/null
++++ b/test/fixtures/usage-rating-seed.sql
@@@ -0,0 +1,35 @@
++-- PostgreSQL seed for the cutover rehearsal of migrations/0004_rating.sql (docs/cutover-usage-rating.md): a ledger as
++-- 0001-0003 leave it — one bought-credit purchase, one tip and their accounts — plus one stored usage reading (0002)
++-- and one promo allowance (0003), so the rehearsal proves the widened transactions_type_check accepts every existing
++-- row, that no ledger row, balance or allowance moves, and that the stored reading gains the new columns unrated.
++INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES
++ ('provider_clearing', 'powerchat', 'usd-cents', '2026-10-01T00:00:00.000Z'),
++ ('fx_conversion', NULL, 'usd-cents', '2026-10-01T00:00:00.000Z'),
++ ('fx_conversion', NULL, 'vibes-bits', '2026-10-01T00:00:00.000Z'),
++ ('user_credit', 'usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'vibes-bits', '2026-10-01T00:00:00.000Z'),
++ ('creator_payable', 'usr_01JAB2C3D4E5F6G7H8J9K0SEEE', 'vibes-bits', '2026-10-01T00:00:00.000Z');
++
++INSERT INTO transactions (id, type, idempotency_key, from_subject, to_subject, provider, receipt_ref, created_at) VALUES
++ ('txn_01JAB2C3D4E5F6G7H8J9K0SEED', 'purchase', 'seed:purchase:1', NULL, 'usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'powerchat', 'powerchat:seed-1', '2026-10-01T00:00:01.000Z'),
++ ('txn_01JAB2C3D4E5F6G7H8J9K0SEEE', 'donation', 'seed:donation:1', 'usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'usr_01JAB2C3D4E5F6G7H8J9K0SEEE', NULL, NULL, '2026-10-01T00:00:02.000Z');
++
++INSERT INTO ledger_entries (txn_id, account_id, amount)
++SELECT 'txn_01JAB2C3D4E5F6G7H8J9K0SEED', a.id, v.amount FROM (VALUES
++ ('provider_clearing', 'powerchat', 'usd-cents', -1000), ('fx_conversion', NULL, 'usd-cents', 1000),
++ ('fx_conversion', NULL, 'vibes-bits', -1000), ('user_credit', 'usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'vibes-bits', 1000)
++) AS v(kind, owner, currency, amount) JOIN accounts a ON a.kind = v.kind AND a.owner_subject IS NOT DISTINCT FROM v.owner AND a.currency = v.currency;
++INSERT INTO ledger_entries (txn_id, account_id, amount)
++SELECT 'txn_01JAB2C3D4E5F6G7H8J9K0SEEE', a.id, v.amount FROM (VALUES
++ ('user_credit', 'usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'vibes-bits', -400), ('creator_payable', 'usr_01JAB2C3D4E5F6G7H8J9K0SEEE', 'vibes-bits', 400)
++) AS v(kind, owner, currency, amount) JOIN accounts a ON a.kind = v.kind AND a.owner_subject IS NOT DISTINCT FROM v.owner AND a.currency = v.currency;
++
++INSERT INTO account_balances (account_id, balance, updated_at)
++SELECT a.id, (SELECT COALESCE(SUM(e.amount), 0) FROM ledger_entries e WHERE e.account_id = a.id), '2026-10-01T00:00:02.000Z' FROM accounts a;
++
++INSERT INTO usage_records (idempotency_key, project, subject, service, at, reading, reading_hash, principal, received_at) VALUES
++ ('seed:usage:1', NULL, 'user:usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'media', '2026-10-01T00:00:03.000Z',
++ '{"id": "use-seed-1", "idempotency_key": "seed:usage:1", "service": "media", "subject": "user:usr_01JAB2C3D4E5F6G7H8J9K0SEED", "provider": "local", "resource": "gib-delivered", "operation": "deliver", "quantity": 1.5, "unit": "GiB", "at": "2026-10-01T00:00:03Z", "source": "media.egress"}',
++ 'seed', 'svc:media', '2026-10-01T00:00:04.000Z');
++
++INSERT INTO promo_allowances (subject, service, reset_period, granted_bits, window_start, window_end, created_at, updated_at) VALUES
++ ('usr_01JAB2C3D4E5F6G7H8J9K0SEED', 'media', 'month', 100, '2026-10-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z', '2026-10-01T00:00:05.000Z', '2026-10-01T00:00:05.000Z');
ddiff --git a/test/rating.test.js b/test/rating.test.js
nnew file mode 100644
iindex 0000000..faa0239
---- /dev/null
++++ b/test/rating.test.js
@@@ -0,0 +1,289 @@
++'use strict';
++/**
++ * Rating (migrations/0004_rating.sql, ops/rating.js; plan T5 step 6): a stored reading is priced by its rate card,
++ * the promo allowance covers what it can, the rest is one `usage` transaction user_credit → platform_revenue under
++ * `usage:<reading key>`, inside hard budgets. Nothing is charged without a card; a reading refused for want of a card,
++ * funds or budget is left unrated (never partly charged) and is rated once what stopped it changes.
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++const rating = require('../server/ops/rating');
++const { load } = require('../scripts/load-rate-cards');
++
++const ADMIN = ['billing.ledger.admin'];
++const REC = ['billing.usage.record'];
++const CARD = {
++ id: 'rc_test_cdn_egress', provider: 'test-cdn', metric: 'gib-delivered', unit_size: 1, unit_price_usd: 0.1, free_allowance: 0,
++ reset_period: 'month', effective_from: '2026-01-01', source: 'https://example.com/pricing', verified_at: '2026-09-28',
++};
++let n = 0;
++
++(async () => {
++ console.log('usage rating');
++ const t = await boot();
++ const reading = (subject, over = {}) => {
++ n++;
++ return {
++ id: `use-${n}`, idempotency_key: `media:delivery:${n}`, service: 'media', subject: subject === null ? undefined : `user:${subject.id}`,
++ resource: 'gib-delivered', provider: 'test-cdn', operation: 'deliver', quantity: 3, unit: 'GiB',
++ at: new Date(t.ctx.now()).toISOString(), source: 'media.egress', ...over,
++ };
++ };
++ const store = async (body) => {
++ const r = await t.call('POST', '/api/v1/usage', { body, cap: REC, key: null, sub: 'svc:media' });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json.record;
++ };
++ const row = (key) => t.db.prepare('SELECT * FROM usage_records WHERE idempotency_key = ?').get(key);
++ const usageTxns = async (key) => (await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE type = 'usage'" + (key ? ' AND idempotency_key = ?' : '')).get(...(key ? [`usage:${key}`] : []))).n;
++ const credit = async (s) => (await t.balances(s.id)).credit;
++ const promoBits = async (s) => (await t.balances(s.id)).promo_bits;
++ const grant = async (s, bits) => { const r = await t.call('POST', '/api/v1/admin/promo/grant', { body: { subject: s, bits, service: 'media' }, cap: ADMIN }); assert.strictEqual(r.status, 201, r.text); };
++ const sweep = () => rating.sweep(t.ctx);
++
++ await check('0004 widened transactions_type_check (the name 0001 got) by usage, and the sweep is off by default', async () => {
++ const c = await t.db.prepare("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conrelid = 'transactions'::regclass AND conname = 'transactions_type_check'").get();
++ for (const k of ['purchase', 'import', 'usage']) assert.ok(c.def.includes(`'${k}'`), `${k} in ${c.def}`);
++ assert.strictEqual(t.config.jobs.ratingIntervalMs, 0);
++ assert.strictEqual(require('../server/config').loadConfig({}).jobs.ratingIntervalMs, 0);
++ });
++
++ await check('chargeFor is exact and rounds up once (3 × $0.10 × 100 = 30, not 31; 1 op at $0.004 = 1 bit)', async () => {
++ assert.strictEqual(rating.chargeFor(CARD, 3, { bitsPerUsd: 100 }), 30);
++ assert.strictEqual(rating.chargeFor({ ...CARD, unit_price_usd: 0.004 }, 1, { bitsPerUsd: 100 }), 1);
++ assert.strictEqual(rating.chargeFor({ ...CARD, unit_size: 1000000, unit_price_usd: 0.4 }, 2500000, { bitsPerUsd: 100 }), 100);
++ assert.strictEqual(rating.chargeFor({ ...CARD, unit_size: 1000000, unit_price_usd: 0.4 }, 1, { bitsPerUsd: 100 }), 1);
++ assert.strictEqual(rating.chargeFor(CARD, 0, { bitsPerUsd: 100 }), 0);
++ assert.strictEqual(rating.chargeFor({ ...CARD, unit_price_usd: 1e-7 }, 1, { bitsPerUsd: 100 }), 1);
++ });
++
++ await check('card match: provider + metric = resource, region and dates; the unit must fit the metric', async () => {
++ const at = '2026-10-04T10:00:00Z';
++ const cards = [CARD, { ...CARD, id: 'eu', region: 'eu' }, { ...CARD, id: 'old', effective_from: '2025-01-01', effective_until: '2026-01-01' }];
++ assert.strictEqual(rating.pickCard(cards, { provider: 'test-cdn', resource: 'gib-delivered', region: 'eu', at }).id, 'eu');
++ assert.strictEqual(rating.pickCard(cards, { provider: 'test-cdn', resource: 'gib-delivered', region: 'us', at }).id, CARD.id);
++ assert.strictEqual(rating.pickCard(cards, { provider: 'test-cdn', resource: 'gib-delivered', at: '2025-06-01T00:00:00Z' }).id, 'old');
++ assert.strictEqual(rating.pickCard(cards, { provider: 'test-cdn', resource: 'gib-delivered', at: '2024-06-01T00:00:00Z' }), null);
++ assert.strictEqual(rating.pickCard(cards, { provider: 'other', resource: 'gib-delivered', at }), null);
++ assert.ok(rating.unitFits(CARD, 'GiB'));
++ assert.ok(rating.unitFits({ metric: 'request' }, 'requests'));
++ assert.ok(!rating.unitFits(CARD, 'MiB'));
++ assert.ok(!rating.unitFits(CARD, ''));
++ });
++
++ const alice = t.user(1);
++ const bob = t.user(2);
++ const carol = t.user(3);
++ let early;
++ await check('no rate card: nothing is charged, the reading is left unrated with rating_error and not retried by itself', async () => {
++ await fund(t, alice.id, 1000);
++ early = await store(reading(alice));
++ const out = await sweep();
++ assert.strictEqual(out.rated, 0);
++ const r = await row(early.idempotency_key);
++ assert.strictEqual(r.rating_error, 'billing.no_rate_card');
++ assert.strictEqual(r.rated_at, null);
++ assert.strictEqual(r.rating_due_at, null);
++ assert.strictEqual(await usageTxns(), 0);
++ assert.strictEqual(await credit(alice), 1000);
++ const g = await t.call('GET', `/api/v1/usage?subject=user:${alice.id}`, { cap: ADMIN });
++ assert.strictEqual(g.json.records[0].vibes_charged, undefined, 'absent vibes_charged = not rated yet');
++ assert.strictEqual(g.json.records[0].rating_error, 'billing.no_rate_card');
++ });
++
++ await check('the loader refuses an invalid card whole (platform.rate-card@1) and loads nothing', async () => {
++ await assert.rejects(load({ cards: JSON.stringify([CARD, { ...CARD, id: 'bad', unit_price_usd: -1 }]) }, { db: t.db }), (e) => e.code === 'billing.invalid_rate_card');
++ await assert.rejects(load({ cards: JSON.stringify([{ ...CARD, unit_size: 0 }]) }, { db: t.db }), (e) => e.code === 'billing.invalid_rate_card');
++ await assert.rejects(load({ cards: '' }, { db: t.db }), /OV_RATE_CARDS is empty/);
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM rate_cards').get()).n, 0);
++ const dry = await load({ cards: JSON.stringify([CARD]), dryRun: true }, { db: t.db });
++ assert.strictEqual(dry.loaded, 0);
++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM rate_cards').get()).n, 0);
++ });
++
++ await check('a card loaded later makes the waiting reading due again; the next pass charges it: one usage txn user_credit → platform_revenue', async () => {
++ const out = await load({ cards: JSON.stringify([CARD]) }, { db: t.db });
++ assert.deepStrictEqual(out, { loaded: 1, rearmed: 1 });
++ assert.strictEqual((await row(early.idempotency_key)).rating_error, null);
++ const s = await sweep();
++ assert.strictEqual(s.rated, 1);
++ const r = await row(early.idempotency_key);
++ assert.ok(r.rated_at);
++ assert.strictEqual(Number(r.vibes_charged), 30);
++ assert.strictEqual(Number(r.promo_bits), 0);
++ assert.strictEqual(Number(r.free_allowance_used), 0);
++ assert.strictEqual(await credit(alice), 970);
++ const txn = await t.db.prepare('SELECT * FROM transactions WHERE id = ?').get(r.txn_id);
++ assert.strictEqual(txn.type, 'usage');
++ assert.strictEqual(txn.idempotency_key, `usage:${early.idempotency_key}`);
++ const entries = (await require('../server/ledger').entriesOf(t.db, r.txn_id)).map((e) => [e.kind, e.owner, e.currency, e.amount]);
++ assert.deepStrictEqual(entries, [['platform_revenue', null, 'vibes-bits', 30], ['user_credit', alice.id, 'vibes-bits', -30]]);
++ const g = await t.call('GET', `/api/v1/usage?subject=user:${alice.id}`, { cap: ADMIN });
++ assert.strictEqual(g.json.records[0].vibes_charged, 30);
++ assert.strictEqual(g.json.records[0].txn_id, r.txn_id);
++ });
++
++ await check('re-rating is idempotent: the same reading again is a replay, nothing moves', async () => {
++ const r = await row(early.idempotency_key);
++ const again = await rating.rate(t.ctx, r.id);
++ assert.strictEqual(again.replay, true);
++ assert.strictEqual(again.vibes_charged, 30);
++ assert.strictEqual(await usageTxns(), 1);
++ assert.strictEqual(await credit(alice), 970);
++ assert.strictEqual((await sweep()).rated, 0);
++ });
++
++ await check('promo allowance covering the whole charge: 0 charged, free_allowance_used = quantity, txn_id = the promo draw, no credit debit', async () => {
++ await grant(bob, 100);
++ await fund(t, bob.id, 50);
++ const rec = await store(reading(bob, { quantity: 2 }));
++ await sweep();
++ const r = await row(rec.idempotency_key);
++ assert.strictEqual(Number(r.vibes_charged), 0);
++ assert.strictEqual(Number(r.promo_bits), 20);
++ assert.strictEqual(Number(r.free_allowance_used), 2);
++ assert.ok(r.txn_id);
++ const txn = await t.db.prepare('SELECT * FROM transactions WHERE id = ?').get(r.txn_id);
++ assert.strictEqual(txn.type, 'adjustment');
++ assert.strictEqual(JSON.parse(txn.metadata).promo, 'consume');
++ assert.strictEqual(await usageTxns(rec.idempotency_key), 0);
++ assert.strictEqual(await credit(bob), 50);
++ assert.strictEqual(await promoBits(bob), 80);
++ });
++
++ await check('partial cover: the allowance first, the rest from Vibes (ceil once); promo never reaches creator money', async () => {
++ const rec = await store(reading(bob, { quantity: 10.5 })); // 105 bits gross; 80 promo left
++ await sweep();
++ const r = await row(rec.idempotency_key);
++ assert.strictEqual(Number(r.promo_bits), 80);
++ assert.strictEqual(Number(r.vibes_charged), 25);
++ assert.ok(Math.abs(Number(r.free_allowance_used) - (10.5 * 80) / 105) < 1e-9);
++ assert.strictEqual(await credit(bob), 25);
++ assert.strictEqual(await promoBits(bob), 0);
++ const kinds = (await require('../server/ledger').entriesOf(t.db, r.txn_id)).map((e) => e.kind).sort();
++ assert.deepStrictEqual(kinds, ['platform_revenue', 'user_credit']);
++ assert.strictEqual((await t.balances(bob.id)).payable, 0);
++ await t.assertReconciled('after promo + usage');
++ });
++
++ await check('insufficient Vibes: billing.insufficient_funds, left unrated, promo draw rolled back; rated after a top-up and the retry delay', async () => {
++ await grant(carol, 10);
++ const rec = await store(reading(carol, { quantity: 5 })); // 50 gross, 10 promo, 40 from Vibes; carol has none
++ const out = await sweep();
++ assert.strictEqual(out.skipped, 1);
++ let r = await row(rec.idempotency_key);
++ assert.strictEqual(r.rating_error, 'billing.insufficient_funds');
++ assert.strictEqual(r.rated_at, null);
++ assert.ok(new Date(r.rating_due_at).getTime() > t.ctx.now(), 'retried later, not at once');
++ assert.strictEqual(await promoBits(carol), 10, 'the promo draw rolled back with the refusal');
++ assert.strictEqual((await t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE idempotency_key = ?").get(`promo:consume:usage:${rec.idempotency_key}`)).n, 0);
++ await fund(t, carol.id, 100);
++ assert.strictEqual((await sweep()).rated, 0, 'not before the retry delay');
++ t.clock.offset += t.config.rating.retryMs + 1000;
++ assert.strictEqual((await sweep()).rated, 1);
++ r = await row(rec.idempotency_key);
++ assert.strictEqual(Number(r.vibes_charged), 40);
++ assert.strictEqual(Number(r.promo_bits), 10);
++ assert.strictEqual(await credit(carol), 60);
++ });
++
++ await check('a reading with no person to charge (no subject, an app) is left unrated with rating_error, no txn', async () => {
++ const a = await store(reading(null));
++ const b = await store(reading(null, { subject: 'app:jobs' }));
++ await sweep();
++ for (const rec of [a, b]) {
++ const r = await row(rec.idempotency_key);
++ assert.strictEqual(r.rating_error, 'billing.no_subject');
++ assert.strictEqual(r.rating_due_at, null);
++ assert.strictEqual(await usageTxns(rec.idempotency_key), 0);
++ }
++ });
++
++ await check('a unit that does not fit the card is left unrated (billing.unit_mismatch), not retried by itself', async () => {
++ const rec = await store(reading(alice, { unit: 'MiB' }));
++ await sweep();
++ assert.strictEqual((await row(rec.idempotency_key)).rating_error, 'billing.unit_mismatch');
++ assert.strictEqual(await credit(alice), 970);
++ });
++
++ await check('a charge past the exact integer range is refused (billing.amount_overflow), left for review, not retried by itself', async () => {
++ const rec = await store(reading(alice, { quantity: 1e20 }));
++ const out = await sweep();
++ assert.strictEqual(out.failed, 0);
++ const r = await row(rec.idempotency_key);
++ assert.strictEqual(r.rating_error, 'billing.amount_overflow');
++ assert.strictEqual(r.rating_due_at, null);
++ assert.strictEqual(await credit(alice), 970);
++ });
++
++ await check('two sweeps at once (and POST /admin/rate beside them) charge each reading once and draw the allowance once', async () => {
++ const dave = t.user(4);
++ await fund(t, dave.id, 1000);
++ await grant(dave, 15);
++ const recs = [];
++ for (let i = 0; i < 4; i++) recs.push(await store(reading(dave, { quantity: 1 }))); // 10 bits each
++ const [a, b, c] = await Promise.all([sweep(), sweep(), t.call('POST', '/api/v1/admin/rate', { body: {}, cap: ADMIN })]);
++ assert.strictEqual(c.status, 200, c.text);
++ assert.strictEqual(a.rated + b.rated + c.json.rated, 4);
++ for (const rec of recs) assert.strictEqual(await usageTxns(rec.idempotency_key) <= 1, true);
++ const rows = await Promise.all(recs.map((rec) => row(rec.idempotency_key)));
++ const charged = rows.reduce((s, r) => s + Number(r.vibes_charged), 0);
++ const drawn = rows.reduce((s, r) => s + Number(r.promo_bits), 0);
++ assert.strictEqual(drawn, 15);
++ assert.strictEqual(charged, 25);
++ assert.strictEqual(await credit(dave), 975);
++ assert.strictEqual(await promoBits(dave), 0);
++ await t.assertReconciled('after concurrent sweeps');
++ });
++
++ await check('a hard budget refuses a charge whole (billing.budget_exceeded), counts spent_bits with the post, and opens again next window', async () => {
++ const erin = t.user(5);
++ await fund(t, erin.id, 1000);
++ const set = await t.call('POST', '/api/v1/admin/budgets', { body: { subject: erin, service: 'media', period: 'day', budget_bits: 50 }, cap: ADMIN });
++ assert.strictEqual(set.status, 201, set.text);
++ assert.strictEqual(set.json.budget.budget_bits, 50);
++ const first = await store(reading(erin, { quantity: 3 })); // 30
++ const second = await store(reading(erin, { quantity: 3 })); // 30 more: over 50
++ await sweep();
++ assert.strictEqual(Number((await row(first.idempotency_key)).vibes_charged), 30);
++ const r2 = await row(second.idempotency_key);
++ assert.strictEqual(r2.rating_error, 'billing.budget_exceeded');
++ assert.strictEqual(r2.vibes_charged, null);
++ assert.strictEqual(await credit(erin), 970);
++ let b = (await t.call('GET', `/api/v1/admin/budgets?subject=${erin.id}`, { cap: ADMIN })).json.budgets;
++ assert.strictEqual(b[0].spent_bits, 30);
++ const budget0 = await t.call('POST', '/api/v1/admin/budgets', { body: { subject: erin, service: '*', period: 'month', budget_bits: 0 }, cap: ADMIN });
++ assert.strictEqual(budget0.status, 201);
++ t.clock.offset += 86_400_000 + t.config.rating.retryMs;
++ await sweep();
++ assert.strictEqual((await row(second.idempotency_key)).rating_error, 'billing.budget_exceeded', 'the all-services budget of 0 stops it');
++ await t.call('POST', '/api/v1/admin/budgets', { body: { subject: erin, service: '*', period: 'month', budget_bits: 1000 }, cap: ADMIN });
++ t.clock.offset += t.config.rating.retryMs + 1000;
++ await sweep();
++ assert.strictEqual(Number((await row(second.idempotency_key)).vibes_charged), 30, 'the next day window has room');
++ assert.strictEqual(await credit(erin), 940);
++ b = (await t.call('GET', `/api/v1/admin/budgets?subject=${erin.id}`, { cap: ADMIN })).json.budgets;
++ assert.deepStrictEqual(b.filter((x) => x.service === 'media').map((x) => x.spent_bits), [30, 30]);
++ assert.strictEqual(b.find((x) => x.service === '*').spent_bits, 30);
++ });
++
++ await check('POST /admin/rate needs billing.ledger.admin and is refused while frozen; the sweep rates nothing while frozen', async () => {
++ assert.strictEqual((await t.call('POST', '/api/v1/admin/rate', { body: {}, cap: REC })).status, 403);
++ const frank = t.user(6);
++ await fund(t, frank.id, 100);
++ const rec = await store(reading(frank));
++ await t.call('POST', '/api/v1/admin/freeze', { body: { on: true, reason: 'test' }, cap: ADMIN });
++ assert.strictEqual((await t.call('POST', '/api/v1/admin/rate', { body: {}, cap: ADMIN })).status, 503);
++ assert.strictEqual((await sweep()).frozen, true);
++ assert.strictEqual((await row(rec.idempotency_key)).rated_at, null);
++ await t.call('POST', '/api/v1/admin/freeze', { body: { on: false }, cap: ADMIN });
++ const r = await t.call('POST', '/api/v1/admin/rate', { body: {}, cap: ADMIN });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.rated, 1);
++ assert.strictEqual(await credit(frank), 70);
++ await t.assertReconciled('end');
++ });
++
++ await t.close();
++ done();
++})();