openvibers/openvibe.billing Commits

Add a promo allowance ledger that never becomes creator money (#7)

OP OpenVibers committed 4 Oct 2026 · 578a21bcc1 · 13 files, +612 −13

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Billing/pull/7

Changes

ddiff --git a/README.md b/README.md
iindex c823c90..23bdcc0 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 5837337..31eea5f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
]
}
ddiff --git a/docs/cutover-pr-openvibe-billing-7.md b/docs/cutover-pr-openvibe-billing-7.md
nnew file mode 100644
iindex 0000000..d8ca122
---- /dev/null
++++ b/docs/cutover-pr-openvibe-billing-7.md
@@@ -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"}
++```
++````
ddiff --git a/migrations/0003_promo_ledger.sql b/migrations/0003_promo_ledger.sql
nnew file mode 100644
iindex 0000000..85ed1d4
---- /dev/null
++++ b/migrations/0003_promo_ledger.sql
@@@ -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;
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 82ba20f..18887b8 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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);
ddiff --git a/server/db.js b/server/db.js
iindex 541fcbb..b3b5afe 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/ops/admin.js b/server/ops/admin.js
iindex 697d61a..df0f43d 100644
---- a/server/ops/admin.js
++++ b/server/ops/admin.js
@@@ -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');
ddiff --git a/server/ops/common.js b/server/ops/common.js
iindex 7ad3136..7f6fb69 100644
---- a/server/ops/common.js
++++ b/server/ops/common.js
@@@ -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 }),
ddiff --git a/server/ops/promo.js b/server/ops/promo.js
nnew file mode 100644
iindex 0000000..f84222e
---- /dev/null
++++ b/server/ops/promo.js
@@@ -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 };
ddiff --git a/server/ops/transfers.js b/server/ops/transfers.js
iindex a5523f4..a7740ba 100644
---- a/server/ops/transfers.js
++++ b/server/ops/transfers.js
@@@ -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);
ddiff --git a/server/reconcile.js b/server/reconcile.js
iindex 2d797c9..9e0ce7a 100644
---- a/server/reconcile.js
++++ b/server/reconcile.js
@@@ -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) })),
ddiff --git a/test/fixtures/promo-ledger-seed.sql b/test/fixtures/promo-ledger-seed.sql
nnew file mode 100644
iindex 0000000..c03e381
---- /dev/null
++++ b/test/fixtures/promo-ledger-seed.sql
@@@ -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;
ddiff --git a/test/promo.test.js b/test/promo.test.js
nnew file mode 100644
iindex 0000000..6c3c135
---- /dev/null
++++ b/test/promo.test.js
@@@ -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();
++})();