openvibers/openvibe.billing Commits
Rate stored usage readings into the ledger under hard budgets
Changes
@@@ -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@@@ -137,6 +137,43 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unitreview 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.++## APIAll `/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` |nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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;@@@ -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": [nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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)@@@ -23,7 +23,7 @@ const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'pconst 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');@@@ -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();}nnew file mode 100644@@@ -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 };@@@ -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 } : {}),};}nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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();++})();
@@ -120,3 +120,9 @@ POWERCHAT_SITE_USERNAME=
@@ -120,3 +120,9 @@ POWERCHAT_SITE_USERNAME=
#BILLING_RECONCILE_KEEP_DAYS=30
#BILLING_RECONCILE_KEEP_DAYS=30
# A provider receipt unprocessed this long (economy open) fails reconciliation (receipts.stale).
# A provider receipt unprocessed this long (economy open) fails reconciliation (receipts.stale).
#BILLING_RECONCILE_STALE_RECEIPT_MIN=60
#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
@@ -137,6 +137,43 @@ Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
@@ -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 —
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.
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
## API
All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`) and are
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
@@ -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/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/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` |
| `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 /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 |
@@ -174,6 +211,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
@@ -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) |
| `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/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/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 |
| `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 /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` |
| `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` |
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -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;
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
"reconcile": "node scripts/reconcile.js",
"reconcile": "node scripts/reconcile.js",
"freeze": "node scripts/freeze.js",
"freeze": "node scripts/freeze.js",
"import-live": "node scripts/import-live.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"
"test:pg": "BILLING_TEST_STORE=pg node test/run.js"
},
},
"keywords": [
"keywords": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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 };
@@ -27,6 +27,7 @@ const { reconcile } = require('../reconcile');
@@ -27,6 +27,7 @@ const { reconcile } = require('../reconcile');
const external = require('../ops/external');
const external = require('../ops/external');
const usage = require('../ops/usage');
const usage = require('../ops/usage');
const promo = require('../ops/promo');
const promo = require('../ops/promo');
+const rating = require('../ops/rating');
const CAP = {
const CAP = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
@@ -250,7 +251,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -250,7 +251,7 @@ function v1Router({ ctx, auth, adapters, limits }) {
}));
}));
// ── Usage readings (platform.usage-sample@1; ops/usage.js) ──
// ── 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.
// 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) => {
r.post('/usage', auth.needs(CAP.usage), wrap(async (req, res) => {
const out = await usage.record(ctx, req.body, { principal: req.principal.sub });
const out = await usage.record(ctx, req.body, { principal: req.principal.sub });
@@ -314,6 +315,21 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -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) });
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) });
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) => {
r.get('/admin/provider-events', ...read(CAP.admin, async (req, res) => {
const pending = req.query.pending === '1' || req.query.pending === 'true';
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);
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);
@@ -160,6 +160,16 @@ function loadConfig(env = process.env) {
@@ -160,6 +160,16 @@ function loadConfig(env = process.env) {
// runs older than reconcileKeepDays are pruned, failed ones are kept.
// runs older than reconcileKeepDays are pruned, failed ones are kept.
reconcileIntervalMs: int(env.BILLING_RECONCILE_INTERVAL_MS, 60 * 60 * 1000),
reconcileIntervalMs: int(env.BILLING_RECONCILE_INTERVAL_MS, 60 * 60 * 1000),
reconcileKeepDays: int(env.BILLING_RECONCILE_KEEP_DAYS, 30),
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: {
reconcile: {
// A provider receipt still unprocessed this long after it arrived (economy not frozen)
// A provider receipt still unprocessed this long after it arrived (economy not frozen)
@@ -23,7 +23,7 @@ const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'p
@@ -23,7 +23,7 @@ const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'p
const PROMO_KINDS = ['promo_credit', 'promo_reserve'];
const PROMO_KINDS = ['promo_credit', 'promo_reserve'];
const CURRENCIES = ['vibes-bits', 'usd-cents'];
const CURRENCIES = ['vibes-bits', 'usd-cents'];
const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid',
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');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
@@ -8,13 +8,15 @@
@@ -8,13 +8,15 @@
* retried every minute; the subscription renewal sweep runs hourly; reconciliation runs hourly
* 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
* (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
* /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.
* reconciliation (it only reads the books) run.
*/
*/
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
const providers = require('./providers');
const providers = require('./providers');
const subscriptions = require('./ops/subscriptions');
const subscriptions = require('./ops/subscriptions');
+const rating = require('./ops/rating');
const { createRelay } = require('./outbox');
const { createRelay } = require('./outbox');
const { isFrozen } = require('./ops/common');
const { isFrozen } = require('./ops/common');
const { runScheduled } = require('./reconcile');
const { runScheduled } = require('./reconcile');
@@ -43,6 +45,12 @@ const { runScheduled } = require('./reconcile');
@@ -43,6 +45,12 @@ const { runScheduled } = require('./reconcile');
first.unref();
first.unref();
timers.push(first);
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();
relay.start();
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,309 @@
@@ -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 };
@@ -4,7 +4,8 @@
@@ -4,7 +4,8 @@
* Usage readings (platform.usage-sample@1, plan T5 lane F): a service reports what was used — by which project and
* 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
* 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
* 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
* 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);
* 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) {
@@ -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,
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,
at: row.at, received_at: row.received_at, principal: row.principal,
reading: typeof row.reading === 'string' ? JSON.parse(row.reading) : row.reading,
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 } : {}),
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,289 @@
@@ -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();
+})();