openvibers/openvibe.billing Commits
Add a promo allowance ledger that never becomes creator money (#7)
Changes
@@@ -23,16 +23,31 @@ webhooks land here and nowhere else.|---|---|| CREDIT (Vibes bought, spendable, never withdrawable by the buyer) | `user_credit:<subject>` (vibes-bits) || MONEY (creator payable, payouts, receipts, platform revenue) | `creator_payable:<subject>`, `payouts_pending:<subject>`, `provider_clearing:<provider>`, `platform_revenue`, `refunds`, `chargeback_loss` |++| PROMO (free service allowance, plan T5 ledger 2: never spendable as CREDIT, never MONEY) | `promo_credit:<subject>` (vibes-bits), issued from and returned to `promo_reserve` (platform, vibes-bits); windows in `promo_allowances`. **Counterparty: a dedicated `promo_reserve`, not `platform_revenue`**: free allowance is a marketing cost, not income, so debiting revenue would understate earnings and blur the one account that reports what the platform made; a reserve of its own always equals −Σ `promo_credit` and is checked in isolation || ENTITLEMENT (channel subscriptions) | `subscriptions` + `entitlements` (one row per paid period) || EXTERNAL (tips on a streamer's own PowerChat) | stored receipt (`external_receipts`), no journal entry; announced as `billing.receipt.external` once Billing is the authority; a direct-route subscription grants its entitlement only || LOYALTY, COSMETIC, GAME-STATE, LEGACY | not here (rule 5–8) |++**Three ledgers, one journal.** Bought Vibes (`user_credit`), creator money (`creator_payable` and its payouts)++and free promo allowance (`promo_credit`) are separate account kinds that never mix. Credit becomes money only++through a transfer (`transfers.create`: `user_credit` → `creator_payable`); money leaves only through a cashout++(`creator_payable` → `payouts_pending`). Promo has no such path: it is granted by an admin++(`POST /api/v1/admin/promo/grant`: `promo_reserve` → `promo_credit`) and only drawn down by usage++(`ops/promo.js` `consume()`, keyed by the caller's idempotency key) or lapses when its window ends, both back to++`promo_reserve`. Transfers read `user_credit` only and cashouts `creator_payable` only, so a subject holding only++promo is refused with `billing.insufficient_funds`; an admin adjustment that pairs a promo account with any other++kind is refused (`billing.promo_isolated`), and reconciliation's `promo.isolated` check fails on any transaction++that mixes them. `promo_allowances` is the policy and window record (`granted_bits`, `used_bits`, `expired_bits`++per subject, service, period and window); the `promo_credit` balance is the authoritative remainder, and every++promo write changes both in one transaction.++## Owns- the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts-- (`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, the-- freeze, reconciliation runs, import runs and the staff audit log, in Billing's own PostgreSQL database-- (`ov_billing` on the host's data role, ADR-035; schema in [migrations/](migrations/))++ (`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, usage readings++ (`usage_records`), promo allowances (`promo_allowances`), the freeze, reconciliation runs, import runs and the++ staff audit log, in Billing's own PostgreSQL database (`ov_billing` on the host's data role, ADR-035;++ schema in [migrations/](migrations/))- provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments): they land here and nowhere else- the `billing.*` events and the public billing policy page (`/policy`, every number from the rates)@@@ -149,7 +164,7 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ| `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first || `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read || `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |--| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, payable_value_cents}` |++| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, 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 || `GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor=` | `billing.ledger.admin` | readings newest first, `from` ≤ `at` < `to`, cursor paging (`limit` ≤ 500) |@@@ -157,7 +172,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation || `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report || `GET\|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) |++| `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` || `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` |@@@ -8,9 +8,9 @@"authoritative": false,"deployed": true,"deployment": "Deployed in shadow with `sudo ovhost deploy billing`: /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), /api/health, /api/ready (openvibe-shared/ready shape), /release.json and /webhooks/*. No provider secret is configured, so every provider is off and /webhooks/* answers 404 billing.provider_disabled.",-- "contracts": "openvibe-contracts v0.76.0",++ "contracts": "openvibe-contracts v0.85.0","notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation (GET /api/v1/admin/reconciliations; 16 runs by 2026-09-24 05:06 UTC, ledger-vs-receipt checks), EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing; recorded only while Live is the authority), overflow guards and serializable settlement (PostgreSQL, ADR-035). Production holds the shadow import of Live (322 transactions, 996 ledger entries), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat; a duplicate-webhook proof in production follows it. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",-- "updated": "2026-09-28",++ "updated": "2026-10-04","features": ["append-only double-entry journal in bits and cents, balanced per currency","provider receipts stored before processing, settled once (PowerChat, Stripe, PayPal, CCBill, NOWPayments adapters)",@@@ -23,6 +23,8 @@"public billing policy page","Live shadow import","outbox events billing.*",-- "per-person limits (openvibe-sdk/limits)"++ "per-person limits (openvibe-sdk/limits)",++ "usage readings (billing.usage.record)",++ "promo allowance ledger"]}nnew file mode 100644@@@ -0,0 +1,121 @@++# Cutover runbook — PR #7 (OpenVibe.Billing promo/free-credit ledger)++++PR #7 adds the promo ledger (plan T5 step 5): two account kinds, `promo_credit:<subject>` and++`promo_reserve` (both vibes-bits), one empty table, `promo_allowances`, one admin route++(`POST /api/v1/admin/promo/grant`, `billing.ledger.admin`) and a `promo_bits` field on++`GET /api/v1/balances/:subject`. **The only ALTER widens `accounts_kind_check`** to accept the two new++kinds; no ledger row moves, no balance changes, no existing table gains or loses a column. No provider++webhook, auth, capability or Contracts change. The importer (`server/importer/live.js`) is untouched.++++## What the migration does++++`migrations/0003_promo_ledger.sql` (`phase: expand`), in one transaction:++++```sql++ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;++ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing',++ 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve'));++++CREATE TABLE promo_allowances (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ 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')),++ granted_bits bigint NOT NULL CHECK (granted_bits > 0),++ used_bits bigint NOT NULL DEFAULT 0 CHECK (used_bits >= 0),++ expired_bits bigint NOT NULL DEFAULT 0 CHECK (expired_bits >= 0),++ window_start timestamptz NOT NULL,++ window_end timestamptz NOT NULL CHECK (window_end > window_start),++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL,++ CHECK (used_bits + expired_bits <= granted_bits),++ UNIQUE (subject, service, reset_period, window_start)++);++CREATE INDEX promo_allowances_open ON promo_allowances (subject, window_end) WHERE used_bits + expired_bits < granted_bits;++```++++`accounts_kind_check` is the name PostgreSQL gave 0001's inline CHECK on `accounts.kind`++(`SELECT conname FROM pg_constraint WHERE conrelid = 'accounts'::regclass`); the new CHECK is the old list++plus two values, so every existing row satisfies it and the `ADD CONSTRAINT` validation scan only reads++`accounts` (a few hundred rows in production: 322 imported transactions). The `ACCESS EXCLUSIVE` lock on++`accounts` is held for that scan only, inside the migration's transaction, before the service serves.++++## What is not changed++++- No ledger row (`transactions`, `ledger_entries`, `account_balances`) is written, updated or deleted.++- No existing column, index, trigger or transaction type changes (`transactions_type_check` is untouched;++ promo movements are `adjustment` transactions with `metadata.promo` = grant | consume | lapse).++- The importer still maps Live's `bonus` to `user_credit` (O1-owned; see the PR body).++- No `BILLING_AUTHORITY`, webhook, service-token, capability or Contracts change. Nothing calls the grant++ route on the day of the merge; rating (T5 step 6) is the first caller of `consume()`.++++## 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 state` (from `/opt/openvibe.billing`) returns++ `{"on": false}`.++++## Rehearsal++++`ov rehearse` builds the base from main's migrations (0001, 0002), loads the PostgreSQL seed below (a++purchase and a tip with their accounts and balances; not a SQLite fixture), applies 0003, runs the++migrations a second time (nothing must apply), then the check: the widened CHECK holds all eleven kinds,++the five seeded balances and six entries are exactly as seeded, and `promo_allowances` is empty.++++```rehearse++seed: test/fixtures/promo-ledger-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("accounts","accounts_kind_check");for(const k of ["user_credit","creator_payable","fx_conversion","promo_credit","promo_reserve"])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 promo_allowances").get()).n),0);console.log("0003: accounts_kind_check widened, 5 balances and 6 entries unchanged, promo_allowances 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 = 'accounts'::regclass AND conname = 'accounts_kind_check';++SELECT id, name, phase FROM ov_migrations WHERE id = '0003'; -- 0003 | promo_ledger | expand++SELECT COUNT(*) FROM promo_allowances; -- 0++```++++and compare `node scripts/reconcile.js` before and after: the same account, transaction and entry counts,++every check ok (the new `promo.isolated` check passes on a ledger with no promo rows).++++## Cutover++++1. **Merge the PR** once the rehearsal marker is `{"ok": true}`.++2. **Deploy:** `sudo ovhost deploy billing "promo ledger (PR #7) — migration 0003 widens accounts_kind_check"`.++ No lockfile change. The boot applies `0003_promo_ledger.sql` once++ (`migrations: applied 0003_promo_ledger (expand, NNms)`).++3. **Verify on production:**++ - `/api/ready` is 200; `ov_migrations` has the `0003` row; `\d promo_allowances` lists the table.++ - `GET /api/v1/balances/<subject>` answers with `promo_bits: 0` beside the unchanged `credit`/`payable`.++ - `node scripts/reconcile.js` shows the same totals as before the deploy, every check ok, `promo.isolated` included.++++## Rollback++++- **No grant has been made (the day of the merge).** `sudo ovhost rollback billing`, then restore the old++ CHECK and drop the empty table in one transaction:++ ```sql++ BEGIN;++ DROP TABLE promo_allowances;++ ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;++ ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing',++ 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'));++ DELETE FROM ov_migrations WHERE id = '0003';++ COMMIT;++ ```++ Leaving them in place is also safe: the previous release never writes a promo kind.++- **Grants have been made.** The journal is append-only: promo rows cannot be deleted and the narrow CHECK++ cannot be restored over them. Roll back the code only (`sudo ovhost rollback billing`); the previous++ release ignores `promo_credit`/`promo_reserve` (its balances route does not report them and no code path++ moves them), and the snapshot is the last resort.++++## PR description++++````++```cutover++{"runbook": "docs/cutover-pr-openvibe-billing-7.md", "rehearsal": "billing-promo-ledger-001"}++```++````nnew file mode 100644@@@ -0,0 +1,32 @@++-- phase: expand++-- The promo/free-credit ledger (plan T5 step 5, ledger 2): a recurring service allowance that can never become creator++-- money. Two new account kinds: promo_credit:<subject> (vibes-bits) holds a person's unused allowance, and promo_reserve++-- (platform, vibes-bits) is the marketing reserve it is issued from and returns to when consumed or lapsed, so free++-- allowance never touches platform_revenue. transfers.create reads user_credit only and cashouts read creator_payable++-- only, so no code path moves a promo bit into MONEY. promo_allowances is the policy and window record; the promo_credit++-- balance is the authoritative remainder (ops/promo.js writes both in one transaction).++-- The only ALTER widens accounts_kind_check (the name PostgreSQL gave 0001's inline CHECK on accounts.kind): no row moves.++++ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;++ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing',++ 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve'));++++-- One allowance per subject, service ('*' = any eligible service), period and window. used_bits were consumed;++-- expired_bits lapsed when the window ended (returned to promo_reserve).++CREATE TABLE promo_allowances (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ 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')),++ granted_bits bigint NOT NULL CHECK (granted_bits > 0),++ used_bits bigint NOT NULL DEFAULT 0 CHECK (used_bits >= 0),++ expired_bits bigint NOT NULL DEFAULT 0 CHECK (expired_bits >= 0),++ window_start timestamptz NOT NULL,++ window_end timestamptz NOT NULL CHECK (window_end > window_start),++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL,++ CHECK (used_bits + expired_bits <= granted_bits),++ UNIQUE (subject, service, reset_period, window_start)++);++++CREATE INDEX promo_allowances_open ON promo_allowances (subject, window_end) WHERE used_bits + expired_bits < granted_bits;@@@ -26,6 +26,7 @@ const providers = require('../providers');const { reconcile } = require('../reconcile');const external = require('../ops/external');const usage = require('../ops/usage');++const promo = require('../ops/promo');const CAP = {intent: 'billing.intent.create',@@@ -217,8 +218,9 @@ function v1Router({ ctx, auth, adapters, limits }) {const credit = await balance(db, A.credit(subject));const payable = await balance(db, A.payable(subject));const pending = await balance(db, A.pending(subject));++ const promoBits = await promo.remaining(ctx, subject);return {-- subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending,++ subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending, promo_bits: promoBits,payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,};}@@@ -307,6 +309,11 @@ function v1Router({ ctx, auth, adapters, limits }) {const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });res.status(201).json(txnOut(out.txn));}));++ r.post('/admin/promo/grant', ...write(CAP.admin, async (req, res) => {++ const b = req.body || {};++ 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) });++ }));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);@@@ -18,7 +18,9 @@ const path = require('path');const { createDb } = require('openvibe-sdk/db');const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',-- 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'];++ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve'];++// The promo ledger's kinds (migrations/0003): their entries never share a transaction with any other kind.++const PROMO_KINDS = ['promo_credit', 'promo_reserve'];const CURRENCIES = ['vibes-bits', 'usd-cents'];const TXN_TYPES = ['purchase', 'donation', 'subscription', 'subscription_share', 'cashout_request', 'cashout_paid','cashout_denied', 'recycle', 'refund', 'chargeback', 'adjustment', 'import'];@@@ -48,4 +50,4 @@ async function openDb(config, { log = console, registry } = {}) {return createDb({ url: config.db.url, service: 'billing', registry, log });}--module.exports = { openDb, MIGRATIONS, ACCOUNT_KINDS, CURRENCIES, TXN_TYPES };++module.exports = { openDb, MIGRATIONS, ACCOUNT_KINDS, PROMO_KINDS, CURRENCIES, TXN_TYPES };@@@ -10,7 +10,7 @@const { post, getTxn, iso, money } = require('../ledger');const { enqueue } = require('../outbox');const { entry, fail, positiveInt, text } = require('./common');--const { ACCOUNT_KINDS, CURRENCIES } = require('../db');++const { ACCOUNT_KINDS, PROMO_KINDS, CURRENCIES } = require('../db');const { summary } = require('./purchases');async function freezeState(db) {@@@ -37,6 +37,10 @@ async function adjust(ctx, input) {const from = account(input.from, 'from');const to = account(input.to, 'to');if (from.currency !== to.currency) fail(422, 'billing.invalid_input', 'an adjustment moves value within one currency');++ // Free allowance never becomes credit or money: a promo account is corrected only against the other promo kind.++ if (PROMO_KINDS.includes(from.kind) !== PROMO_KINDS.includes(to.kind)) {++ fail(422, 'billing.promo_isolated', 'promo_credit and promo_reserve adjust only against each other; free allowance never becomes credit or money');++ }const amount = positiveInt(input.amount, 'amount', 1_000_000_000);const reason = text(input.reason, 'reason', 500);if (!reason) fail(422, 'billing.invalid_input', 'an adjustment needs a reason');@@@ -16,6 +16,9 @@ const MAX_RECEIPT_CENTS = 100_000_000;/** Account addresses. owner is a subject id, a provider slug, 'hold:live:<id>' or null (platform). */const A = {credit: (s) => ({ kind: 'user_credit', owner: s, currency: BITS }),++ // Free allowance (ops/promo.js): never spendable as credit, never MONEY; it moves only to and from promoReserve.++ promo: (s) => ({ kind: 'promo_credit', owner: s, currency: BITS }),++ promoReserve: () => ({ kind: 'promo_reserve', owner: null, currency: BITS }),payable: (s) => ({ kind: 'creator_payable', owner: s, currency: BITS }),pending: (s) => ({ kind: 'payouts_pending', owner: s, currency: BITS }),clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CENTS }),nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++++/**++ * The promo ledger (plan T5 ledger 2): a recurring, non-transferable free allowance, kept in the journal under its own++ * account kinds so it can never become MONEY.++ *++ * grant() promo_reserve → promo_credit:<subject> (admin only; tops up the allowance of the current window)++ * consume() promo_credit:<subject> → promo_reserve (rating draws the allowance down; keyed by the caller)++ * lapse promo_credit:<subject> → promo_reserve (a window that ended returns what was left unused)++ *++ * transfers.create reads A.credit (user_credit) and cashouts read A.payable (creator_payable) only, so a promo bit has++ * no path to creator_payable; admin adjustments refuse to pair a promo account with any other kind. promo_allowances++ * is the policy and window record; the promo_credit balance is the authoritative remainder: every write here changes++ * both in one transaction, and reconciliation checks that no transaction mixes promo and other accounts.++ */++const { post, balance, getTxnByKey, iso, money } = require('../ledger');++const { A, entry, fail, positiveInt, text, userSubject } = require('./common');++++const PERIODS = ['day', 'month', 'none'];++const MAX_GRANT_BITS = 1_000_000_000;++const ACTOR = { type: 'service', id: 'billing' };++const NEVER = '9999-12-31T23:59:59.999Z';++++/** The [start, end) window of `period` that covers `at` (ms), as ISO strings; 'none' is one window for all time. */++function windowOf(period, at) {++ const d = new Date(at);++ if (period === 'none') return { start: iso(0), end: NEVER }; // the last ISO-8601 instant PostgreSQL parses back++ if (period === 'day') {++ const s = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());++ return { start: iso(s), end: iso(s + 86_400_000) };++ }++ return { start: iso(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)), end: iso(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)) };++}++++const serviceOf = (v) => text(v, 'service', 100) || '*';++const inTx = (db, fn) => (db.inTransaction() ? fn() : money(db, fn));++const open = (r) => Number(r.granted_bits) - Number(r.used_bits) - Number(r.expired_bits);++++function present(r) {++ if (!r) return null;++ return {++ id: Number(r.id), subject: { type: 'user', id: r.subject }, service: r.service, reset_period: r.reset_period,++ granted_bits: Number(r.granted_bits), used_bits: Number(r.used_bits), expired_bits: Number(r.expired_bits), remaining_bits: open(r),++ window_start: new Date(r.window_start).toISOString(), window_end: new Date(r.window_end).toISOString(),++ };++}++++/** Return what windows that have ended left unused to promo_reserve (once per allowance). Inside the caller's transaction. */++async function lapse(ctx, subject) {++ const { db } = ctx;++ const now = iso(ctx.now());++ const ended = await db.prepare(`SELECT * FROM promo_allowances WHERE subject = ? AND window_end <= ?::timestamptz++ AND used_bits + expired_bits < granted_bits ORDER BY window_end, id`).all(subject, now);++ for (const r of ended) {++ const left = open(r);++ await db.prepare('UPDATE promo_allowances SET expired_bits = expired_bits + ?, updated_at = ?::timestamptz WHERE id = ?').run(left, now, r.id);++ // An out-of-band correction may already have taken some of it: never drive promo_credit below zero.++ const bits = Math.min(left, Math.max(0, await balance(db, A.promo(subject))));++ if (bits > 0) {++ await post(ctx, {++ type: 'adjustment', idempotencyKey: `promo:lapse:${r.id}`, actor: ACTOR, fromSubject: subject,++ entries: [entry(A.promo(subject), -bits), entry(A.promoReserve(), bits)],++ metadata: { promo: 'lapse', allowance_id: Number(r.id), service: r.service, amount_bits: bits },++ });++ }++ }++}++++/**++ * Grant `bits` of promo allowance to `subject` for the current `period` window of `service`; a second grant in the++ * same window adds to it. Idempotent by the caller's key (the admin request's Idempotency-Key): a replay moves nothing.++ */++async function grant(ctx, input) {++ const { db } = ctx;++ if (!input.idempotencyKey) fail(422, 'billing.invalid_input', 'a promo grant needs an idempotency key');++ const subject = userSubject(input.subject);++ const service = serviceOf(input.service);++ const period = input.period == null ? 'month' : input.period;++ if (!PERIODS.includes(period)) fail(422, 'billing.invalid_input', `period must be one of ${PERIODS.join(', ')}`);++ const amount = positiveInt(input.bits, 'bits', MAX_GRANT_BITS);++ const key = `promo:grant:${input.idempotencyKey}`;++ return await inTx(db, async () => {++ const existing = await getTxnByKey(db, key);++ if (existing) {++ const row = await db.prepare('SELECT * FROM promo_allowances WHERE id = ?').get(existing.metadata.allowance_id);++ return { allowance: present(row), txn: existing, replay: true };++ }++ await lapse(ctx, subject);++ const now = iso(ctx.now());++ const { start, end } = windowOf(period, ctx.now());++ const row = await db.prepare(`INSERT INTO promo_allowances (subject, service, reset_period, granted_bits, window_start, window_end, created_at, updated_at)++ VALUES (?, ?, ?, ?, ?::timestamptz, ?::timestamptz, ?::timestamptz, ?::timestamptz)++ ON CONFLICT (subject, service, reset_period, window_start)++ DO UPDATE SET granted_bits = promo_allowances.granted_bits + excluded.granted_bits, updated_at = excluded.updated_at++ RETURNING *`).get(subject, service, period, amount, start, end, now, now);++ if (Number(row.granted_bits) > Number.MAX_SAFE_INTEGER) fail(422, 'billing.amount_overflow', 'the allowance would leave the exact integer range');++ const { txn } = await post(ctx, {++ type: 'adjustment', idempotencyKey: key, actor: input.actor || ACTOR, toSubject: subject,++ entries: [entry(A.promoReserve(), -amount), entry(A.promo(subject), amount)],++ metadata: { promo: 'grant', allowance_id: Number(row.id), service, period, window_start: start, window_end: end, amount_bits: amount },++ });++ return { allowance: present(row), txn, replay: false };++ });++}++++/**++ * Unused promo bits `subject` may spend on `service` now (on any service when `service` is omitted): the open windows,++ * never more than the promo_credit balance. Reads only, so a window that ended counts 0 before lapse() books it.++ */++async function remaining(ctx, subject, service) {++ const { db } = ctx;++ const now = iso(ctx.now());++ const scoped = service !== undefined;++ const r = await db.prepare(`SELECT COALESCE(SUM(granted_bits - used_bits - expired_bits), 0)::bigint AS bits FROM promo_allowances++ WHERE subject = ? ${scoped ? "AND service IN (?, '*')" : ''} AND window_start <= ?::timestamptz AND window_end > ?::timestamptz`)++ .get(...(scoped ? [subject, serviceOf(service), now, now] : [subject, now, now]));++ return Math.max(0, Math.min(Number(r.bits), await balance(db, A.promo(subject))));++}++++/**++ * Draw up to `bits` of `subject`'s allowance for `service` (soonest-ending window first) → { bits: consumed, txn, replay }.++ * The key must come from the caller (e.g. the usage reading's idempotency_key): a retry under the same key returns the++ * first draw and consumes nothing more, and the key cannot be reused for another subject or service (409). A call that++ * finds nothing to draw records nothing, so its key stays unused: the caller's own record (the rated reading, itself++ * keyed) is what stops a replay from asking again. Runs inside the caller's transaction when there is one (rating),++ * else in its own.++ */++async function consume(ctx, input) {++ const { db } = ctx;++ if (!input.idempotencyKey) fail(422, 'billing.invalid_input', 'promo consume needs the caller\'s idempotency key');++ const subject = userSubject(input.subject);++ const service = serviceOf(input.service);++ const wanted = positiveInt(input.bits, 'bits');++ const key = `promo:consume:${input.idempotencyKey}`;++ return await inTx(db, async () => {++ const existing = await getTxnByKey(db, key);++ if (existing) {++ if (existing.metadata.subject !== subject || existing.metadata.service !== service) {++ fail(409, 'billing.promo_key_reused', `another draw is already recorded under ${input.idempotencyKey}`);++ }++ return { bits: existing.metadata.amount_bits, txn: existing, replay: true };++ }++ await lapse(ctx, subject);++ const now = iso(ctx.now());++ let left = Math.min(wanted, Math.max(0, await balance(db, A.promo(subject))));++ const rows = await db.prepare(`SELECT * FROM promo_allowances WHERE subject = ? AND service IN (?, '*')++ AND used_bits + expired_bits < granted_bits AND window_start <= ?::timestamptz AND window_end > ?::timestamptz++ ORDER BY window_end, id`).all(subject, service, now, now);++ const drawn = [];++ for (const r of rows) {++ if (left === 0) break;++ const take = Math.min(left, open(r));++ await db.prepare('UPDATE promo_allowances SET used_bits = used_bits + ?, updated_at = ?::timestamptz WHERE id = ?').run(take, now, r.id);++ drawn.push({ allowance_id: Number(r.id), bits: take });++ left -= take;++ }++ const used = drawn.reduce((s, d) => s + d.bits, 0);++ if (!used) return { bits: 0, txn: null, replay: false };++ const { txn } = await post(ctx, {++ type: 'adjustment', idempotencyKey: key, actor: input.actor || ACTOR, fromSubject: subject,++ entries: [entry(A.promo(subject), -used), entry(A.promoReserve(), used)],++ metadata: { promo: 'consume', subject, service, requested_bits: wanted, amount_bits: used, allowances: drawn },++ });++ return { bits: used, txn, replay: false };++ });++}++++module.exports = { grant, consume, remaining, lapse, windowOf, present, PERIODS };@@@ -18,6 +18,8 @@ const { summary } = require('./purchases');const KINDS = ['tip', 'donation', 'paid_interaction'];++// ADR-012 / plan T5: promo credits (A.promo, ops/promo.js) are a non-transferable allowance. create() moves bought++// Vibes only (A.credit(from)); never add A.promo as a source here, and never as a target of refund().async function create(ctx, input) {const { db, rates } = ctx;const amount = positiveInt(input.amount, 'amount', rates.maxBits);@@@ -104,6 +104,10 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {check('receipts.external', !extOffenders.length, extOffenders.length ? { offenders: extOffenders }: { receipts: (await db.prepare('SELECT COUNT(*) AS n FROM external_receipts').get()).n });++ const promoMixed = await db.prepare(`SELECT e.txn_id FROM ledger_entries e JOIN accounts a ON a.id = e.account_id GROUP BY e.txn_id++ HAVING bool_or(a.kind IN ('promo_credit', 'promo_reserve')) AND bool_or(a.kind NOT IN ('promo_credit', 'promo_reserve')) LIMIT 100`).all();++ check('promo.isolated', !promoMixed.length, promoMixed.length ? { offenders: promoMixed } : null);++const frozen = await isFrozen(db);const staleMin = (ctx.config && ctx.config.reconcile && ctx.config.reconcile.staleReceiptMin) || 60;const stale = frozen ? [] : await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL AND received_at < ? ORDER BY id LIMIT 100')@@@ -112,7 +116,7 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {const warnings = {negative_balances: await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id-- WHERE a.kind IN ('user_credit', 'creator_payable', 'payouts_pending') AND b.balance < 0`).all(),++ WHERE a.kind IN ('user_credit', 'creator_payable', 'payouts_pending', 'promo_credit') AND b.balance < 0`).all(),unprocessed_events: await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL ORDER BY id').all(),rejected_events: (await db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected'").all()).map((r) => ({ ...r, result: JSON.parse(r.result) })),nnew file mode 100644@@@ -0,0 +1,26 @@++-- PostgreSQL seed for the cutover rehearsal of migrations/0003_promo_ledger.sql (docs/cutover-pr-openvibe-billing-7.md):++-- a ledger as 0001 + 0002 leave it, with one bought-credit purchase, one tip and its accounts, so the rehearsal proves++-- the widened accounts_kind_check accepts every existing row and that no ledger row or balance moves.++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;nnew file mode 100644@@@ -0,0 +1,212 @@++'use strict';++/**++ * The promo ledger (migrations/0003_promo_ledger.sql, ops/promo.js; plan T5 step 5): free allowance lives in++ * promo_credit:<subject>, issued from promo_reserve by an admin grant and drawn down by consume() under the caller's key.++ * The core invariant: promo bits never become creator money — a subject holding only promo cannot transfer, cash out++ * or recycle, and no adjustment pairs a promo account with any other kind.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, fund, check, done } = require('./helpers/app');++const promo = require('../server/ops/promo');++++const ADMIN = ['billing.ledger.admin'];++const PROMO = { kind: 'promo_credit', currency: 'vibes-bits' };++const RESERVE = { kind: 'promo_reserve', owner: null, currency: 'vibes-bits' };++++/** A real user access token from the Network stub (authorization code + PKCE). */++async function userAccessToken(t) {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const code = t.network.authorize({ subject_id: t.user(900).id, challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), redirect_uri: 'http://x/cb' });++ const res = await fetch(`${t.network.url}/oauth/token`, {++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },++ body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: 'http://x/cb', client_id: 'billing', client_secret: 'shh' }).toString(),++ });++ return (await res.json()).access_token;++}++++(async () => {++ console.log('promo ledger');++ const t = await boot();++ const grant = (body, opts = {}) => t.call('POST', '/api/v1/admin/promo/grant', { body, cap: ADMIN, ...opts });++ const promoBal = async (id) => (await t.balances(id)).promo_bits;++ const allowances = (id) => t.db.prepare('SELECT * FROM promo_allowances WHERE subject = ? ORDER BY id').all(id);++ const txns = async () => (await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n;++++ await check('0003 widened accounts_kind_check (the name 0001 got) to the promo kinds', async () => {++ const c = await t.db.prepare("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conrelid = 'accounts'::regclass AND conname = 'accounts_kind_check'").get();++ assert.ok(c, 'accounts_kind_check exists');++ for (const k of ['user_credit', 'creator_payable', 'fx_conversion', 'promo_credit', 'promo_reserve']) assert.ok(c.def.includes(`'${k}'`), `${k} in ${c.def}`);++ await assert.rejects(t.db.prepare("INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('promo_bogus', NULL, 'vibes-bits', 'x')").run());++ });++++ const alice = t.user(1);++ const bob = t.user(2);++ await check('a grant books promo_reserve → promo_credit as one balanced transaction and opens a month window', async () => {++ const r = await grant({ subject: alice, bits: 300, service: 'openvibe.tools' });++ assert.strictEqual(r.status, 201, r.text);++ const a = r.json.allowance;++ assert.strictEqual(a.granted_bits, 300);++ assert.strictEqual(a.remaining_bits, 300);++ assert.strictEqual(a.reset_period, 'month');++ assert.strictEqual(a.service, 'openvibe.tools');++ const { window_start: ws, window_end: we } = a;++ assert.deepStrictEqual({ start: ws, end: we }, promo.windowOf('month', t.ctx.now()));++ const entries = r.json.transaction.entries.map((e) => [e.account.kind, e.account.owner, e.amount]).sort();++ assert.deepStrictEqual(entries, [['promo_credit', alice.id, 300], ['promo_reserve', null, -300]]);++ assert.strictEqual(r.json.transaction.metadata.promo, 'grant');++ assert.strictEqual(await promo.remaining(t.ctx, alice.id, 'openvibe.tools'), 300);++ assert.strictEqual(await promo.remaining(t.ctx, alice.id, 'media'), 0, 'a service-specific allowance is not spent elsewhere');++ });++++ await check('a second grant in the same window sums granted_bits; a replayed Idempotency-Key moves nothing', async () => {++ const r = await grant({ subject: alice, bits: 200, service: 'openvibe.tools' }, { key: 'promo-grant-0001' });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.allowance.granted_bits, 500);++ const before = await txns();++ const again = await grant({ subject: alice, bits: 200, service: 'openvibe.tools' }, { key: 'promo-grant-0001' });++ assert.ok([200, 201].includes(again.status), again.text);++ assert.strictEqual(await txns(), before);++ const rows = await allowances(alice.id);++ assert.strictEqual(rows.length, 1);++ assert.strictEqual(Number(rows[0].granted_bits), 500);++ assert.strictEqual(await promoBal(alice.id), 500);++ await t.assertReconciled('after grants');++ });++++ await check('GET /balances/:subject reports promo_bits beside credit, payable and pending_payouts', async () => {++ const b = await t.balances(alice.id);++ assert.strictEqual(b.promo_bits, 500);++ assert.strictEqual(b.credit, 0);++ assert.strictEqual(b.payable, 0);++ assert.strictEqual(b.pending_payouts, 0);++ assert.strictEqual((await t.balances(bob.id)).promo_bits, 0);++ });++++ await check('a subject holding only promo cannot transfer: billing.insufficient_funds, nothing moves', async () => {++ const before = await txns();++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: alice, to: bob, amount: 1 } });++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(r.json.code, 'billing.insufficient_funds');++ assert.strictEqual(await txns(), before);++ const b = await t.balances(bob.id);++ assert.strictEqual(b.payable, 0);++ assert.strictEqual(await promoBal(alice.id), 500);++ });++++ await check('a subject holding only promo cannot cash out or recycle', async () => {++ const c = await t.call('POST', '/api/v1/cashouts', { body: { subject: alice, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });++ assert.strictEqual(c.status, 409, c.text);++ assert.strictEqual(c.json.code, 'billing.insufficient_funds');++ const r = await t.call('POST', '/api/v1/recycle', { body: { subject: alice, amount: 100 } });++ assert.strictEqual(r.status, 409, r.text);++ assert.strictEqual(await promoBal(alice.id), 500);++ });++++ await check('recycle still works; promo → payable or credit is impossible, even by an admin adjustment', async () => {++ await fund(t, bob, 1000);++ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: bob, to: alice, amount: 400 } });++ assert.strictEqual(tip.status, 201, tip.text);++ const rec = await t.call('POST', '/api/v1/recycle', { body: { subject: alice, amount: 100 } });++ assert.strictEqual(rec.status, 201, rec.text);++ const b = await t.balances(alice.id);++ assert.deepStrictEqual([b.credit, b.payable, b.promo_bits], [100, 300, 500], 'recycle touched payable and credit only');++ for (const to of [{ kind: 'creator_payable', owner: alice.id, currency: 'vibes-bits' }, { kind: 'user_credit', owner: alice.id, currency: 'vibes-bits' }]) {++ const adj = await t.call('POST', '/api/v1/admin/adjustments', { cap: ADMIN, body: { from: { ...PROMO, owner: alice.id }, to, amount: 1, reason: 'try' } });++ assert.strictEqual(adj.status, 422, adj.text);++ assert.strictEqual(adj.json.code, 'billing.promo_isolated');++ }++ const into = await t.call('POST', '/api/v1/admin/adjustments', { cap: ADMIN, body: { from: { kind: 'platform_revenue', currency: 'vibes-bits' }, to: { ...PROMO, owner: alice.id }, amount: 1, reason: 'try' } });++ assert.strictEqual(into.json.code, 'billing.promo_isolated');++ const fix = await t.call('POST', '/api/v1/admin/adjustments', { cap: ADMIN, body: { from: { ...PROMO, owner: alice.id }, to: RESERVE, amount: 10, reason: 'over-granted' } });++ assert.strictEqual(fix.status, 201, fix.text);++ assert.strictEqual(await promoBal(alice.id), 490);++ assert.strictEqual(await promo.remaining(t.ctx, alice.id, 'openvibe.tools'), 490, 'remaining never exceeds the authoritative balance');++ await t.assertReconciled('after recycle and promo correction');++ });++++ await check('consume() draws the allowance down under the caller\'s key; a retry under the same key consumes nothing more', async () => {++ const out = await promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 90, idempotencyKey: 'usage:reading:1' });++ assert.strictEqual(out.bits, 90);++ assert.strictEqual(out.replay, false);++ const after = { bal: await promoBal(alice.id), used: Number((await allowances(alice.id))[0].used_bits), n: await txns() };++ assert.deepStrictEqual([after.bal, after.used], [400, 90]);++ const retry = await promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 90, idempotencyKey: 'usage:reading:1' });++ assert.strictEqual(retry.replay, true);++ assert.strictEqual(retry.bits, 90);++ assert.strictEqual(retry.txn.id, out.txn.id);++ assert.deepStrictEqual({ bal: await promoBal(alice.id), used: Number((await allowances(alice.id))[0].used_bits), n: await txns() }, after);++ const entries = out.txn.entries.map((e) => [e.kind, e.owner, e.amount]).sort();++ assert.deepStrictEqual(entries, [['promo_credit', alice.id, -90], ['promo_reserve', null, 90]]);++ await assert.rejects(promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 1 }), (e) => e.code === 'billing.invalid_input');++ for (const other of [{ subject: bob.id, service: 'openvibe.tools' }, { subject: alice.id, service: 'media' }]) {++ await assert.rejects(promo.consume(t.ctx, { ...other, bits: 90, idempotencyKey: 'usage:reading:1' }), (e) => e.code === 'billing.promo_key_reused');++ }++ });++++ await check('consume() is capped by what is left and by the service; a "*" grant serves any service', async () => {++ assert.strictEqual((await promo.consume(t.ctx, { subject: alice.id, service: 'media', bits: 5, idempotencyKey: 'usage:reading:2' })).bits, 0);++ const all = await promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 10_000, idempotencyKey: 'usage:reading:3' });++ assert.strictEqual(all.bits, 400);++ assert.strictEqual(await promoBal(alice.id), 0);++ assert.strictEqual((await grant({ subject: bob, bits: 50 })).status, 201);++ assert.strictEqual((await promo.consume(t.ctx, { subject: bob.id, service: 'media', bits: 20, idempotencyKey: 'usage:reading:4' })).bits, 20);++ assert.strictEqual(await promo.remaining(t.ctx, bob.id, 'anything'), 30);++ await t.assertReconciled('after consumption');++ });++++ await check('a window that ends lapses: its unused bits return to promo_reserve once', async () => {++ const carol = t.user(3);++ assert.strictEqual((await grant({ subject: carol, bits: 70, period: 'day' })).status, 201);++ await promo.consume(t.ctx, { subject: carol.id, service: 'media', bits: 20, idempotencyKey: 'usage:reading:5' });++ t.clock.offset += 86_400_000;++ assert.strictEqual(await promo.remaining(t.ctx, carol.id, 'media'), 0);++ assert.strictEqual(await promoBal(carol.id), 0, 'GET /balances does not count an ended window before it lapses');++ assert.strictEqual((await promo.consume(t.ctx, { subject: carol.id, service: 'media', bits: 5, idempotencyKey: 'usage:reading:6' })).bits, 0);++ const [row] = await allowances(carol.id);++ assert.deepStrictEqual([Number(row.used_bits), Number(row.expired_bits)], [20, 50]);++ assert.strictEqual(await promoBal(carol.id), 0);++ const n = await txns();++ await t.ctx.db.tx(() => promo.lapse(t.ctx, carol.id));++ assert.strictEqual(await txns(), n, 'a lapsed window lapses once');++ const fresh = await grant({ subject: carol, bits: 15, period: 'day' });++ assert.strictEqual(fresh.json.allowance.remaining_bits, 15, 'the next window starts afresh');++ t.clock.offset = 0;++ const r = await t.assertReconciled('after lapse');++ assert.ok(r.checks.find((c) => c.id === 'promo.isolated').ok);++ });++++ await check('period "none" is one window that never ends (window_end 9999-12-31T23:59:59.999Z) and never lapses', async () => {++ const dave = t.user(4);++ const r = await grant({ subject: dave, bits: 25, period: 'none' });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.json.allowance.window_start, r.json.allowance.window_end], ['1970-01-01T00:00:00.000Z', '9999-12-31T23:59:59.999Z']);++ t.clock.offset += 400 * 86_400_000;++ assert.strictEqual(await promoBal(dave.id), 25);++ assert.strictEqual((await promo.consume(t.ctx, { subject: dave.id, service: 'media', bits: 5, idempotencyKey: 'usage:reading:7' })).bits, 5);++ assert.strictEqual((await grant({ subject: dave, bits: 10, period: 'none' })).json.allowance.remaining_bits, 30, 'a second grant tops up the same window');++ t.clock.offset = 0;++ await t.assertReconciled('after a period "none" grant');++ });++++ await check('a grant needs billing.ledger.admin and a valid body: no token 401, a user token 401, another capability 403', async () => {++ assert.strictEqual((await grant({ subject: alice, bits: 1 }, { token: null })).status, 401);++ assert.strictEqual((await grant({ subject: alice, bits: 1 }, { token: await userAccessToken(t) })).status, 401);++ const denied = await grant({ subject: alice, bits: 1 }, { cap: ['billing.balance.read', 'billing.transfer.create'] });++ assert.strictEqual(denied.status, 403, denied.text);++ for (const body of [{ subject: alice, bits: 0 }, { subject: alice, bits: 1.5 }, { subject: alice, bits: 1, period: 'week' }, { subject: 'gst_x', bits: 1 }, { bits: 1 }]) {++ const r = await grant(body);++ assert.strictEqual(r.status, 422, `${JSON.stringify(body)} → ${r.status} ${r.text}`);++ }++ });++++ await check('a grant is refused while the economy is frozen', async () => {++ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { cap: ADMIN, key: null, body: { on: true, reason: 'test' } })).status, 200);++ const r = await grant({ subject: alice, bits: 1 });++ assert.strictEqual(r.status, 503, r.text);++ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { cap: ADMIN, key: null, body: { on: false } })).status, 200);++ });++++ await t.close();++ done();++})();
@@ -23,16 +23,31 @@ webhooks land here and nowhere else.
@@ -23,16 +23,31 @@ webhooks land here and nowhere else.
|---|---|
|---|---|
| CREDIT (Vibes bought, spendable, never withdrawable by the buyer) | `user_credit:<subject>` (vibes-bits) |
| CREDIT (Vibes bought, spendable, never withdrawable by the buyer) | `user_credit:<subject>` (vibes-bits) |
| MONEY (creator payable, payouts, receipts, platform revenue) | `creator_payable:<subject>`, `payouts_pending:<subject>`, `provider_clearing:<provider>`, `platform_revenue`, `refunds`, `chargeback_loss` |
| MONEY (creator payable, payouts, receipts, platform revenue) | `creator_payable:<subject>`, `payouts_pending:<subject>`, `provider_clearing:<provider>`, `platform_revenue`, `refunds`, `chargeback_loss` |
+| PROMO (free service allowance, plan T5 ledger 2: never spendable as CREDIT, never MONEY) | `promo_credit:<subject>` (vibes-bits), issued from and returned to `promo_reserve` (platform, vibes-bits); windows in `promo_allowances`. **Counterparty: a dedicated `promo_reserve`, not `platform_revenue`**: free allowance is a marketing cost, not income, so debiting revenue would understate earnings and blur the one account that reports what the platform made; a reserve of its own always equals −Σ `promo_credit` and is checked in isolation |
| ENTITLEMENT (channel subscriptions) | `subscriptions` + `entitlements` (one row per paid period) |
| ENTITLEMENT (channel subscriptions) | `subscriptions` + `entitlements` (one row per paid period) |
| EXTERNAL (tips on a streamer's own PowerChat) | stored receipt (`external_receipts`), no journal entry; announced as `billing.receipt.external` once Billing is the authority; a direct-route subscription grants its entitlement only |
| EXTERNAL (tips on a streamer's own PowerChat) | stored receipt (`external_receipts`), no journal entry; announced as `billing.receipt.external` once Billing is the authority; a direct-route subscription grants its entitlement only |
| LOYALTY, COSMETIC, GAME-STATE, LEGACY | not here (rule 5–8) |
| LOYALTY, COSMETIC, GAME-STATE, LEGACY | not here (rule 5–8) |
+**Three ledgers, one journal.** Bought Vibes (`user_credit`), creator money (`creator_payable` and its payouts)
+and free promo allowance (`promo_credit`) are separate account kinds that never mix. Credit becomes money only
+through a transfer (`transfers.create`: `user_credit` → `creator_payable`); money leaves only through a cashout
+(`creator_payable` → `payouts_pending`). Promo has no such path: it is granted by an admin
+(`POST /api/v1/admin/promo/grant`: `promo_reserve` → `promo_credit`) and only drawn down by usage
+(`ops/promo.js` `consume()`, keyed by the caller's idempotency key) or lapses when its window ends, both back to
+`promo_reserve`. Transfers read `user_credit` only and cashouts `creator_payable` only, so a subject holding only
+promo is refused with `billing.insufficient_funds`; an admin adjustment that pairs a promo account with any other
+kind is refused (`billing.promo_isolated`), and reconciliation's `promo.isolated` check fails on any transaction
+that mixes them. `promo_allowances` is the policy and window record (`granted_bits`, `used_bits`, `expired_bits`
+per subject, service, period and window); the `promo_credit` balance is the authoritative remainder, and every
+promo write changes both in one transaction.
+
## Owns
## Owns
- the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts
- the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts
- (`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, the
- freeze, reconciliation runs, import runs and the staff audit log, in Billing's own PostgreSQL database
- (`ov_billing` on the host's data role, ADR-035; schema in [migrations/](migrations/))
+ (`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, usage readings
+ (`usage_records`), promo allowances (`promo_allowances`), the freeze, reconciliation runs, import runs and the
+ staff audit log, in Billing's own PostgreSQL database (`ov_billing` on the host's data role, ADR-035;
+ schema in [migrations/](migrations/))
- provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments): they land here and nowhere else
- provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments): they land here and nowhere else
- the `billing.*` events and the public billing policy page (`/policy`, every number from the rates)
- the `billing.*` events and the public billing policy page (`/policy`, every number from the rates)
@@ -149,7 +164,7 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
@@ -149,7 +164,7 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first |
| `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first |
| `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read |
| `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read |
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |
| `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, subscription}` — needs nothing but Billing |
-| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, payable_value_cents}` |
+| `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, 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; 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) |
@@ -157,7 +172,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
@@ -157,7 +172,8 @@ are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "typ
| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
| `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report |
| `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report |
| `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) |
+| `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` |
| `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` |
@@ -8,9 +8,9 @@
@@ -8,9 +8,9 @@
"authoritative": false,
"authoritative": false,
"deployed": true,
"deployed": true,
"deployment": "Deployed in shadow with `sudo ovhost deploy billing`: /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), /api/health, /api/ready (openvibe-shared/ready shape), /release.json and /webhooks/*. No provider secret is configured, so every provider is off and /webhooks/* answers 404 billing.provider_disabled.",
"deployment": "Deployed in shadow with `sudo ovhost deploy billing`: /opt/openvibe.billing, unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), the billing policy (/policy), /api/health, /api/ready (openvibe-shared/ready shape), /release.json and /webhooks/*. No provider secret is configured, so every provider is off and /webhooks/* answers 404 billing.provider_disabled.",
- "contracts": "openvibe-contracts v0.76.0",
+ "contracts": "openvibe-contracts v0.85.0",
"notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation (GET /api/v1/admin/reconciliations; 16 runs by 2026-09-24 05:06 UTC, ledger-vs-receipt checks), EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing; recorded only while Live is the authority), overflow guards and serializable settlement (PostgreSQL, ADR-035). Production holds the shadow import of Live (322 transactions, 996 ledger entries), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat; a duplicate-webhook proof in production follows it. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built, tested and deployed, with /metrics (loopback only), hourly scheduled reconciliation (GET /api/v1/admin/reconciliations; 16 runs by 2026-09-24 05:06 UTC, ledger-vs-receipt checks), EXTERNAL PowerChat receipts (billing.receipt.external under BILLING_AUTHORITY=billing; recorded only while Live is the authority), overflow guards and serializable settlement (PostgreSQL, ADR-035). Production holds the shadow import of Live (322 transactions, 996 ledger entries), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat; a duplicate-webhook proof in production follows it. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
- "updated": "2026-09-28",
+ "updated": "2026-10-04",
"features": [
"features": [
"append-only double-entry journal in bits and cents, balanced per currency",
"append-only double-entry journal in bits and cents, balanced per currency",
"provider receipts stored before processing, settled once (PowerChat, Stripe, PayPal, CCBill, NOWPayments adapters)",
"provider receipts stored before processing, settled once (PowerChat, Stripe, PayPal, CCBill, NOWPayments adapters)",
@@ -23,6 +23,8 @@
@@ -23,6 +23,8 @@
"public billing policy page",
"public billing policy page",
"Live shadow import",
"Live shadow import",
"outbox events billing.*",
"outbox events billing.*",
- "per-person limits (openvibe-sdk/limits)"
+ "per-person limits (openvibe-sdk/limits)",
+ "usage readings (billing.usage.record)",
+ "promo allowance ledger"
]
]
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+# Cutover runbook — PR #7 (OpenVibe.Billing promo/free-credit ledger)
+
+PR #7 adds the promo ledger (plan T5 step 5): two account kinds, `promo_credit:<subject>` and
+`promo_reserve` (both vibes-bits), one empty table, `promo_allowances`, one admin route
+(`POST /api/v1/admin/promo/grant`, `billing.ledger.admin`) and a `promo_bits` field on
+`GET /api/v1/balances/:subject`. **The only ALTER widens `accounts_kind_check`** to accept the two new
+kinds; no ledger row moves, no balance changes, no existing table gains or loses a column. No provider
+webhook, auth, capability or Contracts change. The importer (`server/importer/live.js`) is untouched.
+
+## What the migration does
+
+`migrations/0003_promo_ledger.sql` (`phase: expand`), in one transaction:
+
+```sql
+ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;
+ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing',
+ 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve'));
+
+CREATE TABLE promo_allowances (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ 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')),
+ granted_bits bigint NOT NULL CHECK (granted_bits > 0),
+ used_bits bigint NOT NULL DEFAULT 0 CHECK (used_bits >= 0),
+ expired_bits bigint NOT NULL DEFAULT 0 CHECK (expired_bits >= 0),
+ window_start timestamptz NOT NULL,
+ window_end timestamptz NOT NULL CHECK (window_end > window_start),
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL,
+ CHECK (used_bits + expired_bits <= granted_bits),
+ UNIQUE (subject, service, reset_period, window_start)
+);
+CREATE INDEX promo_allowances_open ON promo_allowances (subject, window_end) WHERE used_bits + expired_bits < granted_bits;
+```
+
+`accounts_kind_check` is the name PostgreSQL gave 0001's inline CHECK on `accounts.kind`
+(`SELECT conname FROM pg_constraint WHERE conrelid = 'accounts'::regclass`); the new CHECK is the old list
+plus two values, so every existing row satisfies it and the `ADD CONSTRAINT` validation scan only reads
+`accounts` (a few hundred rows in production: 322 imported transactions). The `ACCESS EXCLUSIVE` lock on
+`accounts` is held for that scan only, inside the migration's transaction, before the service serves.
+
+## What is not changed
+
+- No ledger row (`transactions`, `ledger_entries`, `account_balances`) is written, updated or deleted.
+- No existing column, index, trigger or transaction type changes (`transactions_type_check` is untouched;
+ promo movements are `adjustment` transactions with `metadata.promo` = grant | consume | lapse).
+- The importer still maps Live's `bonus` to `user_credit` (O1-owned; see the PR body).
+- No `BILLING_AUTHORITY`, webhook, service-token, capability or Contracts change. Nothing calls the grant
+ route on the day of the merge; rating (T5 step 6) is the first caller of `consume()`.
+
+## 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 state` (from `/opt/openvibe.billing`) returns
+ `{"on": false}`.
+
+## Rehearsal
+
+`ov rehearse` builds the base from main's migrations (0001, 0002), loads the PostgreSQL seed below (a
+purchase and a tip with their accounts and balances; not a SQLite fixture), applies 0003, runs the
+migrations a second time (nothing must apply), then the check: the widened CHECK holds all eleven kinds,
+the five seeded balances and six entries are exactly as seeded, and `promo_allowances` is empty.
+
+```rehearse
+seed: test/fixtures/promo-ledger-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("accounts","accounts_kind_check");for(const k of ["user_credit","creator_payable","fx_conversion","promo_credit","promo_reserve"])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 promo_allowances").get()).n),0);console.log("0003: accounts_kind_check widened, 5 balances and 6 entries unchanged, promo_allowances 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 = 'accounts'::regclass AND conname = 'accounts_kind_check';
+SELECT id, name, phase FROM ov_migrations WHERE id = '0003'; -- 0003 | promo_ledger | expand
+SELECT COUNT(*) FROM promo_allowances; -- 0
+```
+
+and compare `node scripts/reconcile.js` before and after: the same account, transaction and entry counts,
+every check ok (the new `promo.isolated` check passes on a ledger with no promo rows).
+
+## Cutover
+
+1. **Merge the PR** once the rehearsal marker is `{"ok": true}`.
+2. **Deploy:** `sudo ovhost deploy billing "promo ledger (PR #7) — migration 0003 widens accounts_kind_check"`.
+ No lockfile change. The boot applies `0003_promo_ledger.sql` once
+ (`migrations: applied 0003_promo_ledger (expand, NNms)`).
+3. **Verify on production:**
+ - `/api/ready` is 200; `ov_migrations` has the `0003` row; `\d promo_allowances` lists the table.
+ - `GET /api/v1/balances/<subject>` answers with `promo_bits: 0` beside the unchanged `credit`/`payable`.
+ - `node scripts/reconcile.js` shows the same totals as before the deploy, every check ok, `promo.isolated` included.
+
+## Rollback
+
+- **No grant has been made (the day of the merge).** `sudo ovhost rollback billing`, then restore the old
+ CHECK and drop the empty table in one transaction:
+ ```sql
+ BEGIN;
+ DROP TABLE promo_allowances;
+ ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;
+ ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing',
+ 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'));
+ DELETE FROM ov_migrations WHERE id = '0003';
+ COMMIT;
+ ```
+ Leaving them in place is also safe: the previous release never writes a promo kind.
+- **Grants have been made.** The journal is append-only: promo rows cannot be deleted and the narrow CHECK
+ cannot be restored over them. Roll back the code only (`sudo ovhost rollback billing`); the previous
+ release ignores `promo_credit`/`promo_reserve` (its balances route does not report them and no code path
+ moves them), and the snapshot is the last resort.
+
+## PR description
+
+````
+```cutover
+{"runbook": "docs/cutover-pr-openvibe-billing-7.md", "rehearsal": "billing-promo-ledger-001"}
+```
+````
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+-- phase: expand
+-- The promo/free-credit ledger (plan T5 step 5, ledger 2): a recurring service allowance that can never become creator
+-- money. Two new account kinds: promo_credit:<subject> (vibes-bits) holds a person's unused allowance, and promo_reserve
+-- (platform, vibes-bits) is the marketing reserve it is issued from and returns to when consumed or lapsed, so free
+-- allowance never touches platform_revenue. transfers.create reads user_credit only and cashouts read creator_payable
+-- only, so no code path moves a promo bit into MONEY. promo_allowances is the policy and window record; the promo_credit
+-- balance is the authoritative remainder (ops/promo.js writes both in one transaction).
+-- The only ALTER widens accounts_kind_check (the name PostgreSQL gave 0001's inline CHECK on accounts.kind): no row moves.
+
+ALTER TABLE accounts DROP CONSTRAINT accounts_kind_check;
+ALTER TABLE accounts ADD CONSTRAINT accounts_kind_check CHECK (kind IN ('user_credit', 'creator_payable', 'provider_clearing',
+ 'platform_revenue', 'payouts_pending', 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve'));
+
+-- One allowance per subject, service ('*' = any eligible service), period and window. used_bits were consumed;
+-- expired_bits lapsed when the window ended (returned to promo_reserve).
+CREATE TABLE promo_allowances (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ 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')),
+ granted_bits bigint NOT NULL CHECK (granted_bits > 0),
+ used_bits bigint NOT NULL DEFAULT 0 CHECK (used_bits >= 0),
+ expired_bits bigint NOT NULL DEFAULT 0 CHECK (expired_bits >= 0),
+ window_start timestamptz NOT NULL,
+ window_end timestamptz NOT NULL CHECK (window_end > window_start),
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL,
+ CHECK (used_bits + expired_bits <= granted_bits),
+ UNIQUE (subject, service, reset_period, window_start)
+);
+
+CREATE INDEX promo_allowances_open ON promo_allowances (subject, window_end) WHERE used_bits + expired_bits < granted_bits;
@@ -26,6 +26,7 @@ const providers = require('../providers');
@@ -26,6 +26,7 @@ const providers = require('../providers');
const { reconcile } = require('../reconcile');
const { reconcile } = require('../reconcile');
const external = require('../ops/external');
const external = require('../ops/external');
const usage = require('../ops/usage');
const usage = require('../ops/usage');
+const promo = require('../ops/promo');
const CAP = {
const CAP = {
intent: 'billing.intent.create',
intent: 'billing.intent.create',
@@ -217,8 +218,9 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -217,8 +218,9 @@ function v1Router({ ctx, auth, adapters, limits }) {
const credit = await balance(db, A.credit(subject));
const credit = await balance(db, A.credit(subject));
const payable = await balance(db, A.payable(subject));
const payable = await balance(db, A.payable(subject));
const pending = await balance(db, A.pending(subject));
const pending = await balance(db, A.pending(subject));
+ const promoBits = await promo.remaining(ctx, subject);
return {
return {
- subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending,
+ subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending, promo_bits: promoBits,
payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,
payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,
};
};
}
}
@@ -307,6 +309,11 @@ function v1Router({ ctx, auth, adapters, limits }) {
@@ -307,6 +309,11 @@ function v1Router({ ctx, auth, adapters, limits }) {
const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
const out = await admin.adjust(ctx, { from: b.from, to: b.to, amount: b.amount, reason: b.reason, relatesTo: b.relates_to, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
res.status(201).json(txnOut(out.txn));
res.status(201).json(txnOut(out.txn));
}));
}));
+ r.post('/admin/promo/grant', ...write(CAP.admin, async (req, res) => {
+ const b = req.body || {};
+ 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) });
+ }));
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);
@@ -18,7 +18,9 @@ const path = require('path');
@@ -18,7 +18,9 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
- 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'];
+ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion', 'promo_credit', 'promo_reserve'];
+// The promo ledger's kinds (migrations/0003): their entries never share a transaction with any other kind.
+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'];
@@ -48,4 +50,4 @@ async function openDb(config, { log = console, registry } = {}) {
@@ -48,4 +50,4 @@ async function openDb(config, { log = console, registry } = {}) {
return createDb({ url: config.db.url, service: 'billing', registry, log });
return createDb({ url: config.db.url, service: 'billing', registry, log });
}
}
-module.exports = { openDb, MIGRATIONS, ACCOUNT_KINDS, CURRENCIES, TXN_TYPES };
+module.exports = { openDb, MIGRATIONS, ACCOUNT_KINDS, PROMO_KINDS, CURRENCIES, TXN_TYPES };
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
const { post, getTxn, iso, money } = require('../ledger');
const { post, getTxn, iso, money } = require('../ledger');
const { enqueue } = require('../outbox');
const { enqueue } = require('../outbox');
const { entry, fail, positiveInt, text } = require('./common');
const { entry, fail, positiveInt, text } = require('./common');
-const { ACCOUNT_KINDS, CURRENCIES } = require('../db');
+const { ACCOUNT_KINDS, PROMO_KINDS, CURRENCIES } = require('../db');
const { summary } = require('./purchases');
const { summary } = require('./purchases');
async function freezeState(db) {
async function freezeState(db) {
@@ -37,6 +37,10 @@ async function adjust(ctx, input) {
@@ -37,6 +37,10 @@ async function adjust(ctx, input) {
const from = account(input.from, 'from');
const from = account(input.from, 'from');
const to = account(input.to, 'to');
const to = account(input.to, 'to');
if (from.currency !== to.currency) fail(422, 'billing.invalid_input', 'an adjustment moves value within one currency');
if (from.currency !== to.currency) fail(422, 'billing.invalid_input', 'an adjustment moves value within one currency');
+ // Free allowance never becomes credit or money: a promo account is corrected only against the other promo kind.
+ if (PROMO_KINDS.includes(from.kind) !== PROMO_KINDS.includes(to.kind)) {
+ fail(422, 'billing.promo_isolated', 'promo_credit and promo_reserve adjust only against each other; free allowance never becomes credit or money');
+ }
const amount = positiveInt(input.amount, 'amount', 1_000_000_000);
const amount = positiveInt(input.amount, 'amount', 1_000_000_000);
const reason = text(input.reason, 'reason', 500);
const reason = text(input.reason, 'reason', 500);
if (!reason) fail(422, 'billing.invalid_input', 'an adjustment needs a reason');
if (!reason) fail(422, 'billing.invalid_input', 'an adjustment needs a reason');
@@ -16,6 +16,9 @@ const MAX_RECEIPT_CENTS = 100_000_000;
@@ -16,6 +16,9 @@ const MAX_RECEIPT_CENTS = 100_000_000;
/** Account addresses. owner is a subject id, a provider slug, 'hold:live:<id>' or null (platform). */
/** Account addresses. owner is a subject id, a provider slug, 'hold:live:<id>' or null (platform). */
const A = {
const A = {
credit: (s) => ({ kind: 'user_credit', owner: s, currency: BITS }),
credit: (s) => ({ kind: 'user_credit', owner: s, currency: BITS }),
+ // Free allowance (ops/promo.js): never spendable as credit, never MONEY; it moves only to and from promoReserve.
+ promo: (s) => ({ kind: 'promo_credit', owner: s, currency: BITS }),
+ promoReserve: () => ({ kind: 'promo_reserve', owner: null, currency: BITS }),
payable: (s) => ({ kind: 'creator_payable', owner: s, currency: BITS }),
payable: (s) => ({ kind: 'creator_payable', owner: s, currency: BITS }),
pending: (s) => ({ kind: 'payouts_pending', owner: s, currency: BITS }),
pending: (s) => ({ kind: 'payouts_pending', owner: s, currency: BITS }),
clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CENTS }),
clearing: (provider) => ({ kind: 'provider_clearing', owner: provider, currency: CENTS }),
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+
+/**
+ * The promo ledger (plan T5 ledger 2): a recurring, non-transferable free allowance, kept in the journal under its own
+ * account kinds so it can never become MONEY.
+ *
+ * grant() promo_reserve → promo_credit:<subject> (admin only; tops up the allowance of the current window)
+ * consume() promo_credit:<subject> → promo_reserve (rating draws the allowance down; keyed by the caller)
+ * lapse promo_credit:<subject> → promo_reserve (a window that ended returns what was left unused)
+ *
+ * transfers.create reads A.credit (user_credit) and cashouts read A.payable (creator_payable) only, so a promo bit has
+ * no path to creator_payable; admin adjustments refuse to pair a promo account with any other kind. promo_allowances
+ * is the policy and window record; the promo_credit balance is the authoritative remainder: every write here changes
+ * both in one transaction, and reconciliation checks that no transaction mixes promo and other accounts.
+ */
+const { post, balance, getTxnByKey, iso, money } = require('../ledger');
+const { A, entry, fail, positiveInt, text, userSubject } = require('./common');
+
+const PERIODS = ['day', 'month', 'none'];
+const MAX_GRANT_BITS = 1_000_000_000;
+const ACTOR = { type: 'service', id: 'billing' };
+const NEVER = '9999-12-31T23:59:59.999Z';
+
+/** The [start, end) window of `period` that covers `at` (ms), as ISO strings; 'none' is one window for all time. */
+function windowOf(period, at) {
+ const d = new Date(at);
+ if (period === 'none') return { start: iso(0), end: NEVER }; // the last ISO-8601 instant PostgreSQL parses back
+ if (period === 'day') {
+ const s = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
+ return { start: iso(s), end: iso(s + 86_400_000) };
+ }
+ return { start: iso(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)), end: iso(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)) };
+}
+
+const serviceOf = (v) => text(v, 'service', 100) || '*';
+const inTx = (db, fn) => (db.inTransaction() ? fn() : money(db, fn));
+const open = (r) => Number(r.granted_bits) - Number(r.used_bits) - Number(r.expired_bits);
+
+function present(r) {
+ if (!r) return null;
+ return {
+ id: Number(r.id), subject: { type: 'user', id: r.subject }, service: r.service, reset_period: r.reset_period,
+ granted_bits: Number(r.granted_bits), used_bits: Number(r.used_bits), expired_bits: Number(r.expired_bits), remaining_bits: open(r),
+ window_start: new Date(r.window_start).toISOString(), window_end: new Date(r.window_end).toISOString(),
+ };
+}
+
+/** Return what windows that have ended left unused to promo_reserve (once per allowance). Inside the caller's transaction. */
+async function lapse(ctx, subject) {
+ const { db } = ctx;
+ const now = iso(ctx.now());
+ const ended = await db.prepare(`SELECT * FROM promo_allowances WHERE subject = ? AND window_end <= ?::timestamptz
+ AND used_bits + expired_bits < granted_bits ORDER BY window_end, id`).all(subject, now);
+ for (const r of ended) {
+ const left = open(r);
+ await db.prepare('UPDATE promo_allowances SET expired_bits = expired_bits + ?, updated_at = ?::timestamptz WHERE id = ?').run(left, now, r.id);
+ // An out-of-band correction may already have taken some of it: never drive promo_credit below zero.
+ const bits = Math.min(left, Math.max(0, await balance(db, A.promo(subject))));
+ if (bits > 0) {
+ await post(ctx, {
+ type: 'adjustment', idempotencyKey: `promo:lapse:${r.id}`, actor: ACTOR, fromSubject: subject,
+ entries: [entry(A.promo(subject), -bits), entry(A.promoReserve(), bits)],
+ metadata: { promo: 'lapse', allowance_id: Number(r.id), service: r.service, amount_bits: bits },
+ });
+ }
+ }
+}
+
+/**
+ * Grant `bits` of promo allowance to `subject` for the current `period` window of `service`; a second grant in the
+ * same window adds to it. Idempotent by the caller's key (the admin request's Idempotency-Key): a replay moves nothing.
+ */
+async function grant(ctx, input) {
+ const { db } = ctx;
+ if (!input.idempotencyKey) fail(422, 'billing.invalid_input', 'a promo grant needs an idempotency key');
+ const subject = userSubject(input.subject);
+ const service = serviceOf(input.service);
+ const period = input.period == null ? 'month' : input.period;
+ if (!PERIODS.includes(period)) fail(422, 'billing.invalid_input', `period must be one of ${PERIODS.join(', ')}`);
+ const amount = positiveInt(input.bits, 'bits', MAX_GRANT_BITS);
+ const key = `promo:grant:${input.idempotencyKey}`;
+ return await inTx(db, async () => {
+ const existing = await getTxnByKey(db, key);
+ if (existing) {
+ const row = await db.prepare('SELECT * FROM promo_allowances WHERE id = ?').get(existing.metadata.allowance_id);
+ return { allowance: present(row), txn: existing, replay: true };
+ }
+ await lapse(ctx, subject);
+ const now = iso(ctx.now());
+ const { start, end } = windowOf(period, ctx.now());
+ const row = await db.prepare(`INSERT INTO promo_allowances (subject, service, reset_period, granted_bits, window_start, window_end, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?::timestamptz, ?::timestamptz, ?::timestamptz, ?::timestamptz)
+ ON CONFLICT (subject, service, reset_period, window_start)
+ DO UPDATE SET granted_bits = promo_allowances.granted_bits + excluded.granted_bits, updated_at = excluded.updated_at
+ RETURNING *`).get(subject, service, period, amount, start, end, now, now);
+ if (Number(row.granted_bits) > Number.MAX_SAFE_INTEGER) fail(422, 'billing.amount_overflow', 'the allowance would leave the exact integer range');
+ const { txn } = await post(ctx, {
+ type: 'adjustment', idempotencyKey: key, actor: input.actor || ACTOR, toSubject: subject,
+ entries: [entry(A.promoReserve(), -amount), entry(A.promo(subject), amount)],
+ metadata: { promo: 'grant', allowance_id: Number(row.id), service, period, window_start: start, window_end: end, amount_bits: amount },
+ });
+ return { allowance: present(row), txn, replay: false };
+ });
+}
+
+/**
+ * Unused promo bits `subject` may spend on `service` now (on any service when `service` is omitted): the open windows,
+ * never more than the promo_credit balance. Reads only, so a window that ended counts 0 before lapse() books it.
+ */
+async function remaining(ctx, subject, service) {
+ const { db } = ctx;
+ const now = iso(ctx.now());
+ const scoped = service !== undefined;
+ const r = await db.prepare(`SELECT COALESCE(SUM(granted_bits - used_bits - expired_bits), 0)::bigint AS bits FROM promo_allowances
+ WHERE subject = ? ${scoped ? "AND service IN (?, '*')" : ''} AND window_start <= ?::timestamptz AND window_end > ?::timestamptz`)
+ .get(...(scoped ? [subject, serviceOf(service), now, now] : [subject, now, now]));
+ return Math.max(0, Math.min(Number(r.bits), await balance(db, A.promo(subject))));
+}
+
+/**
+ * Draw up to `bits` of `subject`'s allowance for `service` (soonest-ending window first) → { bits: consumed, txn, replay }.
+ * The key must come from the caller (e.g. the usage reading's idempotency_key): a retry under the same key returns the
+ * first draw and consumes nothing more, and the key cannot be reused for another subject or service (409). A call that
+ * finds nothing to draw records nothing, so its key stays unused: the caller's own record (the rated reading, itself
+ * keyed) is what stops a replay from asking again. Runs inside the caller's transaction when there is one (rating),
+ * else in its own.
+ */
+async function consume(ctx, input) {
+ const { db } = ctx;
+ if (!input.idempotencyKey) fail(422, 'billing.invalid_input', 'promo consume needs the caller\'s idempotency key');
+ const subject = userSubject(input.subject);
+ const service = serviceOf(input.service);
+ const wanted = positiveInt(input.bits, 'bits');
+ const key = `promo:consume:${input.idempotencyKey}`;
+ return await inTx(db, async () => {
+ const existing = await getTxnByKey(db, key);
+ if (existing) {
+ if (existing.metadata.subject !== subject || existing.metadata.service !== service) {
+ fail(409, 'billing.promo_key_reused', `another draw is already recorded under ${input.idempotencyKey}`);
+ }
+ return { bits: existing.metadata.amount_bits, txn: existing, replay: true };
+ }
+ await lapse(ctx, subject);
+ const now = iso(ctx.now());
+ let left = Math.min(wanted, Math.max(0, await balance(db, A.promo(subject))));
+ const rows = await db.prepare(`SELECT * FROM promo_allowances WHERE subject = ? AND service IN (?, '*')
+ AND used_bits + expired_bits < granted_bits AND window_start <= ?::timestamptz AND window_end > ?::timestamptz
+ ORDER BY window_end, id`).all(subject, service, now, now);
+ const drawn = [];
+ for (const r of rows) {
+ if (left === 0) break;
+ const take = Math.min(left, open(r));
+ await db.prepare('UPDATE promo_allowances SET used_bits = used_bits + ?, updated_at = ?::timestamptz WHERE id = ?').run(take, now, r.id);
+ drawn.push({ allowance_id: Number(r.id), bits: take });
+ left -= take;
+ }
+ const used = drawn.reduce((s, d) => s + d.bits, 0);
+ if (!used) return { bits: 0, txn: null, replay: false };
+ const { txn } = await post(ctx, {
+ type: 'adjustment', idempotencyKey: key, actor: input.actor || ACTOR, fromSubject: subject,
+ entries: [entry(A.promo(subject), -used), entry(A.promoReserve(), used)],
+ metadata: { promo: 'consume', subject, service, requested_bits: wanted, amount_bits: used, allowances: drawn },
+ });
+ return { bits: used, txn, replay: false };
+ });
+}
+
+module.exports = { grant, consume, remaining, lapse, windowOf, present, PERIODS };
@@ -18,6 +18,8 @@ const { summary } = require('./purchases');
@@ -18,6 +18,8 @@ const { summary } = require('./purchases');
const KINDS = ['tip', 'donation', 'paid_interaction'];
const KINDS = ['tip', 'donation', 'paid_interaction'];
+// ADR-012 / plan T5: promo credits (A.promo, ops/promo.js) are a non-transferable allowance. create() moves bought
+// Vibes only (A.credit(from)); never add A.promo as a source here, and never as a target of refund().
async function create(ctx, input) {
async function create(ctx, input) {
const { db, rates } = ctx;
const { db, rates } = ctx;
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
const amount = positiveInt(input.amount, 'amount', rates.maxBits);
@@ -104,6 +104,10 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -104,6 +104,10 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
check('receipts.external', !extOffenders.length, extOffenders.length ? { offenders: extOffenders }
check('receipts.external', !extOffenders.length, extOffenders.length ? { offenders: extOffenders }
: { receipts: (await db.prepare('SELECT COUNT(*) AS n FROM external_receipts').get()).n });
: { receipts: (await db.prepare('SELECT COUNT(*) AS n FROM external_receipts').get()).n });
+ const promoMixed = await db.prepare(`SELECT e.txn_id FROM ledger_entries e JOIN accounts a ON a.id = e.account_id GROUP BY e.txn_id
+ HAVING bool_or(a.kind IN ('promo_credit', 'promo_reserve')) AND bool_or(a.kind NOT IN ('promo_credit', 'promo_reserve')) LIMIT 100`).all();
+ check('promo.isolated', !promoMixed.length, promoMixed.length ? { offenders: promoMixed } : null);
+
const frozen = await isFrozen(db);
const frozen = await isFrozen(db);
const staleMin = (ctx.config && ctx.config.reconcile && ctx.config.reconcile.staleReceiptMin) || 60;
const staleMin = (ctx.config && ctx.config.reconcile && ctx.config.reconcile.staleReceiptMin) || 60;
const stale = frozen ? [] : await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL AND received_at < ? ORDER BY id LIMIT 100')
const stale = frozen ? [] : await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL AND received_at < ? ORDER BY id LIMIT 100')
@@ -112,7 +116,7 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
@@ -112,7 +116,7 @@ async function reconcile(ctx, { store = true, trigger = 'manual' } = {}) {
const warnings = {
const warnings = {
negative_balances: await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
negative_balances: await db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
- WHERE a.kind IN ('user_credit', 'creator_payable', 'payouts_pending') AND b.balance < 0`).all(),
+ WHERE a.kind IN ('user_credit', 'creator_payable', 'payouts_pending', 'promo_credit') AND b.balance < 0`).all(),
unprocessed_events: await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL ORDER BY id').all(),
unprocessed_events: await db.prepare('SELECT id, provider, provider_event_id, type, received_at, attempts, last_error FROM provider_events WHERE processed_at IS NULL ORDER BY id').all(),
rejected_events: (await db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected'").all())
rejected_events: (await db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.effect') = 'rejected'").all())
.map((r) => ({ ...r, result: JSON.parse(r.result) })),
.map((r) => ({ ...r, result: JSON.parse(r.result) })),
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+-- PostgreSQL seed for the cutover rehearsal of migrations/0003_promo_ledger.sql (docs/cutover-pr-openvibe-billing-7.md):
+-- a ledger as 0001 + 0002 leave it, with one bought-credit purchase, one tip and its accounts, so the rehearsal proves
+-- the widened accounts_kind_check accepts every existing row and that no ledger row or balance moves.
+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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,212 @@
@@ -0,0 +1,212 @@
+'use strict';
+/**
+ * The promo ledger (migrations/0003_promo_ledger.sql, ops/promo.js; plan T5 step 5): free allowance lives in
+ * promo_credit:<subject>, issued from promo_reserve by an admin grant and drawn down by consume() under the caller's key.
+ * The core invariant: promo bits never become creator money — a subject holding only promo cannot transfer, cash out
+ * or recycle, and no adjustment pairs a promo account with any other kind.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, fund, check, done } = require('./helpers/app');
+const promo = require('../server/ops/promo');
+
+const ADMIN = ['billing.ledger.admin'];
+const PROMO = { kind: 'promo_credit', currency: 'vibes-bits' };
+const RESERVE = { kind: 'promo_reserve', owner: null, currency: 'vibes-bits' };
+
+/** A real user access token from the Network stub (authorization code + PKCE). */
+async function userAccessToken(t) {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const code = t.network.authorize({ subject_id: t.user(900).id, challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), redirect_uri: 'http://x/cb' });
+ const res = await fetch(`${t.network.url}/oauth/token`, {
+ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: 'http://x/cb', client_id: 'billing', client_secret: 'shh' }).toString(),
+ });
+ return (await res.json()).access_token;
+}
+
+(async () => {
+ console.log('promo ledger');
+ const t = await boot();
+ const grant = (body, opts = {}) => t.call('POST', '/api/v1/admin/promo/grant', { body, cap: ADMIN, ...opts });
+ const promoBal = async (id) => (await t.balances(id)).promo_bits;
+ const allowances = (id) => t.db.prepare('SELECT * FROM promo_allowances WHERE subject = ? ORDER BY id').all(id);
+ const txns = async () => (await t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n;
+
+ await check('0003 widened accounts_kind_check (the name 0001 got) to the promo kinds', async () => {
+ const c = await t.db.prepare("SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint WHERE conrelid = 'accounts'::regclass AND conname = 'accounts_kind_check'").get();
+ assert.ok(c, 'accounts_kind_check exists');
+ for (const k of ['user_credit', 'creator_payable', 'fx_conversion', 'promo_credit', 'promo_reserve']) assert.ok(c.def.includes(`'${k}'`), `${k} in ${c.def}`);
+ await assert.rejects(t.db.prepare("INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('promo_bogus', NULL, 'vibes-bits', 'x')").run());
+ });
+
+ const alice = t.user(1);
+ const bob = t.user(2);
+ await check('a grant books promo_reserve → promo_credit as one balanced transaction and opens a month window', async () => {
+ const r = await grant({ subject: alice, bits: 300, service: 'openvibe.tools' });
+ assert.strictEqual(r.status, 201, r.text);
+ const a = r.json.allowance;
+ assert.strictEqual(a.granted_bits, 300);
+ assert.strictEqual(a.remaining_bits, 300);
+ assert.strictEqual(a.reset_period, 'month');
+ assert.strictEqual(a.service, 'openvibe.tools');
+ const { window_start: ws, window_end: we } = a;
+ assert.deepStrictEqual({ start: ws, end: we }, promo.windowOf('month', t.ctx.now()));
+ const entries = r.json.transaction.entries.map((e) => [e.account.kind, e.account.owner, e.amount]).sort();
+ assert.deepStrictEqual(entries, [['promo_credit', alice.id, 300], ['promo_reserve', null, -300]]);
+ assert.strictEqual(r.json.transaction.metadata.promo, 'grant');
+ assert.strictEqual(await promo.remaining(t.ctx, alice.id, 'openvibe.tools'), 300);
+ assert.strictEqual(await promo.remaining(t.ctx, alice.id, 'media'), 0, 'a service-specific allowance is not spent elsewhere');
+ });
+
+ await check('a second grant in the same window sums granted_bits; a replayed Idempotency-Key moves nothing', async () => {
+ const r = await grant({ subject: alice, bits: 200, service: 'openvibe.tools' }, { key: 'promo-grant-0001' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.allowance.granted_bits, 500);
+ const before = await txns();
+ const again = await grant({ subject: alice, bits: 200, service: 'openvibe.tools' }, { key: 'promo-grant-0001' });
+ assert.ok([200, 201].includes(again.status), again.text);
+ assert.strictEqual(await txns(), before);
+ const rows = await allowances(alice.id);
+ assert.strictEqual(rows.length, 1);
+ assert.strictEqual(Number(rows[0].granted_bits), 500);
+ assert.strictEqual(await promoBal(alice.id), 500);
+ await t.assertReconciled('after grants');
+ });
+
+ await check('GET /balances/:subject reports promo_bits beside credit, payable and pending_payouts', async () => {
+ const b = await t.balances(alice.id);
+ assert.strictEqual(b.promo_bits, 500);
+ assert.strictEqual(b.credit, 0);
+ assert.strictEqual(b.payable, 0);
+ assert.strictEqual(b.pending_payouts, 0);
+ assert.strictEqual((await t.balances(bob.id)).promo_bits, 0);
+ });
+
+ await check('a subject holding only promo cannot transfer: billing.insufficient_funds, nothing moves', async () => {
+ const before = await txns();
+ const r = await t.call('POST', '/api/v1/transfers', { body: { from: alice, to: bob, amount: 1 } });
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(r.json.code, 'billing.insufficient_funds');
+ assert.strictEqual(await txns(), before);
+ const b = await t.balances(bob.id);
+ assert.strictEqual(b.payable, 0);
+ assert.strictEqual(await promoBal(alice.id), 500);
+ });
+
+ await check('a subject holding only promo cannot cash out or recycle', async () => {
+ const c = await t.call('POST', '/api/v1/cashouts', { body: { subject: alice, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
+ assert.strictEqual(c.status, 409, c.text);
+ assert.strictEqual(c.json.code, 'billing.insufficient_funds');
+ const r = await t.call('POST', '/api/v1/recycle', { body: { subject: alice, amount: 100 } });
+ assert.strictEqual(r.status, 409, r.text);
+ assert.strictEqual(await promoBal(alice.id), 500);
+ });
+
+ await check('recycle still works; promo → payable or credit is impossible, even by an admin adjustment', async () => {
+ await fund(t, bob, 1000);
+ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: bob, to: alice, amount: 400 } });
+ assert.strictEqual(tip.status, 201, tip.text);
+ const rec = await t.call('POST', '/api/v1/recycle', { body: { subject: alice, amount: 100 } });
+ assert.strictEqual(rec.status, 201, rec.text);
+ const b = await t.balances(alice.id);
+ assert.deepStrictEqual([b.credit, b.payable, b.promo_bits], [100, 300, 500], 'recycle touched payable and credit only');
+ for (const to of [{ kind: 'creator_payable', owner: alice.id, currency: 'vibes-bits' }, { kind: 'user_credit', owner: alice.id, currency: 'vibes-bits' }]) {
+ const adj = await t.call('POST', '/api/v1/admin/adjustments', { cap: ADMIN, body: { from: { ...PROMO, owner: alice.id }, to, amount: 1, reason: 'try' } });
+ assert.strictEqual(adj.status, 422, adj.text);
+ assert.strictEqual(adj.json.code, 'billing.promo_isolated');
+ }
+ const into = await t.call('POST', '/api/v1/admin/adjustments', { cap: ADMIN, body: { from: { kind: 'platform_revenue', currency: 'vibes-bits' }, to: { ...PROMO, owner: alice.id }, amount: 1, reason: 'try' } });
+ assert.strictEqual(into.json.code, 'billing.promo_isolated');
+ const fix = await t.call('POST', '/api/v1/admin/adjustments', { cap: ADMIN, body: { from: { ...PROMO, owner: alice.id }, to: RESERVE, amount: 10, reason: 'over-granted' } });
+ assert.strictEqual(fix.status, 201, fix.text);
+ assert.strictEqual(await promoBal(alice.id), 490);
+ assert.strictEqual(await promo.remaining(t.ctx, alice.id, 'openvibe.tools'), 490, 'remaining never exceeds the authoritative balance');
+ await t.assertReconciled('after recycle and promo correction');
+ });
+
+ await check('consume() draws the allowance down under the caller\'s key; a retry under the same key consumes nothing more', async () => {
+ const out = await promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 90, idempotencyKey: 'usage:reading:1' });
+ assert.strictEqual(out.bits, 90);
+ assert.strictEqual(out.replay, false);
+ const after = { bal: await promoBal(alice.id), used: Number((await allowances(alice.id))[0].used_bits), n: await txns() };
+ assert.deepStrictEqual([after.bal, after.used], [400, 90]);
+ const retry = await promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 90, idempotencyKey: 'usage:reading:1' });
+ assert.strictEqual(retry.replay, true);
+ assert.strictEqual(retry.bits, 90);
+ assert.strictEqual(retry.txn.id, out.txn.id);
+ assert.deepStrictEqual({ bal: await promoBal(alice.id), used: Number((await allowances(alice.id))[0].used_bits), n: await txns() }, after);
+ const entries = out.txn.entries.map((e) => [e.kind, e.owner, e.amount]).sort();
+ assert.deepStrictEqual(entries, [['promo_credit', alice.id, -90], ['promo_reserve', null, 90]]);
+ await assert.rejects(promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 1 }), (e) => e.code === 'billing.invalid_input');
+ for (const other of [{ subject: bob.id, service: 'openvibe.tools' }, { subject: alice.id, service: 'media' }]) {
+ await assert.rejects(promo.consume(t.ctx, { ...other, bits: 90, idempotencyKey: 'usage:reading:1' }), (e) => e.code === 'billing.promo_key_reused');
+ }
+ });
+
+ await check('consume() is capped by what is left and by the service; a "*" grant serves any service', async () => {
+ assert.strictEqual((await promo.consume(t.ctx, { subject: alice.id, service: 'media', bits: 5, idempotencyKey: 'usage:reading:2' })).bits, 0);
+ const all = await promo.consume(t.ctx, { subject: alice.id, service: 'openvibe.tools', bits: 10_000, idempotencyKey: 'usage:reading:3' });
+ assert.strictEqual(all.bits, 400);
+ assert.strictEqual(await promoBal(alice.id), 0);
+ assert.strictEqual((await grant({ subject: bob, bits: 50 })).status, 201);
+ assert.strictEqual((await promo.consume(t.ctx, { subject: bob.id, service: 'media', bits: 20, idempotencyKey: 'usage:reading:4' })).bits, 20);
+ assert.strictEqual(await promo.remaining(t.ctx, bob.id, 'anything'), 30);
+ await t.assertReconciled('after consumption');
+ });
+
+ await check('a window that ends lapses: its unused bits return to promo_reserve once', async () => {
+ const carol = t.user(3);
+ assert.strictEqual((await grant({ subject: carol, bits: 70, period: 'day' })).status, 201);
+ await promo.consume(t.ctx, { subject: carol.id, service: 'media', bits: 20, idempotencyKey: 'usage:reading:5' });
+ t.clock.offset += 86_400_000;
+ assert.strictEqual(await promo.remaining(t.ctx, carol.id, 'media'), 0);
+ assert.strictEqual(await promoBal(carol.id), 0, 'GET /balances does not count an ended window before it lapses');
+ assert.strictEqual((await promo.consume(t.ctx, { subject: carol.id, service: 'media', bits: 5, idempotencyKey: 'usage:reading:6' })).bits, 0);
+ const [row] = await allowances(carol.id);
+ assert.deepStrictEqual([Number(row.used_bits), Number(row.expired_bits)], [20, 50]);
+ assert.strictEqual(await promoBal(carol.id), 0);
+ const n = await txns();
+ await t.ctx.db.tx(() => promo.lapse(t.ctx, carol.id));
+ assert.strictEqual(await txns(), n, 'a lapsed window lapses once');
+ const fresh = await grant({ subject: carol, bits: 15, period: 'day' });
+ assert.strictEqual(fresh.json.allowance.remaining_bits, 15, 'the next window starts afresh');
+ t.clock.offset = 0;
+ const r = await t.assertReconciled('after lapse');
+ assert.ok(r.checks.find((c) => c.id === 'promo.isolated').ok);
+ });
+
+ await check('period "none" is one window that never ends (window_end 9999-12-31T23:59:59.999Z) and never lapses', async () => {
+ const dave = t.user(4);
+ const r = await grant({ subject: dave, bits: 25, period: 'none' });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.json.allowance.window_start, r.json.allowance.window_end], ['1970-01-01T00:00:00.000Z', '9999-12-31T23:59:59.999Z']);
+ t.clock.offset += 400 * 86_400_000;
+ assert.strictEqual(await promoBal(dave.id), 25);
+ assert.strictEqual((await promo.consume(t.ctx, { subject: dave.id, service: 'media', bits: 5, idempotencyKey: 'usage:reading:7' })).bits, 5);
+ assert.strictEqual((await grant({ subject: dave, bits: 10, period: 'none' })).json.allowance.remaining_bits, 30, 'a second grant tops up the same window');
+ t.clock.offset = 0;
+ await t.assertReconciled('after a period "none" grant');
+ });
+
+ await check('a grant needs billing.ledger.admin and a valid body: no token 401, a user token 401, another capability 403', async () => {
+ assert.strictEqual((await grant({ subject: alice, bits: 1 }, { token: null })).status, 401);
+ assert.strictEqual((await grant({ subject: alice, bits: 1 }, { token: await userAccessToken(t) })).status, 401);
+ const denied = await grant({ subject: alice, bits: 1 }, { cap: ['billing.balance.read', 'billing.transfer.create'] });
+ assert.strictEqual(denied.status, 403, denied.text);
+ for (const body of [{ subject: alice, bits: 0 }, { subject: alice, bits: 1.5 }, { subject: alice, bits: 1, period: 'week' }, { subject: 'gst_x', bits: 1 }, { bits: 1 }]) {
+ const r = await grant(body);
+ assert.strictEqual(r.status, 422, `${JSON.stringify(body)} → ${r.status} ${r.text}`);
+ }
+ });
+
+ await check('a grant is refused while the economy is frozen', async () => {
+ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { cap: ADMIN, key: null, body: { on: true, reason: 'test' } })).status, 200);
+ const r = await grant({ subject: alice, bits: 1 });
+ assert.strictEqual(r.status, 503, r.text);
+ assert.strictEqual((await t.call('POST', '/api/v1/admin/freeze', { cap: ADMIN, key: null, body: { on: false } })).status, 200);
+ });
+
+ await t.close();
+ done();
+})();