openvibers/openvibe.billing Commits

OpenVibe.Billing W8: balanced append-only journal, provider receipts, subscriptions, payouts, freeze, reconciliation and the Live import (ADR-012)

OP OpenVibers committed 23 Sept 2026 · ab5ae8197a · 60 files, +6991 −69

- Double-entry journal (accounts, transactions, ledger_entries): zero-sum per currency enforced in
  code and verified by reconciliation, append-only by triggers, cached balances updated in the same
  SQLite transaction, unique idempotency keys; cents and bits bridged by an fx_conversion pair at
  the recorded value rate. Live's rates are configuration and recorded on every transaction.
- Operations API on /api/v1 for service tokens (audience openvibe.billing): intents, receipt
  settlement, transfers (self-dealing refused by subject) and their refunds, recycle, cashouts with
  enforced escrow and required payout references, subscriptions paid from credit or receipts with
  the streamer share on every period, cancel sent to Stripe, entitlements readable with Live
  offline, balances and history. Idempotency-Key on every POST, replays return the original.
- Provider receipts stored before processing (UNIQUE provider + event id) and settled once per
  payment (unique receipt_ref). PowerChat adapter ported from Live; Stripe, PayPal, CCBill and
  NOWPayments ported and off unless their secrets are set. Refunds and chargebacks are reversing
  transactions; a chargeback on donated credit keeps the recipient's payable and books the loss
  for review.
- Freeze switch: writes 503 billing.frozen, reads served, webhooks queued and processed after
  unfreeze. Reconciliation endpoint and script. Transactional outbox (event-envelope@1) relayed to
  OpenVibe.Events with events.event.publish when EVENTS_URL is set.
- scripts/import-live.js: read-only snapshot import (subjects via resolve-batch, holds for
  unmapped users, payment_orders as intents and receipts, history as test-flagged imports,
  opening balances equal to Live's columns with listed import adjustments, subscriptions as
  entitlements), idempotent.
- docs/economic-inventory.md (units, classes, file:line references, defects), capability and
  service manifest proposals (billing.ledger.admin for operator controls), systemd unit, nginx
  example, CI on Node 22.22.1, 8 test files / 69 cases.

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..74db595
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,77 @@
++# ═══════════════════════════════════════════════════════════════
++# billing.openvibe.network — Environment Configuration
++# Copy to .env for development. In production this lives at /etc/openvibe/billing.env (0600).
++# ═══════════════════════════════════════════════════════════════
++
++# Server (a service API; other services call http://127.0.0.1:4600)
++PORT=4600
++HOST=127.0.0.1
++NODE_ENV=development
++BASE_URL=http://localhost:4600
++TRUST_PROXY=1
++
++# SQLite ledger (ADR-007). The systemd unit sets /var/lib/openvibe-billing/billing.db.
++BILLING_DB_PATH=./data/billing.db
++
++# ── Identity: OpenVibe.Network ──────────────────────────────
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++# Expected `iss` of service tokens (defaults to OV_NETWORK_URL).
++#OV_NETWORK_ISSUER=https://openvibe.network
++# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.
++#OV_NETWORK_PUBLIC_KEY=
++# Audience Billing accepts on incoming service tokens.
++BILLING_AUDIENCE=openvibe.billing
++# Billing's own client credentials (client `billing` registered in the Network) — used for
++# resolve-batch (identity.subject.resolve, audience openvibe.network) and the events relay
++# (events.event.publish, audience openvibe.events).
++OV_OAUTH_CLIENT_ID=billing
++OV_OAUTH_CLIENT_SECRET=change-me
++
++# ── Money rates (Live's current values are the defaults) ────
++#BILLING_BITS_PER_USD=100
++#BILLING_PRICE_TIERS=[{"min":25000,"usd_per_bit":0.011},{"min":10000,"usd_per_bit":0.0115},{"min":5000,"usd_per_bit":0.012},{"min":2500,"usd_per_bit":0.0124},{"min":1000,"usd_per_bit":0.013},{"min":500,"usd_per_bit":0.014},{"min":0,"usd_per_bit":0.015}]
++#BILLING_MIN_PURCHASE_BITS=100
++#BILLING_MAX_BITS=10000000
++#BILLING_SUB_PRICE_CENTS=499
++#BILLING_SUB_SHARE_PCT=70
++#BILLING_SITE_ROUTE_FEE_PCT=10
++#BILLING_SUB_PERIOD_DAYS=31
++#BILLING_MIN_CASHOUT_BITS=500
++#BILLING_ESCROW_DAYS=14
++#BILLING_STRIPE_GRACE_DAYS=3
++
++# ── Providers: each adapter is ENABLED only when its secrets are set ──
++# PowerChat (the only rail carrying money in production)
++POWERCHAT_WEBHOOK_SECRET=
++# The site's PowerChat tips account (lower-case username); unattributed tips to it are flagged.
++POWERCHAT_SITE_USERNAME=
++# Credit isTest deliveries (flagged test). Development/sandbox only.
++#POWERCHAT_ALLOW_TEST_FULFILLMENT=false
++
++# Stripe (off in production)
++#STRIPE_SECRET_KEY=
++#STRIPE_WEBHOOK_SECRET=
++# PayPal (off in production)
++#PAYPAL_CLIENT_ID=
++#PAYPAL_CLIENT_SECRET=
++#PAYPAL_WEBHOOK_ID=
++#PAYPAL_MODE=sandbox
++# CCBill (off in production)
++#CCBILL_WEBHOOK_SECRET=
++#CCBILL_CLIENT_ACCOUNT=
++#CCBILL_SUBACCOUNT=
++#CCBILL_FLEXFORM_ID=
++#CCBILL_SALT=
++# NOWPayments (off in production)
++#NOWPAYMENTS_API_KEY=
++#NOWPAYMENTS_IPN_SECRET=
++
++# ── Durable events: relay the outbox to OpenVibe.Events (off when unset) ──
++#EVENTS_URL=http://127.0.0.1:4300
++#EVENTS_RELAY_INTERVAL_MS=2000
++
++# ── Background jobs: webhook retries, hourly renewal sweep, outbox relay ──
++#BILLING_JOBS=on
++#BILLING_SWEEP_INTERVAL_MS=3600000
++#BILLING_WEBHOOK_RETRY_MS=60000
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..d17156f
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,19 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1 # production runs Node 22
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm test
++ # openvibe-contracts-check --service billing is added once the billing manifest and the
++ # billing.* capabilities (docs/capabilities-proposal/) ship in an openvibe-contracts release;
++ # v0.5.1 still carries the placeholder manifest (range ^0.1.0, no capabilities).
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/README.md b/README.md
iindex a2255dd..2fa4ed4 100644
---- a/README.md
++++ b/README.md
@@@ -2,76 +2,166 @@
> The isolated money ledger: providers, receipts, subscriptions, entitlements, refunds and payouts.
--**Status:** placeholder — planning only, no runnable code yet.
--**Domain:** `billing.openvibe.network`
++**Status:** alpha — runtime built and tested (Wave 8); OpenVibe.Live stays authoritative until the
++cutover below is run.
++**Domain:** `billing.openvibe.network` (service API; only `/webhooks/*` and `/api/health` are public)
++**Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) —
++economic classification. The survey behind it is [docs/economic-inventory.md](docs/economic-inventory.md).
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.1, §11.4, §11.5.
--**License:** AGPL-3.0 (same as every OpenVibe service).
--
--## Purpose
--
--One append-only, balanced ledger for every monetary flow on the network, kept strictly separate from loyalty points (channel points, OpenCoins) and from every product UI. Provider webhooks land here and nowhere else.
--
--## Owns
--
--- `accounts`, `ledger_entries`, `transactions`, `provider_events`, `payment_intents`, `payouts`, `refunds`, `subscriptions`, `plans`, `entitlements`, `reconciliation_runs`, `idempotency_keys`
--- immutable provider receipts and idempotent settlement
--- operational freeze/reconciliation controls
--
--## Does not own
--
--- tip/creator UX (OpenVibe.Tips)
--- membership plan/perk presentation (OpenVibe.VIP)
--- loyalty/channel-point semantics
--
--## Planned surfaces
--
--- ledger and provider adapters; subscription lifecycle; entitlement queries and projections
--- operator reconciliation and receipt history
--
--## Data (authority tables / families)
--
--- see above
--
--## Capabilities and events
--
--- `billing.intent.create`, `billing.subscription.*`, `billing.entitlement.check`
--
--Events: ``billing.transaction.settled|refunded``, ``billing.subscription.*``, ``billing.entitlement.granted|revoked``
--
--## Depends on
--
--- OpenVibe.Contracts
--- OpenVibe.Events
--- OpenVibe.Network
--
--## Acceptance (must be true before "done")
--
--- a duplicated provider webhook produces exactly one accounting effect
--- refund/reversal traces to the original transaction
--- loyalty balances can never be withdrawn or mistaken for money
--- entitlement truth is queryable with Live offline
--
--## Bootstrap / extraction source
--
--Network's OpenCoins wallet and Live's Vibes/PayPal/subscription logic after every economic unit has been classified (ADR-012).
++**License:** AGPL-3.0.
++
++## What it is
++
++One append-only, balanced, double-entry journal for every MONEY and CREDIT flow on the network,
++kept apart from loyalty points (OpenCoins, channel points) and from every product UI. Provider
++webhooks land here and nowhere else.
++
++| ADR-012 class | Here |
++|---|---|
++| 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` |
++| ENTITLEMENT (channel subscriptions) | `subscriptions` + `entitlements` (one row per paid period) |
++| EXTERNAL (tips on a streamer's own PowerChat) | stored receipt, no journal entry; a direct-route subscription grants its entitlement only |
++| LOYALTY, COSMETIC, GAME-STATE, LEGACY | not here (rule 5–8) |
++
++## Run it
++
++```bash
++fnm exec --using=22.22.1 npm install
++cp .env.example .env # set OV_OAUTH_CLIENT_SECRET, POWERCHAT_WEBHOOK_SECRET, …
++npm run dev # http://localhost:4600
++npm test # 8 test files: stub Network/Events/providers, temp DBs, random ports
++npm run reconcile # reconciliation report (exit 1 on failure)
++node scripts/import-live.js --live-db <snapshot> [--dry-run] [--json]
++```
++
++Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
++[deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service) (state in
++`/var/lib/openvibe-billing`), vhost [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf).
++
++## Design
++
++- **Journal** (`server/ledger.js`, `server/db.js`): `accounts(kind, owner_subject, currency)`,
++ `transactions(id txn_<ULID>, type, status, idempotency_key UNIQUE, reverses_txn, test, actor, metadata, …)`,
++ `ledger_entries(txn_id, account_id, amount)` with signed integer minor units. Every transaction sums
++ to zero **per currency** (`vibes-bits`, `usd-cents`); triggers refuse UPDATE/DELETE on entries and
++ transactions; corrections are reversing transactions. `account_balances` is a cache updated in the
++ same SQLite transaction and verified by reconciliation. Funds checks run inside the transaction
++ that moves the money.
++- **Two currencies.** Money arrives in cents and becomes bits through the `fx_conversion` account pair
++ at the recorded value rate (`bits_per_usd`, 100 today); reconciliation checks that each transaction's
++ cents side mirrors its bits side. Anything a payment exceeds the bits' value by is `platform_revenue`.
++- **Rates are configuration** (`BILLING_*`, defaults = Live's): price tiers, value rate, sub price,
++ 70 % streamer share, 10 % site-route fee, minimum cashout, escrow days. Each transaction records the
++ rates it used.
++- **Receipts**: webhooks are verified, stored in `provider_events` (UNIQUE provider + event id) before
++ anything else, then processed with the effect and the "processed" mark in one SQLite transaction.
++ Every settling transaction has a unique `receipt_ref` (`<provider>:<payment id>`), so a payment
++ settles once whatever event, retry or API call carries it.
++- **Freeze** (ADR-012 rule 11): `settings.freeze`; while on, every mutating endpoint answers
++ `503 billing.frozen`, reads work, webhooks are stored (202) and processed in arrival order after
++ unfreeze. Jobs pause.
++- **Outbox**: `billing.transaction.settled|reversed`, `billing.entitlement.changed`,
++ `billing.subscription.canceled`, `billing.cashout.requested|paid|denied`, written in the same
++ transaction as the effect (events.event-envelope@1, source `billing`, actor `service:billing`).
++ Relayed to OpenVibe.Events only when `EVENTS_URL` is set.
++
++## API
++
++All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`). Every
++POST needs an `Idempotency-Key` (8–200 of `A-Za-z0-9._:-`); a replay returns the original response
++(`Idempotent-Replayed: true`), the same key with another body is `422 idempotency.key_reused`, refusals
++are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "type": "user", "id": "usr_…" }`
++(path parameters take the bare id); amounts are integer bits or cents.
++
++| Method & path | Capability | Does |
++|---|---|---|
++| `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) |
++| `GET /api/v1/rates` | — | prices, packages, sub price/share/fee, cashout rules, enabled providers |
++| `POST /api/v1/intents` | `billing.intent.create` | start a checkout `{provider, kind: purchase\|subscription, subject, bits \| streamer, route?, auto_renew?}`; amount priced here; returns the provider checkout URL, or `checkout_ref` (`pcorder:`/`pcsub:`) for PowerChat |
++| `GET /api/v1/intents/:id`, `POST …/:id/capture` | `billing.intent.create` | read; capture an approved PayPal order |
++| `POST /api/v1/purchases/settle` | `billing.ledger.admin` | settle a verified provider receipt `{provider, provider_ref, amount_cents, subject \| intent_id, bits?}` |
++| `POST /api/v1/transfers` | `billing.transfer.create` | tip/donation `{from, to, amount, kind?, target? (EntityRef), message?}`; self-dealing refused by subject |
++| `POST /api/v1/transfers/:id/refund` | `billing.transfer.create` | give a credit-funded transfer back `{amount?, reason?}` (refused if the recipient no longer holds it) |
++| `POST /api/v1/recycle` | `billing.cashout.request` | creator payable → own spendable credit `{subject, amount}` |
++| `POST /api/v1/cashouts` | `billing.cashout.request` | `{subject, amount, payout_method: {type, address}}` → escrow |
++| `GET /api/v1/cashouts[?status&subject]`, `GET …/:id` | `billing.cashout.manage` (`…request` for one) | list / read |
++| `POST /api/v1/cashouts/:id/approve` | `billing.cashout.manage` | `{payout_reference (required), payout_provider?}`; refused before the escrow ends |
++| `POST /api/v1/cashouts/:id/deny` | `billing.cashout.manage` | reversal back to payable `{reason?}` |
++| `POST /api/v1/subscriptions` | `billing.subscription.manage` | start/renew a period `{subscriber, streamer, source: credit, auto_renew?}` (share credited every period); `source: receipt` also needs `billing.ledger.admin` |
++| `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/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` |
++| `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks |
++| `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation |
++| `POST /api/v1/admin/adjustments` | `billing.ledger.admin` | `{from: account, to: account, amount, reason, relates_to?}` (same currency) |
++| `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` |
++
++Capabilities are proposed in [docs/capabilities-proposal/](docs/capabilities-proposal/) with a service
++manifest in [docs/service-manifest-proposal.json](docs/service-manifest-proposal.json). Until they
++ship in an openvibe-contracts release, grants are matched locally (exact id or a `.*` family such as
++`billing.*`) with contracts' own `capabilities.grants()`.
++
++## Providers
++
++| Adapter | Enabled when | Settles | Reverses |
++|---|---|---|---|
++| PowerChat | `POWERCHAT_WEBHOOK_SECRET` | `donation.completed` with `pcorder:` (purchase), `pcsub:` (site → share; direct → EXTERNAL entitlement), `pcdon:` (site-routed tip → payable) | `donation.refunded`, `donation.disputed`/`donation.chargeback` (event names to confirm with PowerChat) |
++| Stripe | `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` | `checkout.session.completed` (payment), every `invoice.paid` | `charge.refunded` (cumulative), `charge.dispute.funds_withdrawn` |
++| PayPal | client id + secret + `PAYPAL_WEBHOOK_ID` | `PAYMENT.CAPTURE.COMPLETED`, capture after return | `PAYMENT.CAPTURE.REFUNDED`, `…REVERSED` |
++| CCBill | `CCBILL_WEBHOOK_SECRET` | `NewSaleSuccess` (reported price required) | `Refund`, `Void`, `Chargeback` |
++| NOWPayments | `NOWPAYMENTS_IPN_SECRET` | `finished`/`confirmed`/`sending` | `refunded` |
++
++Only PowerChat is expected to be enabled in production. A reversal of a purchase claws back the
++buyer's remaining credit; credit already given to someone stays with them — their payable is never
++touched — and the unrecovered value goes to `chargeback_loss`, flagged `review: required`.
++Reversing a subscription payment revokes the periods it granted.
++
++## Operations
++
++- **Reconciliation** (`npm run reconcile`, or `GET /api/v1/admin/reconcile`) checks: journal zero-sum
++ per currency, per-transaction balance, cached balances = entry sums, fx mirror, every settled
++ provider event has exactly one transaction, paid cashouts have payout references, payouts_pending =
++ requested cashouts. It also lists negative balances, unprocessed/rejected events, items for review
++ and import holds; totals exclude test transactions. Runs are stored in `reconciliation_runs`.
++- **Freeze** before any risky change: `POST /api/v1/admin/freeze {"on": true, "reason": "…"}`.
++- **Review queue**: chargebacks on donated credit and unattributed site tips appear under
++ `warnings`; resolve them with an adjustment (`relates_to` = the transaction).
++- **Backups**: `sqlite3 /var/lib/openvibe-billing/billing.db ".backup billing-$(date +%F).db"`.
++
++## Cutover runbook (Live → Billing)
++
++1. **Shadow import.** Copy Live's DB (`sqlite3 live.db ".backup live-snapshot.db"`), then
++ `node scripts/import-live.js --live-db live-snapshot.db --dry-run`; read the report (holds,
++ adjustments, duplicate refs, anomalies). Run it for real; it reconciles afterwards.
++2. **Reconcile** and resolve: map held users in the Network (re-running the import releases their
++ holds), investigate every adjustment and anomaly. Re-run the import against fresh snapshots as often
++ as needed — it only adds what changed.
++3. **Freeze Live money writes** (Live's payments/donations/cashouts off; PowerChat checkout links
++ paused) and wait for in-flight PowerChat checkouts (intents live one hour).
++4. **Final import** from a snapshot taken after the freeze; reconcile; the opening balances now equal
++ Live's columns, with every difference listed.
++5. **Switch Live to the Billing client**: checkout → `POST /intents`, donations → `/transfers`,
++ cashouts/recycle/subscriptions/balances → their endpoints, entitlement checks →
++ `/entitlements`; re-point the PowerChat webhook to `https://billing.openvibe.network/webhooks/powerchat`;
++ Live's money columns become legacy read-only.
++6. **Verify**: a small real PowerChat purchase settles once, balances match, `npm run reconcile` is OK,
++ events flow when `EVENTS_URL` is set. Rollback: freeze Billing, export its journal, re-derive Live's
++ columns from it; keep the last Live snapshot.
## Launch rule
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
--
--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
--implemented service.
++The domain keeps its placeholder page on
++[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until everything in plan §12.12
++holds: owning runtime with health/readiness ✔; canonical identity and scoped service principals ✔;
++server-rendered public routes useful without JavaScript (not yet — Billing has no public pages);
++real persistence and end-to-end workflows ✔; capability/event registration against
++OpenVibe.Contracts (proposed, not released); migration strategy ✔ with a security review, sitemap/robots
++still to do; acceptance tests ✔. A placeholder is never counted as an implemented service.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex c8d88e8..2fc5afb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -1,7 +1,11 @@
{
"repository": "OpenVibers/OpenVibe.Billing",
-- "stage": "placeholder",
-- "code": false,
++ "stage": "alpha",
++ "code": true,
++ "wave": "W8",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
-- "updated": "2026-09-21"
++ "adr": "ADR-012",
++ "authoritative": false,
++ "notes": "Runtime, journal, adapters, import and reconciliation are built and tested; Live stays authoritative until the cutover runbook in README.md is executed.",
++ "updated": "2026-09-22"
}
ddiff --git a/deploy/nginx/billing.openvibe.network.conf b/deploy/nginx/billing.openvibe.network.conf
nnew file mode 100644
iindex 0000000..cc7a5d8
---- /dev/null
++++ b/deploy/nginx/billing.openvibe.network.conf
@@@ -0,0 +1,59 @@
++## ═══════════════════════════════════════════════════════════════
++## billing.openvibe.network — Nginx Configuration (EXAMPLE)
++##
++## Deploy to /etc/nginx/sites-available/billing.openvibe.network.conf and reload nginx.
++##
++## The Node app (port 4600, systemd unit openvibe-billing) is a service API. Other OpenVibe
++## services call it on http://127.0.0.1:4600 directly; the public host exposes only what must be
++## reachable from the internet:
++## /webhooks/<provider> payment provider deliveries (PowerChat today)
++## /api/health liveness for uptime checks
++## /api/v1/* is not published here (service tokens protect it anyway; keeping it off the public
++## edge removes the attack surface). Everything else answers 404.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovbilling_hooks:10m rate=20r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name billing.openvibe.network;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://billing.openvibe.network$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name billing.openvibe.network;
++
++ ssl_certificate /etc/letsencrypt/live/billing.openvibe.network/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/billing.openvibe.network/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/billing.openvibe.network.access.log;
++ error_log /var/log/nginx/billing.openvibe.network.error.log;
++
++ client_max_body_size 1m;
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ proxy_http_version 1.1;
++
++ # Provider webhooks: the raw body must reach Node untouched (signatures cover it).
++ location /webhooks/ {
++ limit_req zone=ovbilling_hooks burst=40 nodelay;
++ proxy_request_buffering on;
++ proxy_pass http://127.0.0.1:4600;
++ }
++
++ location = /api/health { proxy_pass http://127.0.0.1:4600; }
++
++ location / { return 404; }
++}
ddiff --git a/deploy/systemd/openvibe-billing.service b/deploy/systemd/openvibe-billing.service
nnew file mode 100644
iindex 0000000..1d0d281
---- /dev/null
++++ b/deploy/systemd/openvibe-billing.service
@@@ -0,0 +1,48 @@
++[Unit]
++Description=OpenVibe.Billing — the isolated money ledger (journal, provider receipts, subscriptions, payouts)
++# Network signs the service tokens Billing verifies (its key is fetched at boot and retried);
++# Events receives the outbox when EVENTS_URL is set. Billing starts without either.
++After=network.target
++Wants=openvibe-network.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.billing
++EnvironmentFile=/etc/openvibe/billing.env
++Environment=NODE_ENV=production
++Environment=PORT=4600
++Environment=HOST=127.0.0.1
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.billing/server/index.js
++Restart=always
++RestartSec=5
++# The ledger database lives in /var/lib/openvibe-billing (ADR-007: one database per service);
++# the code tree stays read-only.
++StateDirectory=openvibe-billing
++StateDirectoryMode=0700
++Environment=BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db
++UMask=0077
++NoNewPrivileges=true
++PrivateTmp=true
++PrivateDevices=true
++ProtectSystem=strict
++ProtectHome=true
++ProtectKernelTunables=true
++ProtectKernelModules=true
++ProtectKernelLogs=true
++ProtectControlGroups=true
++ProtectClock=true
++ProtectHostname=true
++RestrictSUIDSGID=true
++RestrictRealtime=true
++RestrictNamespaces=true
++LockPersonality=true
++SystemCallArchitectures=native
++RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
++CapabilityBoundingSet=
++AmbientCapabilities=
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/docs/capabilities-proposal/billing.balance.read.json b/docs/capabilities-proposal/billing.balance.read.json
nnew file mode 100644
iindex 0000000..f72eab7
---- /dev/null
++++ b/docs/capabilities-proposal/billing.balance.read.json
@@@ -0,0 +1,21 @@
++{
++ "id": "billing.balance.read",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Read a subject's balances (spendable credit, creator payable, pending payouts) and transaction history.",
++ "permissions": [
++ "billing:read"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ledger-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/balances/:subject",
++ "GET /api/v1/transactions",
++ "GET /api/v1/transactions/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.cashout.manage.json b/docs/capabilities-proposal/billing.cashout.manage.json
nnew file mode 100644
iindex 0000000..b39837a
---- /dev/null
++++ b/docs/capabilities-proposal/billing.cashout.manage.json
@@@ -0,0 +1,25 @@
++{
++ "id": "billing.cashout.manage",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Operator payouts: list cashouts, approve one after its escrow period with the payout reference from the provider (required), or deny it (reversal back to creator payable). Separately controlled from every other capability (ADR-012 rule 10).",
++ "permissions": [
++ "billing:payouts"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ledger-admin",
++ "events": [
++ "billing.cashout.paid",
++ "billing.cashout.denied",
++ "billing.transaction.reversed"
++ ],
++ "implementedBy": [
++ "GET /api/v1/cashouts",
++ "POST /api/v1/cashouts/:id/approve",
++ "POST /api/v1/cashouts/:id/deny"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.cashout.request.json b/docs/capabilities-proposal/billing.cashout.request.json
nnew file mode 100644
iindex 0000000..ae585d3
---- /dev/null
++++ b/docs/capabilities-proposal/billing.cashout.request.json
@@@ -0,0 +1,24 @@
++{
++ "id": "billing.cashout.request",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Request a cashout of creator payable into escrow (minimum and escrow period enforced by Billing), or recycle creator payable into the subject's own spendable credit. Bought credit is never withdrawable.",
++ "permissions": [
++ "billing:cashout"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ledger-write",
++ "events": [
++ "billing.cashout.requested",
++ "billing.transaction.settled"
++ ],
++ "implementedBy": [
++ "POST /api/v1/cashouts",
++ "POST /api/v1/recycle",
++ "GET /api/v1/cashouts/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.entitlement.check.json b/docs/capabilities-proposal/billing.entitlement.check.json
nnew file mode 100644
iindex 0000000..c985388
---- /dev/null
++++ b/docs/capabilities-proposal/billing.entitlement.check.json
@@@ -0,0 +1,21 @@
++{
++ "id": "billing.entitlement.check",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Is a subject entitled to a streamer's subscription right now, and until when. Answered from Billing alone (works with Live offline).",
++ "permissions": [
++ "billing:read"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ledger-read",
++ "events": [],
++ "implementedBy": [
++ "GET /api/v1/entitlements/:subject",
++ "GET /api/v1/subscriptions",
++ "GET /api/v1/subscriptions/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.intent.create.json b/docs/capabilities-proposal/billing.intent.create.json
nnew file mode 100644
iindex 0000000..096dd11
---- /dev/null
++++ b/docs/capabilities-proposal/billing.intent.create.json
@@@ -0,0 +1,21 @@
++{
++ "id": "billing.intent.create",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Start a checkout: create a payment intent priced by Billing (never by the client) for a Vibes purchase or a channel subscription, and get the provider checkout URL or PowerChat app_ref. Also reads intents and captures approved PayPal orders.",
++ "permissions": [
++ "billing:intents"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ledger-write",
++ "events": [],
++ "implementedBy": [
++ "POST /api/v1/intents",
++ "GET /api/v1/intents/:id",
++ "POST /api/v1/intents/:id/capture"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.ledger.admin.json b/docs/capabilities-proposal/billing.ledger.admin.json
nnew file mode 100644
iindex 0000000..2b43964
---- /dev/null
++++ b/docs/capabilities-proposal/billing.ledger.admin.json
@@@ -0,0 +1,28 @@
++{
++ "id": "billing.ledger.admin",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Operate the ledger: economy freeze switch (writes refused, reads served, webhooks queued), reconciliation runs, balanced manual adjustments with a reason, settling a verified provider receipt, provider-event reprocessing, the renewal sweep and import holds.",
++ "permissions": [
++ "billing:admin"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "ledger-admin",
++ "events": [
++ "billing.transaction.settled"
++ ],
++ "implementedBy": [
++ "GET|POST /api/v1/admin/freeze",
++ "GET /api/v1/admin/reconcile",
++ "POST /api/v1/admin/adjustments",
++ "POST /api/v1/purchases/settle",
++ "GET /api/v1/admin/provider-events",
++ "POST /api/v1/admin/provider-events/:id/reprocess",
++ "POST /api/v1/admin/sweep",
++ "GET /api/v1/admin/import-holds"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.subscription.manage.json b/docs/capabilities-proposal/billing.subscription.manage.json
nnew file mode 100644
iindex 0000000..e734298
---- /dev/null
++++ b/docs/capabilities-proposal/billing.subscription.manage.json
@@@ -0,0 +1,26 @@
++{
++ "id": "billing.subscription.manage",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Start or renew a channel subscription paid from credit (the streamer share is credited on every period), and cancel at period end (Stripe subscriptions are cancelled at Stripe). Receipt-paid subscriptions through the API additionally need billing.ledger.admin.",
++ "permissions": [
++ "billing:subscriptions"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ledger-write",
++ "events": [
++ "billing.transaction.settled",
++ "billing.entitlement.changed",
++ "billing.subscription.canceled"
++ ],
++ "implementedBy": [
++ "POST /api/v1/subscriptions",
++ "POST /api/v1/subscriptions/:id/cancel",
++ "GET /api/v1/subscriptions",
++ "GET /api/v1/subscriptions/:id"
++ ]
++}
ddiff --git a/docs/capabilities-proposal/billing.transfer.create.json b/docs/capabilities-proposal/billing.transfer.create.json
nnew file mode 100644
iindex 0000000..1581b11
---- /dev/null
++++ b/docs/capabilities-proposal/billing.transfer.create.json
@@@ -0,0 +1,23 @@
++{
++ "id": "billing.transfer.create",
++ "version": "1.0",
++ "owner": "billing",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Give CREDIT to someone (tip, donation, paid interaction): user_credit of the giver to creator_payable of the recipient. Self-dealing is refused by subject. Also refunds a credit-funded transfer back to its giver (e.g. a media request that never played). Idempotent by Idempotency-Key.",
++ "permissions": [
++ "billing:transfer"
++ ],
++ "resourceConstraints": [
++ "owner"
++ ],
++ "quotaClass": "ledger-write",
++ "events": [
++ "billing.transaction.settled",
++ "billing.transaction.reversed"
++ ],
++ "implementedBy": [
++ "POST /api/v1/transfers",
++ "POST /api/v1/transfers/:id/refund"
++ ]
++}
ddiff --git a/docs/economic-inventory.md b/docs/economic-inventory.md
nnew file mode 100644
iindex 0000000..051dac7
---- /dev/null
++++ b/docs/economic-inventory.md
@@@ -0,0 +1,98 @@
++# Economic inventory (ADR-012 evidence)
++
++A survey of every economic unit on the OpenVibe network, the class ADR-012 gives it, and the defects
++in today's money code that OpenVibe.Billing replaces. References are `file:line` in **OpenVibe.Live**
++(branch `seadragon`, commit `c9af780`) unless another repository is named.
++
++Production facts (not re-measured here): card, PayPal and crypto rails are off (`payments_enabled=false`,
++`server/monetization/payments.js:26`); only PowerChat carries money, at low volume; almost no balances
++are non-zero.
++
++## Units
++
++| Unit | Where it lives | Class | Notes |
++|---|---|---|---|
++| **Vibes, spendable** | `users.openvibe_bucks_balance REAL` (`server/db/schema.sql:15`) | **CREDIT** | Integer bits since migration `001_vibes_decimal_to_bits` (`server/db/migrations.js:45-68`); 100 bits = $1 of creator value (`server/monetization/vibes.js:17`). Bought at tiered prices with a spread (`vibes.js:33-46`). Spent by donating (`vibes.js:106-140`), subscribing (`payments-routes.js:118-128`) and on media requests (`server/media/media-queue.js:134-144`). |
++| **Vibes, cashout** | `users.openvibe_bucks_cashout_balance REAL` (`server/db/database.js:341-344`) | **MONEY** (creator payable) | Only received Vibes land here (`vibes.js:115-117`); cashed out by hand over PayPal (`vibes.js:165-194`); can be recycled back into spendable (`vibes.js:227-248`). |
++| **Payment orders** | `payment_orders` (`database.js:1592-1608`) | **MONEY** (provider receipts / intents) | One row per checkout; `status` pending/paid/failed/credited is the only idempotency guard. |
++| **Transactions ledger** | `transactions` (`schema.sql:247-261`) | MONEY/CREDIT history | Written after balances move (`database.js:5380-5386`); mutable (`vibes.js:204`, `vibes.js:218`); amounts in bits. |
++| **Cashout escrow** | `transactions` rows `type='cashout', status='escrow'` (`vibes.js:178-185`) | **MONEY** | Escrow days are config (`server/config.js:248`) but never enforced at approval (`vibes.js:199-206`); approval records no payout reference. |
++| **Subscriptions** | `subscriptions` + extension columns (`schema.sql:234-245`, `database.js:1567-1589`) | **ENTITLEMENT** (+ MONEY for the payment) | Paid from Vibes, Stripe, PayPal, CCBill, crypto or PowerChat; streamer share 70 % (`payments.js:298`) credited to cashout; renewal sweeper (`payments.js:330-364`). |
++| **PowerChat direct tips** | streamer's own PowerChat; webhook → goals/alerts only (`server/integrations/powerchat-webhook.js:93-153`) | **EXTERNAL** | Money never touches OpenVibe; direct donate links (`powerchat-checkout.js:239-260`). Direct-route subscriptions (`powerchat-checkout.js:309-311`, `creditShare: false`) are EXTERNAL money + ENTITLEMENT. |
++| **PowerChat site-routed tips/purchases** | site PowerChat account; `pcorder:` / `pcsub:` (site) / `pcdon:` refs (`powerchat-checkout.js:267-347`) | **MONEY** | `pcorder` mints spendable Vibes (`:272-287`); `pcsub` site route pays the share (`:289-315`); `pcdon` credits the creator's cashout balance 1 ¢ = 1 bit (`:332-347`). |
++| **Donation goals** | `donation_goals` (`schema.sql:264-277`) | display counter | Not a balance: progress bars fed by donations and manual corrections (`vibes.js:147-160`, `:331-340`). Stays in Live/Tips. |
++| **OpenCoins** | OpenVibe.Network `coin_transactions` with `idempotency_key TEXT UNIQUE` (Network `server/db/database.js:407-414`) | **LOYALTY** | Earned by activity, never bought; spent on media requests (`media-queue.js:146-157`). User-to-user transfer exists with no caller (Network `server/coins/wallet.js:123`). Live's spend key is `Date.now()`+random (`media-queue.js:151`) — not deterministic. |
++| **Channel points** | `channel_points` (`database.js:764-771`), `addChannelPoints`/`deductChannelPoints` (`database.js:4756-4773`) | **LOYALTY** | Per streamer; mutable balance, partial log, no idempotency keys. Spent on rewards and media requests (`media-queue.js:127-133`). |
++| **Arena XP / levels** | `server/arena/mic.js:33-34` | **LOYALTY** | Progression only. |
++| **Cosmetics** | `user_cosmetics` (`schema.sql:774-783`) | **COSMETIC-ENTITLEMENT** | Unlocks, no balance, not tradable. |
++| **Chat tags** | `server/game/tags.js:14-34` | **COSMETIC-ENTITLEMENT** | Granted or bought with legacy in-game gold; not money. |
++| **Game caps (Scraplandia)** | OpenVibe.Games (e.g. `apps/client/src/ui/hud.ts:275-286`) | **GAME-STATE** | Items/currency inside the game world; never bridges out (ADR-012 rule 7). |
++| **Legacy "gold"** | `users.openvibe_coins_balance` (`schema.sql:16`), frozen per `server/monetization/opencoins.js:12` | **LEGACY-ARCHIVE** | Never written any more; must not be shown as live. |
++| **Camp Funds / Hobo Coins / old game** | migrated away (`database.js:325-333`), old game tables (`server/game/schema.sql`) | **LEGACY-ARCHIVE** | Read-only for reconciliation and claims. |
++
++### Rates Live uses today (Billing keeps them as configuration)
++
++| Rate | Value | Source |
++|---|---|---|
++| Value rate | 100 bits = $1 (`bucks_per_usd`) | `vibes.js:17`, `payments.js:31-34` |
++| Purchase tiers | $0.0150/bit (<500) … $0.0110/bit (≥25 000) | `vibes.js:33-46` |
++| Minimum purchase | 100 bits | `payments-routes.js:33` |
++| Subscription price | $4.99 (`sub_price_usd`) | `payments-routes.js:113` |
++| Streamer share | 70 % (`sub_streamer_share_pct`) | `payments.js:298` |
++| Site-route fee | 10 % on top (`sub_site_route_fee_pct`) | `payments-routes.js:145-147` |
++| Minimum cashout / escrow | 500 bits / 14 days | `config.js:247-248` |
++
++## Defects found
++
++1. **Non-atomic donate.** `donate()` deducts, credits and records in three separate statements with no
++ transaction (`vibes.js:111-128`); `deductVibes` is read-then-write (`database.js:5393-5399`), so two
++ concurrent donations can both pass the check. A crash between the deduct and the credit loses money.
++ *Billing:* every operation is one SQLite transaction with the funds check inside it.
++2. **Best-effort, mutable ledger.** The ledger row is written after the balances move and failures are
++ swallowed (`payments.js:262-268`, `powerchat-checkout.js:337-343`); rows are later UPDATEd
++ (`vibes.js:204`, `:218`); subscription payments and streamer shares write no ledger row at all
++ (`payments.js:277-319`); media-request refunds move Vibes with no row (`media-queue.js:489-501`).
++ *Billing:* append-only journal (triggers refuse UPDATE/DELETE), every movement is a balanced
++ transaction, corrections are reversing transactions.
++3. **No unique provider reference.** `payment_orders(provider, provider_ref)` has a plain index
++ (`database.js:1607`) and `getPaymentOrderByRef` takes the newest match (`database.js:5429-5432`);
++ PowerChat subscription orders even store route markers (`direct:renew`, `site:fee=50`) in
++ `provider_ref` (`powerchat-checkout.js:216`, `:224`). *Billing:* `payment_intents` has a unique
++ provider ref; every settling transaction carries a unique `receipt_ref`, so one payment settles once.
++4. **No refund / chargeback handling.** No provider handler processes refunds, reversals or disputes
++ (`payments-routes.js:233-310`, `powerchat-webhook.js:226-256`). *Billing:* each adapter maps them to
++ `refund`/`chargeback` transactions with `reverses_txn`; credit already given away stays with its
++ recipient and the loss is booked to `chargeback_loss`, flagged for review.
++5. **Stripe cancel is not sent to Stripe.** Cancel only flips local flags ("provider stops billing via
++ dashboard/API", `payments-routes.js:212-213`); Stripe keeps charging. *Billing:* cancel calls
++ Stripe (`cancel_at_period_end=true`) first and changes nothing if Stripe refuses.
++6. **Stripe renewals don't credit the creator share.** `invoice.paid` only moves the period end
++ (`payments-routes.js:246-253`); the share is credited once, on the first order
++ (`payments.js:295-304`). *Billing:* every paid invoice settles a subscription payment with the share.
++7. **`recycle` is missing from the transactions CHECK.** The type list is
++ `donation|purchase|subscription|cashout|refund|bonus` (`schema.sql:253`), but `recycleCashout` moves
++ both balances first and then inserts `type: 'recycle'` (`vibes.js:229-240`) — the insert throws after
++ the balances already changed and the route reports an error for a completed move.
++8. **PowerChat deliveries are marked seen before processing.** The delivery id is inserted
++ (`powerchat-routes.js:454`, `database.js:4887-4891`) and the 200 sent (`:459`) before processing runs
++ in `setImmediate` (`:461`), which swallows errors (`powerchat-webhook.js:257-259`). A crash or error
++ loses the event while PowerChat considers it delivered; the delivery table is also pruned after
++ 3 days (`database.js:4892-4895`). *Billing:* the verified payload is stored first and marked
++ processed in the same transaction as its effect; failures stay pending and are retried.
++9. **User deletion cascades provider receipts.** `payment_orders.user_id REFERENCES users(id) ON DELETE
++ CASCADE` (`database.js:1605`) with `foreign_keys = ON` (`database.js:98`): deleting a user erases
++ their payment records. *Billing:* receipts are keyed by subject, immutable, and never cascade.
++
++Further observations, fixed or recorded by Billing:
++
++- PowerChat Vibes purchases credit `bucksForUsd(paid)` — the value rate — not the package
++ (`powerchat-checkout.js:281`), so PowerChat buyers get no tier spread applied (1 bit per cent paid);
++ Billing credits the intent's package when the payment covers it, otherwise what the payment buys
++ under the tiers, and records the pricing basis on the transaction.
++- Unattributed tips to the site PowerChat account are dropped (`powerchat-webhook.js:193-202`);
++ Billing stores them and lists them for review.
++- Cancelling a Vibes-paid subscription ends access immediately (`payments-routes.js:213` sets
++ `canceled`, and `getActiveSubscription` requires `active`, `database.js:5487-5491`); Billing keeps the
++ paid period and ends it at period end.
++- Cashout approval neither checks the escrow date nor records a payout reference (`vibes.js:199-206`).
++- CCBill's shared secret is compared with `===` (`payments.js:204`); Billing compares in constant time.
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
nnew file mode 100644
iindex 0000000..c90360d
---- /dev/null
++++ b/docs/service-manifest-proposal.json
@@@ -0,0 +1,40 @@
++{
++ "version": "0.1.0",
++ "contractRanges": {
++ "openvibe-contracts": ">=0.5.1 <1.0.0"
++ },
++ "id": "billing",
++ "name": "OpenVibe.Billing",
++ "status": "alpha",
++ "repository": "OpenVibers/OpenVibe.Billing",
++ "domains": [
++ "billing.openvibe.network"
++ ],
++ "publicOrigin": "https://billing.openvibe.network",
++ "health": "/api/health",
++ "ready": "/api/ready",
++ "capabilities": [
++ "billing.intent.create",
++ "billing.transfer.create",
++ "billing.balance.read",
++ "billing.cashout.request",
++ "billing.cashout.manage",
++ "billing.subscription.manage",
++ "billing.entitlement.check",
++ "billing.ledger.admin"
++ ],
++ "eventsProduced": [
++ "billing.transaction.settled",
++ "billing.transaction.reversed",
++ "billing.entitlement.changed",
++ "billing.subscription.canceled",
++ "billing.cashout.requested",
++ "billing.cashout.paid",
++ "billing.cashout.denied"
++ ],
++ "eventsConsumed": [],
++ "namespacesOwned": [
++ "billing.*"
++ ],
++ "notes": "The isolated money ledger (ADR-012): balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts. Proposed manifest; replaces the placeholder in manifests/services/billing.json."
++}
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..a5ede9d
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1360 @@
++{
++ "name": "openvibe-billing",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-billing",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.5.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
++ "integrity": "sha512-czQpWKEo8cY56Crj5l5Mv8r5vlIIj0vn/HMxv0OlIu5YTuSSlpvXyayYEfn3GQ5orDhaMIhOUlzmQMxUtWXaLA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..2f83a7f
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,26 @@
++{
++ "name": "openvibe-billing",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Billing — the isolated money ledger of the OpenVibe network: a balanced append-only journal, provider receipts, subscriptions/entitlements, refunds and payouts (ADR-012).",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "reconcile": "node scripts/reconcile.js",
++ "import-live": "node scripts/import-live.js"
++ },
++ "keywords": ["openvibe", "billing", "ledger", "double-entry"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++}
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
nnew file mode 100644
iindex 0000000..f4ffe26
---- /dev/null
++++ b/scripts/import-live.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Import OpenVibe.Live's money state into Billing (ADR-012 migration).
++ *
++ * node scripts/import-live.js --live-db /path/to/live-snapshot.db [--dry-run] [--json]
++ *
++ * --live-db must be a COPY of live.db (e.g. `sqlite3 live.db ".backup live-snapshot.db"`); it is
++ * opened read-only. Live user ids are resolved to Network subjects through resolve-batch with this
++ * service's client credentials (OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, capability
++ * identity.subject.resolve). The report is printed and stored in import_runs; a reconciliation run
++ * follows every real import. Safe to re-run: a second run over the same snapshot changes nothing.
++ */
++const path = require('path');
++const Database = require('better-sqlite3');
++const { loadConfig } = require('../server/config');
++const { openDb } = require('../server/db');
++const { createRates } = require('../server/rates');
++const { createIdentity } = require('../server/network');
++const { importLive } = require('../server/importer/live');
++const { reconcile } = require('../server/reconcile');
++
++const args = process.argv.slice(2);
++const opt = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : null; };
++const flag = (name) => args.includes(`--${name}`);
++
++async function main() {
++ const livePath = opt('live-db');
++ if (!livePath) { console.error('usage: import-live.js --live-db <snapshot path> [--dry-run] [--json]'); process.exit(2); }
++ const config = loadConfig();
++ if (path.resolve(livePath) === path.resolve(config.dbPath)) throw new Error('--live-db points at the Billing database');
++ const live = new Database(livePath, { readonly: true, fileMustExist: true });
++ const db = openDb(config.dbPath);
++ const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now(), log: console };
++ const identity = createIdentity(config);
++ const report = await importLive(ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, dryRun: flag('dry-run') });
++ const rec = flag('dry-run') ? null : reconcile(ctx);
++ if (flag('json')) console.log(JSON.stringify({ import: report, reconciliation: rec }, null, 2));
++ else {
++ console.log(`import ${report.run_id}${report.dry_run ? ' (DRY RUN — nothing kept)' : ''}`);
++ console.log(` live snapshot: ${JSON.stringify(report.counts.live)}; identities ${JSON.stringify(report.counts.identities)}`);
++ console.log(` new: ${report.new_transactions} transactions, ${report.counts.intents_new} intents, ${report.counts.receipts_new} receipts; subscriptions ${report.subscriptions.imported} imported / ${report.subscriptions.updated} updated`);
++ console.log(` opening adjustments (${report.adjustments.length}):`);
++ for (const a of report.adjustments) console.log(` live user ${a.live_user_id} (${a.username}) ${a.account}: history ${a.replayed_history} → column ${a.live_column} (adjust ${a.adjustment > 0 ? '+' : ''}${a.adjustment})`);
++ console.log(` holds (unmapped, kept on hold accounts): ${report.holds.length}`);
++ for (const h of report.holds) console.log(` live user ${h.live_user_id} (${h.username || '?'}): credit ${h.credit_bits}, payable ${h.payable_bits}`);
++ console.log(` duplicate provider refs: ${report.duplicate_provider_refs.length}; route markers parsed: ${report.route_markers}`);
++ console.log(` unreplayable history rows: ${report.unreplayable.length}; anomalies: ${report.anomalies.length}`);
++ if (rec) console.log(` reconciliation ${rec.id}: ${rec.ok ? 'OK' : 'FAILED'} (${rec.checks.filter((c) => !c.ok).map((c) => c.id).join(', ') || 'all checks pass'})`);
++ }
++ live.close();
++ db.close();
++ if (rec && !rec.ok) process.exit(1);
++}
++
++main().catch((e) => { console.error(e.message); process.exit(1); });
ddiff --git a/scripts/reconcile.js b/scripts/reconcile.js
nnew file mode 100644
iindex 0000000..438dff2
---- /dev/null
++++ b/scripts/reconcile.js
@@@ -0,0 +1,25 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Run a reconciliation against BILLING_DB_PATH, store it in reconciliation_runs and print it.
++ * node scripts/reconcile.js [--json] exit 1 when any check fails
++ */
++const { loadConfig } = require('../server/config');
++const { openDb } = require('../server/db');
++const { createRates } = require('../server/rates');
++const { reconcile } = require('../server/reconcile');
++
++const config = loadConfig();
++const db = openDb(config.dbPath);
++const report = reconcile({ db, config, rates: createRates(config.rates), now: () => Date.now() });
++if (process.argv.includes('--json')) console.log(JSON.stringify(report, null, 2));
++else {
++ console.log(`reconciliation ${report.id}: ${report.ok ? 'OK' : 'FAILED'}`);
++ for (const c of report.checks) console.log(` ${c.ok ? 'ok ' : 'FAIL'} ${c.id}${c.ok ? '' : ` ${JSON.stringify(c.detail)}`}`);
++ const w = report.warnings;
++ console.log(` warnings: ${w.negative_balances.length} negative balances, ${w.unprocessed_events.length} unprocessed events, ${w.rejected_events.length} rejected events, ` +
++ `${w.events_for_review.length + w.transactions_for_review.length} for review, ${w.import_holds.length} import holds`);
++ console.log(` totals (test excluded): ${JSON.stringify(report.totals)}`);
++}
++db.close();
++process.exit(report.ok ? 0 : 1);
ddiff --git a/server/api/auth.js b/server/api/auth.js
nnew file mode 100644
iindex 0000000..9a54a8a
---- /dev/null
++++ b/server/api/auth.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++/**
++ * Service-token authentication (ADR-003). Every /api/v1 call carries a client-credentials JWT
++ * from OpenVibe.Network for audience 'openvibe.billing'; the capability is checked here against
++ * the token's `cap` claim.
++ *
++ * The billing.* capabilities are proposed (docs/capabilities-proposal/) and not yet in an
++ * openvibe-contracts release, so contracts' capabilities.check() would answer capability.unknown.
++ * Until they ship, the grant is matched locally with contracts' own grants(): an exact id, or a
++ * trailing ".*" family grant ("billing.*", "billing.cashout.*").
++ */
++const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');
++
++function createAuth({ config, keys }) {
++ function authenticate(req, res) {
++ if (req.principal) return true;
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) { http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov }); return false; }
++ const publicKey = keys.get();
++ if (!publicKey) { http.sendProblem(res, 503, 'auth.unavailable', { detail: 'the Network key is not loaded yet', ctx: req.ov }); return false; }
++ const r = serviceAuth.verifyServiceToken(header.slice(7).trim(), { publicKey, issuer: config.network.issuer, audience: config.audience });
++ if (!r.ok) { http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov }); return false; }
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };
++ return true;
++ }
++
++ /** needs('billing.x.y') or needs(['a', 'b']) — any one of the listed capabilities. */
++ function needs(caps) {
++ const list = Array.isArray(caps) ? caps : [caps];
++ return function billingCapability(req, res, next) {
++ if (!authenticate(req, res)) return;
++ if (!list.some((c) => capabilities.grants(req.principal.cap, c))) {
++ return http.sendProblem(res, 403, 'capability.denied', { detail: `${list.join(' or ')} not granted`, ctx: req.ov });
++ }
++ return next();
++ };
++ }
++
++ return { needs, authenticate };
++}
++
++/** Audit actor for a request: the calling principal, plus the person it acts for when given. */
++function actorOf(req) {
++ const actor = { principal: req.principal ? req.principal.sub : null, request_id: req.ov ? req.ov.requestId : null };
++ const onBehalf = req.body && req.body.on_behalf_of;
++ if (onBehalf && validate('identity.subject-ref@1', onBehalf).valid) actor.on_behalf_of = onBehalf;
++ return actor;
++}
++
++module.exports = { createAuth, actorOf };
ddiff --git a/server/api/idempotency.js b/server/api/idempotency.js
nnew file mode 100644
iindex 0000000..0a3a388
---- /dev/null
++++ b/server/api/idempotency.js
@@@ -0,0 +1,55 @@
++'use strict';
++
++/**
++ * Idempotency-Key handling for every mutating call.
++ *
++ * The key is scoped to the calling principal. The first successful (2xx) response is stored with
++ * a hash of the request; a replay with the same key and the same request returns that response
++ * verbatim (header Idempotent-Replayed: true) and moves nothing. The same key with a different
++ * request is refused (422 idempotency.key_reused). Refusals (4xx/5xx) are not stored, so a caller
++ * may retry a refused operation with the same key once the cause is fixed.
++ *
++ * The key also becomes the journal transaction's idempotency_key (api:<principal>:<key>), whose
++ * UNIQUE constraint is the second line of defence.
++ */
++const crypto = require('crypto');
++const { http } = require('openvibe-contracts');
++
++const KEY_RE = /^[A-Za-z0-9._:-]{8,200}$/;
++
++function stable(v) {
++ if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stable(v[k])}`).join(',')}}`;
++ return JSON.stringify(v === undefined ? null : v);
++}
++
++function idempotent(db, now) {
++ return function idempotencyKey(req, res, next) {
++ const key = req.get('Idempotency-Key');
++ if (!key || !KEY_RE.test(key)) {
++ return http.sendProblem(res, 400, 'idempotency.key_required', { detail: 'mutating calls need an Idempotency-Key header (8-200 of A-Z a-z 0-9 . _ : -)', ctx: req.ov });
++ }
++ const scoped = `${req.principal.sub}:${key}`;
++ const hash = crypto.createHash('sha256').update(`${req.method} ${req.baseUrl}${req.path}\n${stable(req.body || {})}`).digest('hex');
++ const row = db.prepare('SELECT * FROM idempotency_keys WHERE key = ?').get(scoped);
++ if (row) {
++ if (row.request_hash !== hash) {
++ return http.sendProblem(res, 422, 'idempotency.key_reused', { detail: 'this Idempotency-Key was used for a different request', ctx: req.ov });
++ }
++ res.setHeader('Idempotent-Replayed', 'true');
++ return res.status(row.status).json(JSON.parse(row.response));
++ }
++ req.idempotencyKey = `api:${scoped}`;
++ const json = res.json.bind(res);
++ res.json = (body) => {
++ if (res.statusCode >= 200 && res.statusCode < 300) {
++ db.prepare('INSERT OR IGNORE INTO idempotency_keys (key, request_hash, method, path, status, response, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ .run(scoped, hash, req.method, `${req.baseUrl}${req.path}`, res.statusCode, JSON.stringify(body), new Date(now()).toISOString());
++ }
++ return json(body);
++ };
++ return next();
++ };
++}
++
++module.exports = { idempotent, stable };
ddiff --git a/server/api/v1.js b/server/api/v1.js
nnew file mode 100644
iindex 0000000..f74acd2
---- /dev/null
++++ b/server/api/v1.js
@@@ -0,0 +1,293 @@
++'use strict';
++
++/**
++ * /api/v1 — the operations API. Service tokens only (audience openvibe.billing); Live is the
++ * first client. Every mutating call needs an Idempotency-Key and is refused with 503
++ * billing.frozen while the economy is frozen (reads keep working).
++ *
++ * Amounts are integers: bits for Vibes (vibes-bits), cents for money (usd-cents). People are
++ * Network SubjectRefs ({ type: 'user', id: 'usr_…' }); path parameters take the bare subject id.
++ */
++const express = require('express');
++const { http, capabilities } = require('openvibe-contracts');
++const { getTxn, present: presentTxn, balance, BillingError } = require('../ledger');
++const { A, userSubject, positiveInt, isFrozen, fail } = require('../ops/common');
++const { idempotent } = require('./idempotency');
++const { actorOf } = require('./auth');
++const purchases = require('../ops/purchases');
++const transfers = require('../ops/transfers');
++const cashouts = require('../ops/cashouts');
++const subscriptions = require('../ops/subscriptions');
++const intents = require('../ops/intents');
++const admin = require('../ops/admin');
++const providers = require('../providers');
++const { reconcile } = require('../reconcile');
++
++const CAP = {
++ intent: 'billing.intent.create',
++ transfer: 'billing.transfer.create',
++ balance: 'billing.balance.read',
++ cashoutRequest: 'billing.cashout.request',
++ cashoutManage: 'billing.cashout.manage',
++ subscription: 'billing.subscription.manage',
++ entitlement: 'billing.entitlement.check',
++ admin: 'billing.ledger.admin',
++};
++
++function v1Router({ ctx, auth, adapters }) {
++ const r = express.Router();
++ const { db } = ctx;
++ const idem = idempotent(db, ctx.now);
++ const notFrozen = (req, res, next) => (isFrozen(db)
++ ? http.sendProblem(res, 503, 'billing.frozen', { detail: 'the economy is frozen: writes are refused, reads are served', ctx: req.ov })
++ : next());
++ /** A mutating route: capability → freeze → idempotency → handler. */
++ const write = (cap, handler) => [auth.needs(cap), notFrozen, idem, wrap(handler)];
++ const read = (cap, handler) => [auth.needs(cap), wrap(handler)];
++ const txnOut = (t, extra = {}) => ({ transaction: presentTxn(t), ...extra });
++ const pathSubject = (v) => userSubject(String(v), 'subject');
++
++ // ── Rates (public: prices the UI shows) ──────────────────
++ r.get('/rates', (req, res) => {
++ const rt = ctx.rates;
++ res.json({
++ currency: 'vibes-bits', bits_per_usd: rt.bitsPerUsd, price_tiers: rt.priceTiers, min_purchase_bits: rt.minPurchaseBits,
++ packages: [100, 500, 1000, 2500, 5000, 10000, 25000].map((bits) => ({ bits, price_cents: rt.priceCents(bits) })),
++ subscription: { price_cents: rt.subPriceCents, streamer_share_pct: rt.subSharePct, site_route_fee_pct: rt.siteRouteFeePct, period_days: rt.subPeriodDays },
++ cashout: { min_bits: rt.minCashoutBits, escrow_days: rt.escrowDays },
++ providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),
++ });
++ });
++
++ // ── Checkout intents ─────────────────────────────────────
++ r.post('/intents', ...write(CAP.intent, async (req, res) => {
++ const b = req.body || {};
++ const adapter = adapters[String(b.provider || '').toLowerCase()];
++ if (!adapter) fail(422, 'billing.invalid_input', `unknown provider ${b.provider}`);
++ if (!adapter.enabled) fail(409, 'billing.provider_disabled', `${adapter.name} is not enabled on Billing`);
++ const intent = intents.create(ctx, b);
++ let checkoutUrl = null;
++ if (adapter.createCheckout) {
++ const name = intent.kind === 'purchase' ? `${intent.bits.toLocaleString('en-US')} Vibes` : 'Channel subscription (1 month)';
++ try {
++ const out = await adapter.createCheckout(ctx, intent, {
++ name, successUrl: b.success_url || `${ctx.config.baseUrl}/checkout/success`, cancelUrl: b.cancel_url || `${ctx.config.baseUrl}/checkout/cancel`,
++ ipnUrl: `${ctx.config.baseUrl}/webhooks/${adapter.name}`,
++ });
++ if (out.providerRef) intents.setProviderRef(ctx, intent.id, out.providerRef);
++ checkoutUrl = out.url || null;
++ } catch (e) {
++ intents.setStatus(ctx, intent.id, 'failed');
++ fail(502, 'billing.provider_error', `${adapter.name}: ${e.message}`);
++ }
++ }
++ res.status(201).json({ intent: intents.present(intents.find(db, intent.id)), checkout_url: checkoutUrl });
++ }));
++ r.get('/intents/:id', ...read(CAP.intent, (req, res) => {
++ const i = intents.find(db, req.params.id);
++ if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
++ res.json({ intent: intents.present(i) });
++ }));
++ r.post('/intents/:id/capture', ...write(CAP.intent, async (req, res) => {
++ const i = intents.find(db, req.params.id);
++ if (!i) fail(404, 'billing.intent_not_found', `no intent ${req.params.id}`);
++ const adapter = adapters[i.provider];
++ if (!adapter || !adapter.capture || !adapter.enabled) fail(409, 'billing.provider_disabled', `${i.provider} has no enabled capture`);
++ const plan = await adapter.capture(ctx, i);
++ const result = db.transaction(() => providers.applyPlan(ctx, plan, { id: null }))();
++ res.json({ result, intent: intents.present(intents.find(db, i.id)) });
++ }));
++
++ // ── Purchases: settle a provider receipt (operator/reconciler grade) ──
++ r.post('/purchases/settle', ...write(CAP.admin, (req, res) => {
++ const b = req.body || {};
++ if (!b.provider || !b.provider_ref) fail(422, 'billing.invalid_input', 'provider and provider_ref (the provider payment id) are required');
++ const out = purchases.settle(ctx, {
++ provider: String(b.provider).toLowerCase(), receiptRef: `${String(b.provider).toLowerCase()}:${b.provider_ref}`,
++ subject: b.subject ? userSubject(b.subject) : null, paidCents: b.amount_cents, bits: b.bits, intentId: b.intent_id,
++ test: !!b.test, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
++ });
++ res.status(out.replay ? 200 : 201).json(txnOut(out.txn, { duplicate_receipt: !!out.duplicateReceipt }));
++ }));
++
++ // ── Transfers (tips / donations / paid interactions) ─────
++ r.post('/transfers', ...write(CAP.transfer, (req, res) => {
++ const b = req.body || {};
++ const out = transfers.create(ctx, {
++ from: userSubject(b.from, 'from'), to: userSubject(b.to, 'to'), amount: b.amount, kind: b.kind, target: b.target,
++ message: b.message, idempotencyKey: req.idempotencyKey, actor: actorOf(req), traceId: req.ov.traceId,
++ });
++ res.status(201).json(txnOut(out.txn, { balance: { credit: balance(db, A.credit(out.txn.from_subject)) } }));
++ }));
++ r.post('/transfers/:id/refund', ...write(CAP.transfer, (req, res) => {
++ const b = req.body || {};
++ const out = transfers.refund(ctx, { txnId: req.params.id, amount: b.amount, reason: b.reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ res.status(201).json(txnOut(out.txn));
++ }));
++
++ // ── Recycle: creator payable → own spendable credit ──────
++ r.post('/recycle', ...write(CAP.cashoutRequest, (req, res) => {
++ const b = req.body || {};
++ const subject = userSubject(b.subject);
++ const out = cashouts.recycle(ctx, { subject, amount: b.amount, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ res.status(201).json(txnOut(out.txn, { balance: balanceOf(subject) }));
++ }));
++
++ // ── Cashouts ─────────────────────────────────────────────
++ r.post('/cashouts', ...write(CAP.cashoutRequest, (req, res) => {
++ const b = req.body || {};
++ const out = cashouts.request(ctx, { subject: userSubject(b.subject), amount: b.amount, payout_method: b.payout_method, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ res.status(201).json({ cashout: cashouts.present(out.cashout) });
++ }));
++ r.get('/cashouts', ...read(CAP.cashoutManage, (req, res) => {
++ const list = cashouts.list(db, { status: req.query.status, subject: req.query.subject, limit: Number(req.query.limit) || 100 });
++ res.json({ cashouts: list.map(cashouts.present) });
++ }));
++ r.get('/cashouts/:id', ...read([CAP.cashoutManage, CAP.cashoutRequest], (req, res) => {
++ const c = cashouts.find(db, req.params.id);
++ if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${req.params.id}`);
++ res.json({ cashout: cashouts.present(c) });
++ }));
++ r.post('/cashouts/:id/approve', ...write(CAP.cashoutManage, (req, res) => {
++ const b = req.body || {};
++ const out = cashouts.approve(ctx, { id: req.params.id, payout_reference: b.payout_reference, payout_provider: b.payout_provider, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ res.json({ cashout: cashouts.present(out.cashout) });
++ }));
++ r.post('/cashouts/:id/deny', ...write(CAP.cashoutManage, (req, res) => {
++ const out = cashouts.deny(ctx, { id: req.params.id, reason: (req.body || {}).reason, idempotencyKey: req.idempotencyKey, actor: actorOf(req) });
++ res.json({ cashout: cashouts.present(out.cashout) });
++ }));
++
++ // ── Subscriptions and entitlements ───────────────────────
++ r.post('/subscriptions', ...write(CAP.subscription, (req, res) => {
++ const b = req.body || {};
++ const source = b.source || 'credit';
++ let receipt = null;
++ if (source === 'receipt') {
++ // Claiming money was received is operator grade; providers normally settle through webhooks.
++ if (!capabilities.grants(req.principal.cap, CAP.admin)) {
++ fail(403, 'capability.denied', `a receipt-paid subscription through the API needs ${CAP.admin}`);
++ }
++ const rc = b.receipt || {};
++ if (!rc.provider || !rc.provider_ref) fail(422, 'billing.invalid_input', 'receipt.provider and receipt.provider_ref are required');
++ const provider = String(rc.provider).toLowerCase();
++ receipt = { provider, receiptRef: `${provider}:${rc.provider_ref}`, paidCents: rc.amount_cents, feeCents: rc.fee_cents, route: rc.route, providerRef: rc.subscription_ref || null, test: !!rc.test };
++ }
++ const out = subscriptions.pay(ctx, {
++ subscriber: userSubject(b.subscriber, 'subscriber'), streamer: userSubject(b.streamer, 'streamer'), source,
++ priceCents: b.price_cents, autoRenew: b.auto_renew, receipt, idempotencyKey: req.idempotencyKey, actor: actorOf(req),
++ });
++ res.status(201).json({ subscription: subscriptions.present(out.subscription), entitlement: out.entitlement, transaction: presentTxn(out.txn) });
++ }));
++ r.get('/subscriptions', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
++ const list = subscriptions.list(db, {
++ subscriber: req.query.subscriber ? pathSubject(req.query.subscriber) : null,
++ streamer: req.query.streamer ? pathSubject(req.query.streamer) : null,
++ status: req.query.status || null,
++ });
++ res.json({ subscriptions: list.map(subscriptions.present) });
++ }));
++ r.get('/subscriptions/:id', ...read([CAP.entitlement, CAP.subscription], (req, res) => {
++ const s = subscriptions.find(db, req.params.id);
++ if (!s) fail(404, 'billing.subscription_not_found', `no subscription ${req.params.id}`);
++ res.json({ subscription: subscriptions.present(s), entitlement: subscriptions.entitlement(db, s.subscriber, s.streamer, ctx.now()) });
++ }));
++ r.post('/subscriptions/:id/cancel', ...write(CAP.subscription, async (req, res) => {
++ const out = await subscriptions.cancel(ctx, { id: req.params.id, actor: actorOf(req) }, adapters);
++ res.json({ subscription: subscriptions.present(out.subscription), provider_sync: out.provider_sync });
++ }));
++ r.get('/entitlements/:subject', ...read(CAP.entitlement, (req, res) => {
++ const subject = pathSubject(req.params.subject);
++ if (req.query.streamer) return res.json(subscriptions.entitlement(db, subject, pathSubject(req.query.streamer), ctx.now()));
++ return res.json({ subject: { type: 'user', id: subject }, entitlements: subscriptions.activeEntitlements(db, subject, ctx.now()) });
++ }));
++
++ // ── Balances and history ─────────────────────────────────
++ function balanceOf(subject) {
++ const credit = balance(db, A.credit(subject));
++ const payable = balance(db, A.payable(subject));
++ const pending = balance(db, A.pending(subject));
++ return {
++ subject: { type: 'user', id: subject }, currency: 'vibes-bits', credit, payable, pending_payouts: pending,
++ payable_value_cents: ctx.rates.valueCents(payable), bits_per_usd: ctx.rates.bitsPerUsd,
++ };
++ }
++ r.get('/balances/:subject', ...read(CAP.balance, (req, res) => res.json(balanceOf(pathSubject(req.params.subject)))));
++
++ r.get('/transactions', ...read(CAP.balance, (req, res) => {
++ const subject = req.query.subject ? pathSubject(req.query.subject) : null;
++ if (!subject) fail(422, 'billing.invalid_input', 'subject is required');
++ const limit = Math.min(200, positiveInt(req.query.limit || 50, 'limit'));
++ let cur = null;
++ if (req.query.cursor) {
++ try { cur = JSON.parse(Buffer.from(String(req.query.cursor), 'base64url').toString('utf8')); } catch { fail(422, 'billing.invalid_input', 'bad cursor'); }
++ }
++ const rows = db.prepare(`SELECT t.id, t.created_at FROM transactions t
++ WHERE (t.from_subject = @s OR t.to_subject = @s OR t.id IN (SELECT e.txn_id FROM ledger_entries e JOIN accounts a ON a.id = e.account_id WHERE a.owner_subject = @s))
++ AND (@c IS NULL OR t.created_at < @c OR (t.created_at = @c AND t.id < @i))
++ ORDER BY t.created_at DESC, t.id DESC LIMIT @n`).all({ s: subject, c: cur ? cur[0] : null, i: cur ? cur[1] : null, n: limit + 1 });
++ const page = rows.slice(0, limit);
++ const next = rows.length > limit ? Buffer.from(JSON.stringify([page[page.length - 1].created_at, page[page.length - 1].id])).toString('base64url') : null;
++ res.json({ transactions: page.map((x) => presentTxn(getTxn(db, x.id))), next_cursor: next });
++ }));
++ r.get('/transactions/:id', ...read(CAP.balance, (req, res) => {
++ const t = getTxn(db, req.params.id);
++ if (!t) fail(404, 'billing.transaction_not_found', `no transaction ${req.params.id}`);
++ const reversals = db.prepare('SELECT id FROM transactions WHERE reverses_txn = ? ORDER BY created_at').all(t.id).map((x) => x.id);
++ res.json({ transaction: presentTxn(t), reversed_by: reversals });
++ }));
++
++ // ── Admin (billing.ledger.admin) ─────────────────────────
++ r.get('/admin/freeze', ...read(CAP.admin, (req, res) => res.json(admin.freezeState(db))));
++ r.post('/admin/freeze', auth.needs(CAP.admin), wrap(async (req, res) => {
++ const b = req.body || {};
++ if (typeof b.on !== 'boolean') fail(422, 'billing.invalid_input', 'on must be true or false');
++ const state = admin.setFreeze(ctx, { on: b.on, reason: b.reason, actor: actorOf(req) });
++ // Deliveries stored while frozen are processed now, in arrival order.
++ const drained = b.on ? null : await providers.processPending(ctx, adapters);
++ res.json({ ...state, processed_after_unfreeze: drained });
++ }));
++ r.get('/admin/reconcile', ...read(CAP.admin, (req, res) => {
++ const report = reconcile(ctx);
++ res.status(200).json(report);
++ }));
++ r.post('/admin/adjustments', ...write(CAP.admin, (req, res) => {
++ const b = req.body || {};
++ const out = 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.get('/admin/provider-events', ...read(CAP.admin, (req, res) => {
++ const pending = req.query.pending === '1' || req.query.pending === 'true';
++ const rows = db.prepare(`SELECT * FROM provider_events ${pending ? 'WHERE processed_at IS NULL' : ''} ORDER BY id DESC LIMIT 200`).all().map(providers.parseRow);
++ res.json({ events: rows.map(providers.present) });
++ }));
++ r.post('/admin/provider-events/:id/reprocess', auth.needs(CAP.admin), notFrozen, wrap(async (req, res) => {
++ const row = await providers.reprocess(ctx, adapters, Number(req.params.id));
++ res.json({ event: providers.present(row) });
++ }));
++ r.post('/admin/sweep', auth.needs(CAP.admin), notFrozen, wrap((req, res) => res.json(subscriptions.sweep(ctx))));
++ r.get('/admin/import-holds', ...read(CAP.admin, (req, res) => res.json({ holds: db.prepare('SELECT * FROM import_holds ORDER BY live_user_id').all() })));
++
++ return r;
++}
++
++/** Async-safe handler: BillingError → problem+json; anything else → 500. */
++function wrap(fn) {
++ return (req, res, next) => {
++ try {
++ const p = fn(req, res, next);
++ if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
++ } catch (e) { sendError(req, res, e); }
++ };
++}
++
++function sendError(req, res, e) {
++ if (res.headersSent) return;
++ if (e instanceof BillingError) {
++ return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
++ }
++ console.error('[Billing] unexpected error:', e);
++ return http.sendProblem(res, 500, 'billing.internal', { detail: 'internal error', ctx: req.ov });
++}
++
++module.exports = { v1Router, wrap, sendError, CAP };
ddiff --git a/server/api/webhooks.js b/server/api/webhooks.js
nnew file mode 100644
iindex 0000000..d328ad5
---- /dev/null
++++ b/server/api/webhooks.js
@@@ -0,0 +1,51 @@
++'use strict';
++
++/**
++ * Provider webhooks: POST /webhooks/<provider> (CCBill also sends GET).
++ *
++ * 404 billing.provider_disabled the adapter has no secrets configured (nothing is stored)
++ * 401 webhook.unverified signature/secret check failed (nothing is stored)
++ * 200 { received, duplicate } a redelivery of a stored event (processed if still pending)
++ * 200 { received, processed, result }
++ * 202 { received, queued } stored while the economy is frozen; processed after unfreeze
++ *
++ * The raw body is kept for signature checks; the verified payload is stored in provider_events
++ * before any processing.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const providers = require('../providers');
++const { isFrozen } = require('../ops/common');
++
++function webhooksRouter({ ctx, adapters }) {
++ const r = express.Router();
++ const raw = express.raw({ type: () => true, limit: '1mb' });
++
++ r.all('/:provider', raw, async (req, res) => {
++ const adapter = adapters[req.params.provider];
++ if (!adapter) return http.sendProblem(res, 404, 'billing.unknown_provider', { ctx: req.ov });
++ if (!adapter.enabled) return http.sendProblem(res, 404, 'billing.provider_disabled', { detail: `${adapter.name} is not enabled on Billing`, ctx: req.ov });
++ if (req.method !== 'POST' && adapter.name !== 'ccbill') return http.sendProblem(res, 405, 'webhook.method', { ctx: req.ov });
++ req.rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
++ try {
++ const v = await adapter.verify(req);
++ if (!v.ok) {
++ (ctx.log || console).warn(`[Billing] ${adapter.name} webhook rejected: ${v.reason}`);
++ return http.sendProblem(res, 401, 'webhook.unverified', { detail: v.reason, ctx: req.ov });
++ }
++ let parsed;
++ try { parsed = adapter.parse(req); } catch { return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'unparseable payload', ctx: req.ov }); }
++ if (!parsed.eventId) return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'no event id', ctx: req.ov });
++ const { row, duplicate } = providers.store(ctx, adapter.name, parsed);
++ if (isFrozen(ctx.db)) return res.status(202).json({ received: true, queued: true, event: row.id, duplicate });
++ const after = row.processed_at ? row : await providers.process(ctx, adapters, row);
++ return res.status(200).json({ received: true, duplicate, event: after.id, processed: !!after.processed_at, result: after.result || null });
++ } catch (e) {
++ (ctx.log || console).error(`[Billing] ${adapter.name} webhook error:`, e);
++ return http.sendProblem(res, 500, 'billing.internal', { detail: 'webhook error', ctx: req.ov });
++ }
++ });
++ return r;
++}
++
++module.exports = { webhooksRouter };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..20ca13c
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,77 @@
++'use strict';
++
++/**
++ * OpenVibe.Billing — the isolated money ledger (ADR-012). Express app factory; server/index.js
++ * listens, tests build their own instance.
++ *
++ * GET /api/health, /api/ready liveness / readiness
++ * /api/v1/* operations API (service tokens, see api/v1.js)
++ * /webhooks/<provider> provider receipts (see api/webhooks.js)
++ *
++ * createApp({ config, db, keys, identity, adapters, now, fetchImpl, log }) — everything injectable.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { loadConfig } = require('./config');
++const { openDb } = require('./db');
++const { createRates } = require('./rates');
++const { createKeyProvider, createIdentity } = require('./network');
++const { createAuth } = require('./api/auth');
++const { v1Router } = require('./api/v1');
++const { webhooksRouter } = require('./api/webhooks');
++const providers = require('./providers');
++const { isFrozen } = require('./ops/common');
++
++const VERSION = require('../package.json').version;
++
++function createApp(opts = {}) {
++ const config = opts.config || loadConfig();
++ const db = opts.db || openDb(config.dbPath);
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const log = opts.log || console;
++ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
++ const identity = opts.identity || createIdentity(config, { fetchImpl });
++ const ctx = { db, config, rates: createRates(config.rates), now: opts.now || (() => Date.now()), network: identity, log };
++ const adapters = opts.adapters || providers.createAdapters(config, { fetchImpl, network: identity });
++ const auth = createAuth({ config, keys });
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ app.use(http.middleware());
++ app.use((req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
++
++ app.get('/api/health', (req, res) => res.json({
++ ok: true, service: 'billing', version: VERSION, frozen: isFrozen(db),
++ providers: Object.fromEntries(Object.values(adapters).map((a) => [a.name, a.enabled])),
++ events_relay: !!config.events.url,
++ }));
++ app.get('/api/ready', (req, res) => {
++ const problems = [];
++ try { db.prepare('SELECT 1 FROM settings WHERE id = 1').get(); } catch (e) { problems.push(`database: ${e.message}`); }
++ if (!keys.get()) problems.push('Network public key not loaded');
++ if (problems.length) return http.sendProblem(res, 503, 'service.not_ready', { detail: problems.join('; '), ctx: req.ov });
++ return res.json({ ready: true });
++ });
++
++ app.use('/webhooks', webhooksRouter({ ctx, adapters }));
++ app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters }));
++
++ app.get('/', (req, res) => res.type('text/plain').send('OpenVibe.Billing — the OpenVibe money ledger. Service API only; see https://github.com/OpenVibers/OpenVibe.Billing\n'));
++ app.use((req, res) => http.sendProblem(res, 404, 'not_found', { ctx: req.ov }));
++ // Malformed JSON and other body-parser errors.
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, next) => {
++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
++ log.error('[Billing] unhandled error:', err);
++ return http.sendProblem(res, 500, 'billing.internal', { ctx: req.ov });
++ });
++
++ app.locals.ctx = ctx;
++ app.locals.adapters = adapters;
++ app.locals.keys = keys;
++ return app;
++}
++
++module.exports = { createApp, VERSION };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..c4621be
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,129 @@
++'use strict';
++
++/**
++ * OpenVibe.Billing configuration. Everything comes from the environment (.env in development,
++ * /etc/openvibe/billing.env in production). loadConfig(env) is pure so tests build their own.
++ *
++ * Money rates are configuration, never constants in the code paths (ADR-012): every transaction
++ * records the rates it was computed with in its metadata, so a later change of rates never
++ * rewrites history.
++ */
++require('dotenv').config();
++
++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++const num = (v, d) => { const n = Number(v); return v != null && v !== '' && Number.isFinite(n) ? n : d; };
++const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++
++// Live's purchase price tiers (server/monetization/vibes.js BUCKS_PRICE_TIERS): USD per bit,
++// bigger buys are cheaper. The spread over the value rate is platform revenue.
++const DEFAULT_PRICE_TIERS = [
++ { min: 25000, usd_per_bit: 0.0110 },
++ { min: 10000, usd_per_bit: 0.0115 },
++ { min: 5000, usd_per_bit: 0.0120 },
++ { min: 2500, usd_per_bit: 0.0124 },
++ { min: 1000, usd_per_bit: 0.0130 },
++ { min: 500, usd_per_bit: 0.0140 },
++ { min: 0, usd_per_bit: 0.0150 },
++];
++
++function parseTiers(raw) {
++ if (!raw) return DEFAULT_PRICE_TIERS;
++ const tiers = JSON.parse(raw);
++ if (!Array.isArray(tiers) || !tiers.length) throw new Error('BILLING_PRICE_TIERS must be a non-empty JSON array');
++ for (const t of tiers) {
++ if (!Number.isInteger(t.min) || t.min < 0 || !(t.usd_per_bit > 0)) throw new Error('BILLING_PRICE_TIERS entries are { min: int >= 0, usd_per_bit: > 0 }');
++ }
++ return [...tiers].sort((a, b) => b.min - a.min);
++}
++
++function loadConfig(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4600);
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ return {
++ nodeEnv,
++ isProduction,
++ port,
++ host: env.HOST || '127.0.0.1',
++ baseUrl: trim(env.BASE_URL || (isProduction ? 'https://billing.openvibe.network' : `http://localhost:${port}`)),
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
++ dbPath: env.BILLING_DB_PATH || './data/billing.db',
++
++ // Identity: service tokens are RS256 JWTs signed by OpenVibe.Network.
++ network: {
++ url: networkUrl,
++ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
++ },
++ audience: env.BILLING_AUDIENCE || 'openvibe.billing',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'billing',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ },
++
++ // Money rates (Live's current values are the defaults).
++ rates: {
++ bitsPerUsd: int(env.BILLING_BITS_PER_USD, 100), // value rate (Live bucks_per_usd)
++ priceTiers: parseTiers(env.BILLING_PRICE_TIERS), // purchase price tiers
++ minPurchaseBits: int(env.BILLING_MIN_PURCHASE_BITS, 100),
++ maxBits: int(env.BILLING_MAX_BITS, 10_000_000),
++ subPriceCents: int(env.BILLING_SUB_PRICE_CENTS, 499),
++ subSharePct: num(env.BILLING_SUB_SHARE_PCT, 70), // streamer share of a sub
++ siteRouteFeePct: num(env.BILLING_SITE_ROUTE_FEE_PCT, 10), // PowerChat site-route fee
++ subPeriodDays: int(env.BILLING_SUB_PERIOD_DAYS, 31),
++ minCashoutBits: int(env.BILLING_MIN_CASHOUT_BITS, 500),
++ escrowDays: num(env.BILLING_ESCROW_DAYS, 14),
++ stripeGraceDays: num(env.BILLING_STRIPE_GRACE_DAYS, 3),
++ },
++
++ // Provider adapters: each is enabled only when its secrets are set.
++ providers: {
++ powerchat: {
++ webhookSecret: env.POWERCHAT_WEBHOOK_SECRET || '',
++ siteUsername: String(env.POWERCHAT_SITE_USERNAME || '').trim().toLowerCase(),
++ allowTest: bool(env.POWERCHAT_ALLOW_TEST_FULFILLMENT),
++ maxSkewMs: int(env.POWERCHAT_MAX_SKEW_MS, 15 * 60 * 1000),
++ },
++ stripe: {
++ secretKey: env.STRIPE_SECRET_KEY || '',
++ webhookSecret: env.STRIPE_WEBHOOK_SECRET || '',
++ apiBase: trim(env.STRIPE_API_BASE || 'https://api.stripe.com/v1'),
++ toleranceSec: int(env.STRIPE_TOLERANCE_SEC, 300),
++ },
++ paypal: {
++ clientId: env.PAYPAL_CLIENT_ID || '',
++ clientSecret: env.PAYPAL_CLIENT_SECRET || '',
++ webhookId: env.PAYPAL_WEBHOOK_ID || '',
++ apiBase: trim(env.PAYPAL_API_BASE || (env.PAYPAL_MODE === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com')),
++ },
++ ccbill: {
++ webhookSecret: env.CCBILL_WEBHOOK_SECRET || '',
++ clientAccount: env.CCBILL_CLIENT_ACCOUNT || '',
++ subAccount: env.CCBILL_SUBACCOUNT || '',
++ flexformId: env.CCBILL_FLEXFORM_ID || '',
++ salt: env.CCBILL_SALT || '',
++ },
++ nowpayments: {
++ apiKey: env.NOWPAYMENTS_API_KEY || '',
++ ipnSecret: env.NOWPAYMENTS_IPN_SECRET || '',
++ apiBase: trim(env.NOWPAYMENTS_API_BASE || 'https://api.nowpayments.io/v1'),
++ },
++ },
++
++ // Durable events: the outbox relay runs only when EVENTS_URL is set.
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ },
++ jobs: {
++ enabled: env.BILLING_JOBS !== 'off',
++ sweepIntervalMs: int(env.BILLING_SWEEP_INTERVAL_MS, 60 * 60 * 1000),
++ webhookRetryMs: int(env.BILLING_WEBHOOK_RETRY_MS, 60 * 1000),
++ },
++ };
++}
++
++module.exports = { loadConfig, DEFAULT_PRICE_TIERS };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..5c87f99
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,255 @@
++'use strict';
++
++/**
++ * OpenVibe.Billing database (ADR-007: one SQLite database per service, BILLING_DB_PATH).
++ *
++ * The journal is three tables: accounts, transactions, ledger_entries. Every transaction's
++ * entries sum to zero per currency (enforced in ledger.js, verified by reconcile.js), and the
++ * journal is append-only: triggers refuse UPDATE/DELETE on ledger_entries and transactions.
++ * Corrections are new, reversing transactions. account_balances is a cache of SUM(entries)
++ * per account, updated in the same SQLite transaction as the entries and verified by
++ * reconciliation.
++ *
++ * Operational state that does change (intents, provider events, cashouts, subscriptions,
++ * entitlements) lives in its own tables; its money effects are always journal transactions.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++
++const ACCOUNT_KINDS = ['user_credit', 'creator_payable', 'provider_clearing', 'platform_revenue', 'payouts_pending',
++ 'refunds', 'import_adjustment', 'chargeback_loss', 'fx_conversion'];
++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'];
++
++const inList = (xs) => xs.map((x) => `'${x}'`).join(', ');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS accounts (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ kind TEXT NOT NULL CHECK (kind IN (${inList(ACCOUNT_KINDS)})),
++ owner_subject TEXT,
++ currency TEXT NOT NULL CHECK (currency IN (${inList(CURRENCIES)})),
++ created_at TEXT NOT NULL,
++ UNIQUE (kind, owner_subject, currency)
++);
++-- SQLite treats NULLs as distinct in UNIQUE; platform accounts (owner NULL) need this one.
++CREATE UNIQUE INDEX IF NOT EXISTS accounts_identity ON accounts (kind, COALESCE(owner_subject, ''), currency);
++
++CREATE TABLE IF NOT EXISTS transactions (
++ id TEXT PRIMARY KEY,
++ type TEXT NOT NULL CHECK (type IN (${inList(TXN_TYPES)})),
++ status TEXT NOT NULL DEFAULT 'settled' CHECK (status IN ('settled', 'imported')),
++ idempotency_key TEXT NOT NULL UNIQUE,
++ reverses_txn TEXT REFERENCES transactions (id),
++ test INTEGER NOT NULL DEFAULT 0 CHECK (test IN (0, 1)),
++ actor TEXT NOT NULL DEFAULT '{}',
++ metadata TEXT NOT NULL DEFAULT '{}',
++ from_subject TEXT,
++ to_subject TEXT,
++ provider TEXT,
++ receipt_ref TEXT,
++ source_event_id INTEGER REFERENCES provider_events (id),
++ created_at TEXT NOT NULL
++);
++-- One settlement per provider payment, whatever event or retry carried it.
++CREATE UNIQUE INDEX IF NOT EXISTS transactions_receipt ON transactions (receipt_ref) WHERE receipt_ref IS NOT NULL;
++CREATE INDEX IF NOT EXISTS transactions_from ON transactions (from_subject);
++CREATE INDEX IF NOT EXISTS transactions_to ON transactions (to_subject);
++CREATE INDEX IF NOT EXISTS transactions_reverses ON transactions (reverses_txn);
++CREATE INDEX IF NOT EXISTS transactions_event ON transactions (source_event_id);
++
++CREATE TABLE IF NOT EXISTS ledger_entries (
++ txn_id TEXT NOT NULL REFERENCES transactions (id),
++ account_id INTEGER NOT NULL REFERENCES accounts (id),
++ amount INTEGER NOT NULL CHECK (amount <> 0),
++ PRIMARY KEY (txn_id, account_id)
++);
++CREATE INDEX IF NOT EXISTS ledger_entries_account ON ledger_entries (account_id);
++
++CREATE TABLE IF NOT EXISTS account_balances (
++ account_id INTEGER PRIMARY KEY REFERENCES accounts (id),
++ balance INTEGER NOT NULL DEFAULT 0,
++ updated_at TEXT
++);
++
++CREATE TRIGGER IF NOT EXISTS ledger_entries_no_update BEFORE UPDATE ON ledger_entries
++BEGIN SELECT RAISE(ABORT, 'ledger_entries are append-only'); END;
++CREATE TRIGGER IF NOT EXISTS ledger_entries_no_delete BEFORE DELETE ON ledger_entries
++BEGIN SELECT RAISE(ABORT, 'ledger_entries are append-only'); END;
++CREATE TRIGGER IF NOT EXISTS transactions_no_update BEFORE UPDATE ON transactions
++BEGIN SELECT RAISE(ABORT, 'transactions are append-only; post a reversing transaction'); END;
++CREATE TRIGGER IF NOT EXISTS transactions_no_delete BEFORE DELETE ON transactions
++BEGIN SELECT RAISE(ABORT, 'transactions are append-only; post a reversing transaction'); END;
++
++CREATE TABLE IF NOT EXISTS provider_events (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ provider TEXT NOT NULL,
++ provider_event_id TEXT NOT NULL,
++ type TEXT,
++ payload_hash TEXT NOT NULL,
++ payload TEXT NOT NULL,
++ received_at TEXT NOT NULL,
++ processed_at TEXT,
++ result TEXT,
++ attempts INTEGER NOT NULL DEFAULT 0,
++ last_error TEXT,
++ UNIQUE (provider, provider_event_id)
++);
++CREATE INDEX IF NOT EXISTS provider_events_pending ON provider_events (processed_at);
++CREATE TRIGGER IF NOT EXISTS provider_events_immutable BEFORE UPDATE OF provider, provider_event_id, payload, payload_hash, received_at ON provider_events
++BEGIN SELECT RAISE(ABORT, 'provider receipts are immutable'); END;
++CREATE TRIGGER IF NOT EXISTS provider_events_no_delete BEFORE DELETE ON provider_events
++BEGIN SELECT RAISE(ABORT, 'provider receipts are immutable'); END;
++
++CREATE TABLE IF NOT EXISTS payment_intents (
++ id TEXT PRIMARY KEY,
++ provider TEXT NOT NULL,
++ provider_ref TEXT,
++ kind TEXT NOT NULL CHECK (kind IN ('purchase', 'subscription')),
++ subject TEXT NOT NULL,
++ streamer_subject TEXT,
++ amount_cents INTEGER NOT NULL,
++ fee_cents INTEGER NOT NULL DEFAULT 0,
++ bits INTEGER NOT NULL DEFAULT 0,
++ route TEXT,
++ auto_renew INTEGER NOT NULL DEFAULT 0,
++ status TEXT NOT NULL DEFAULT 'created' CHECK (status IN ('created', 'settled', 'failed', 'expired', 'canceled', 'refunded')),
++ settled_txn TEXT REFERENCES transactions (id),
++ legacy_order_id INTEGER UNIQUE,
++ metadata TEXT NOT NULL DEFAULT '{}',
++ created_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS payment_intents_ref ON payment_intents (provider, provider_ref) WHERE provider_ref IS NOT NULL;
++CREATE INDEX IF NOT EXISTS payment_intents_subject ON payment_intents (subject);
++
++CREATE TABLE IF NOT EXISTS cashouts (
++ id TEXT PRIMARY KEY,
++ subject TEXT NOT NULL,
++ amount_bits INTEGER NOT NULL CHECK (amount_bits > 0),
++ value_cents INTEGER NOT NULL,
++ status TEXT NOT NULL CHECK (status IN ('requested', 'paid', 'denied')),
++ payout_method TEXT NOT NULL DEFAULT '{}',
++ escrow_until TEXT NOT NULL,
++ request_txn TEXT NOT NULL REFERENCES transactions (id),
++ settle_txn TEXT REFERENCES transactions (id),
++ payout_provider TEXT,
++ payout_reference TEXT,
++ decided_by TEXT,
++ reason TEXT,
++ legacy_live_txn INTEGER UNIQUE,
++ created_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL
++);
++CREATE INDEX IF NOT EXISTS cashouts_status ON cashouts (status);
++CREATE INDEX IF NOT EXISTS cashouts_subject ON cashouts (subject);
++
++CREATE TABLE IF NOT EXISTS subscriptions (
++ id TEXT PRIMARY KEY,
++ subscriber TEXT NOT NULL,
++ streamer TEXT NOT NULL,
++ tier INTEGER NOT NULL DEFAULT 1,
++ provider TEXT NOT NULL,
++ provider_ref TEXT,
++ route TEXT,
++ status TEXT NOT NULL CHECK (status IN ('active', 'canceled', 'expired')),
++ auto_renew INTEGER NOT NULL DEFAULT 0,
++ cancel_at_period_end INTEGER NOT NULL DEFAULT 0,
++ price_cents INTEGER NOT NULL DEFAULT 0,
++ current_period_end TEXT,
++ legacy_live_id INTEGER UNIQUE,
++ created_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL,
++ UNIQUE (subscriber, streamer)
++);
++CREATE INDEX IF NOT EXISTS subscriptions_provider_ref ON subscriptions (provider, provider_ref);
++CREATE INDEX IF NOT EXISTS subscriptions_streamer ON subscriptions (streamer, status);
++
++CREATE TABLE IF NOT EXISTS entitlements (
++ id TEXT PRIMARY KEY,
++ subject TEXT NOT NULL,
++ kind TEXT NOT NULL,
++ scope TEXT NOT NULL,
++ subscription_id TEXT REFERENCES subscriptions (id),
++ starts_at TEXT NOT NULL,
++ ends_at TEXT NOT NULL,
++ source_txn TEXT REFERENCES transactions (id),
++ revoked_at TEXT,
++ revoked_reason TEXT,
++ created_at TEXT NOT NULL
++);
++CREATE INDEX IF NOT EXISTS entitlements_lookup ON entitlements (subject, kind, scope);
++CREATE INDEX IF NOT EXISTS entitlements_source ON entitlements (source_txn);
++
++CREATE TABLE IF NOT EXISTS idempotency_keys (
++ key TEXT PRIMARY KEY,
++ request_hash TEXT NOT NULL,
++ method TEXT NOT NULL,
++ path TEXT NOT NULL,
++ status INTEGER NOT NULL,
++ response TEXT NOT NULL,
++ created_at TEXT NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS settings (
++ id INTEGER PRIMARY KEY CHECK (id = 1),
++ freeze INTEGER NOT NULL DEFAULT 0,
++ freeze_reason TEXT,
++ frozen_at TEXT,
++ frozen_by TEXT
++);
++INSERT OR IGNORE INTO settings (id, freeze) VALUES (1, 0);
++
++CREATE TABLE IF NOT EXISTS reconciliation_runs (
++ id TEXT PRIMARY KEY,
++ started_at TEXT NOT NULL,
++ finished_at TEXT NOT NULL,
++ ok INTEGER NOT NULL,
++ report TEXT NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS import_runs (
++ id TEXT PRIMARY KEY,
++ source TEXT NOT NULL,
++ dry_run INTEGER NOT NULL,
++ started_at TEXT NOT NULL,
++ finished_at TEXT,
++ report TEXT
++);
++
++CREATE TABLE IF NOT EXISTS import_holds (
++ live_user_id INTEGER PRIMARY KEY,
++ owner TEXT NOT NULL,
++ reason TEXT NOT NULL,
++ credit_bits INTEGER NOT NULL DEFAULT 0,
++ payable_bits INTEGER NOT NULL DEFAULT 0,
++ resolved_subject TEXT,
++ first_seen_at TEXT NOT NULL,
++ updated_at TEXT NOT NULL
++);
++
++CREATE TABLE IF NOT EXISTS outbox (
++ seq INTEGER PRIMARY KEY AUTOINCREMENT,
++ event_id TEXT NOT NULL UNIQUE,
++ event TEXT NOT NULL,
++ created_at TEXT NOT NULL,
++ sent_at TEXT,
++ attempts INTEGER NOT NULL DEFAULT 0,
++ last_error TEXT
++);
++CREATE INDEX IF NOT EXISTS outbox_unsent ON outbox (sent_at, seq);
++`;
++
++function openDb(dbPath) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ if (dbPath !== ':memory:') db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ db.exec(SCHEMA);
++ return db;
++}
++
++module.exports = { openDb, ACCOUNT_KINDS, CURRENCIES, TXN_TYPES };
ddiff --git a/server/importer/live.js b/server/importer/live.js
nnew file mode 100644
iindex 0000000..872fcf4
---- /dev/null
++++ b/server/importer/live.js
@@@ -0,0 +1,296 @@
++'use strict';
++
++/**
++ * Import from a SNAPSHOT COPY of OpenVibe.Live's live.db (opened read-only; never the live file).
++ *
++ * 1. identities every Live user id that holds a balance or appears in a money row is resolved to a
++ * Network subject (resolve-batch). Unmapped users are never dropped: their accounts
++ * are opened under 'hold:live:<id>' and listed in import_holds; a later run moves a
++ * hold to the subject once the Network knows it.
++ * 2. receipts payment_orders → payment_intents (legacy_order_id) plus, for paid/credited orders, an
++ * immutable provider_events receipt (effect 'imported'). Live's PowerChat
++ * "direct[:renew]" / "site[:fee=N][:renew]" provider_ref values are route markers, not
++ * references: they become route/fee/auto_renew. Real duplicate references keep the
++ * first order's ref; the others are reported.
++ * 3. history every Live `transactions` row becomes a historical journal transaction (type
++ * import, status imported, test=1 before site_settings.stats_vibes_reset_at).
++ * 4. opening per user, an import transaction makes user_credit = users.openvibe_bucks_balance
++ * and creator_payable = users.openvibe_bucks_cashout_balance; the difference against
++ * the replayed history is booked against import_adjustment and listed.
++ * 5. subscriptions → Billing subscriptions + an entitlement for the current paid period.
++ *
++ * Idempotent: every write is keyed (import:live:txn:<id>, legacy ids, target-state keys), so a
++ * re-run over the same snapshot changes nothing, and a later snapshot (the final import at
++ * cutover) only adds what changed. --dry-run does everything inside a transaction that is rolled
++ * back, and stores only the report.
++ */
++const { post, balance, iso, prefixedId } = require('../ledger');
++const { A, entry } = require('../ops/common');
++
++const ROUTE_MARKER = /^(direct|site)(:|$)/;
++
++const liveTs = (s) => {
++ if (!s) return null;
++ const str = String(s);
++ const t = Date.parse(str.includes('T') ? str : `${str.replace(' ', 'T')}Z`);
++ return Number.isFinite(t) ? new Date(t).toISOString() : null;
++};
++
++function tableExists(db, name) { return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name); }
++function columns(db, table) { return tableExists(db, table) ? db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name) : []; }
++
++class DryRun extends Error {}
++
++async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = console }) {
++ const { db, rates } = ctx;
++ const started = ctx.now();
++ const runId = prefixedId('imp', started);
++ const report = {
++ run_id: runId, dry_run: !!dryRun, started_at: iso(started), source: 'openvibe-live snapshot',
++ counts: {}, holds: [], duplicate_provider_refs: [], route_markers: 0, adjustments: [], unreplayable: [],
++ anomalies: [], subscriptions: { imported: 0, updated: 0, skipped: [] }, new_transactions: 0,
++ };
++
++ // ── Read the snapshot ────────────────────────────────────
++ const userCols = columns(live, 'users');
++ const hasCashout = userCols.includes('openvibe_bucks_cashout_balance');
++ const users = live.prepare(`SELECT id, username, COALESCE(openvibe_bucks_balance, 0) AS credit${hasCashout ? ', COALESCE(openvibe_bucks_cashout_balance, 0) AS payable' : ', 0 AS payable'} FROM users`).all();
++ const txns = tableExists(live, 'transactions') ? live.prepare('SELECT * FROM transactions ORDER BY id').all() : [];
++ const orders = tableExists(live, 'payment_orders') ? live.prepare('SELECT * FROM payment_orders ORDER BY id').all() : [];
++ const subCols = columns(live, 'subscriptions');
++ const subs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
++ let resetAt = null;
++ if (tableExists(live, 'site_settings')) {
++ const r = live.prepare("SELECT value FROM site_settings WHERE key = 'stats_vibes_reset_at'").get();
++ if (r && r.value && !Number.isNaN(Date.parse(r.value))) resetAt = new Date(r.value).toISOString();
++ }
++ report.test_before = resetAt;
++ report.counts.live = { users: users.length, transactions: txns.length, payment_orders: orders.length, subscriptions: subs.length };
++
++ // ── 1. identities ────────────────────────────────────────
++ const ids = new Set();
++ for (const u of users) if (Math.round(u.credit) !== 0 || Math.round(u.payable) !== 0) ids.add(String(u.id));
++ for (const t of txns) { if (t.from_user_id) ids.add(String(t.from_user_id)); if (t.to_user_id) ids.add(String(t.to_user_id)); }
++ for (const o of orders) { ids.add(String(o.user_id)); if (o.streamer_id) ids.add(String(o.streamer_id)); }
++ for (const s of subs) { ids.add(String(s.subscriber_id)); ids.add(String(s.streamer_id)); }
++ // Holds from earlier runs are re-resolved too, so a newly mapped user is released.
++ for (const h of db.prepare('SELECT live_user_id FROM import_holds WHERE resolved_subject IS NULL').all()) ids.add(String(h.live_user_id));
++ const map = ids.size ? await resolveLiveUsers([...ids]) : new Map();
++ const subjectOf = (liveId) => (liveId == null ? null : map.get(String(liveId)) || null);
++ const owner = (liveId) => (liveId == null ? null : subjectOf(liveId) || `hold:live:${liveId}`);
++ const username = new Map(users.map((u) => [String(u.id), u.username]));
++ report.counts.identities = { resolved: [...ids].filter((i) => subjectOf(i)).length, unmapped: [...ids].filter((i) => !subjectOf(i)).length };
++
++ const before = db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n;
++ const ms = ctx.now();
++ const at = iso(ms);
++ const snap = rates.snapshot();
++
++ const run = () => {
++ const importTxn = (spec) => post(ctx, { type: 'import', status: 'imported', actor: { principal: 'svc:billing', job: 'import-live', run: runId }, ...spec });
++
++ // ── 2. payment_orders → intents + receipts ───────────
++ const refSeen = new Map(db.prepare('SELECT provider, provider_ref, legacy_order_id FROM payment_intents WHERE provider_ref IS NOT NULL').all()
++ .map((r) => [`${r.provider}|${r.provider_ref}`, r.legacy_order_id]));
++ let intentsNew = 0; let receiptsNew = 0;
++ for (const o of orders) {
++ const existing = db.prepare('SELECT id FROM payment_intents WHERE legacy_order_id = ?').get(o.id);
++ const legacy = { status: o.status, provider_ref: o.provider_ref, bucks: o.bucks, amount_cents: o.amount_cents, user_id: o.user_id, streamer_id: o.streamer_id };
++ if (!existing) {
++ let ref = o.provider_ref || null;
++ let route = null; let fee = 0; let renew = 0;
++ const meta = { legacy_live_order: legacy, rates: snap };
++ if (o.provider === 'powerchat' && ref && ROUTE_MARKER.test(ref)) {
++ route = ref.split(':')[0];
++ fee = Number((ref.match(/:fee=(\d+)/) || [])[1] || 0);
++ renew = /:renew$/.test(ref) ? 1 : 0;
++ ref = null;
++ report.route_markers++;
++ }
++ if (ref) {
++ const k = `${o.provider}|${ref}`;
++ if (refSeen.has(k)) {
++ report.duplicate_provider_refs.push({ provider: o.provider, provider_ref: ref, order: o.id, kept_on_order: refSeen.get(k) });
++ meta.duplicate_of_order = refSeen.get(k);
++ ref = null;
++ } else refSeen.set(k, o.id);
++ }
++ const created = liveTs(o.created_at) || at;
++ const stale = Date.parse(created) < ms - 3 * 86_400_000;
++ const status = o.status === 'credited' ? 'settled' : o.status === 'failed' ? 'failed' : (o.status === 'pending' && stale ? 'expired' : 'created');
++ if (o.status === 'paid') report.anomalies.push({ kind: 'order_paid_not_credited', order: o.id, provider: o.provider, amount_cents: o.amount_cents });
++ db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route, auto_renew,
++ status, legacy_order_id, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(prefixedId('pi', Date.parse(created)), o.provider, ref, o.kind === 'subscription' ? 'subscription' : 'purchase', owner(o.user_id),
++ o.streamer_id ? owner(o.streamer_id) : null, o.amount_cents || 0, fee, o.bucks || 0, route, renew, status, o.id, JSON.stringify(meta), created, at);
++ intentsNew++;
++ }
++ if (o.status === 'credited' || o.status === 'paid') {
++ const payload = JSON.stringify(o);
++ const r = db.prepare(`INSERT OR IGNORE INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at, processed_at, result, attempts)
++ VALUES (?, ?, 'live.payment_order', ?, ?, ?, ?, ?, 0)`).run(o.provider, `live-order:${o.id}`,
++ require('crypto').createHash('sha256').update(payload).digest('hex'), payload, liveTs(o.updated_at || o.created_at) || at, at,
++ JSON.stringify({ effect: 'imported', live_order: o.id, live_status: o.status }));
++ receiptsNew += r.changes;
++ }
++ }
++ report.counts.intents_new = intentsNew;
++ report.counts.receipts_new = receiptsNew;
++
++ // ── 3. history ───────────────────────────────────────
++ const openCashout = (t, liveTxn, status) => {
++ if (db.prepare('SELECT 1 FROM cashouts WHERE legacy_live_txn = ?').get(liveTxn.id)) return;
++ const created = liveTs(liveTxn.created_at) || at;
++ const subject = owner(liveTxn.from_user_id);
++ db.prepare(`INSERT INTO cashouts (id, subject, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, settle_txn,
++ payout_provider, payout_reference, reason, legacy_live_txn, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(prefixedId('co', Date.parse(created)), subject, liveTxn.amount, rates.valueCents(liveTxn.amount), status,
++ JSON.stringify({ type: 'paypal', legacy_message: liveTxn.message || null }),
++ iso(Date.parse(created) + rates.escrowDays * 86_400_000), t.id, status === 'requested' ? null : t.id,
++ status === 'paid' ? 'paypal' : null, status === 'paid' ? `live-import:unrecorded:${liveTxn.id}` : null,
++ status === 'denied' ? 'denied on Live' : null, liveTxn.id, created, at);
++ if (status === 'paid') report.anomalies.push({ kind: 'paid_cashout_without_provider_reference', live_txn: liveTxn.id, amount_bits: liveTxn.amount });
++ };
++ let historyNew = 0;
++ for (const t of txns) {
++ const a = Math.round(Number(t.amount) || 0);
++ const from = owner(t.from_user_id);
++ const to = owner(t.to_user_id);
++ const created = liveTs(t.created_at) || at;
++ const test = !!(resetAt && created < resetAt);
++ const money = (bits) => [entry(A.fxBits(), -bits), entry(A.fxCents(), rates.valueCents(bits)), entry(A.clearing('live-legacy'), -rates.valueCents(bits))];
++ let entries = [];
++ let note = null;
++ const moved = t.status === 'completed' || (t.type === 'cashout' && t.status === 'escrow');
++ if (a <= 0) note = 'non-positive amount';
++ else if (!moved && !(t.type === 'cashout' && t.status === 'refunded')) note = `status ${t.status}: no balance moved`;
++ else if (t.type === 'purchase' && to) entries = [...money(a), entry(A.credit(to), a)];
++ else if (t.type === 'donation' && to) entries = from ? [entry(A.credit(from), -a), entry(A.payable(to), a)] : [...money(a), entry(A.payable(to), a)];
++ else if (t.type === 'cashout' && from) {
++ if (t.status === 'escrow') entries = [entry(A.payable(from), -a), entry(A.pending(from), a)];
++ else if (t.status === 'completed') entries = [entry(A.payable(from), -a), entry(A.fxBits(), a), entry(A.fxCents(), -rates.valueCents(a)), entry(A.clearing('paypal_payouts'), rates.valueCents(a))];
++ else entries = []; // refunded (denied): requested and returned — nets to nothing
++ } else if (t.type === 'refund' && from && to) entries = [entry(A.payable(from), -a), entry(A.credit(to), a)];
++ else if (t.type === 'bonus' && to) entries = [entry(A.revenue('vibes-bits'), -a), entry(A.credit(to), a)];
++ else if (t.type === 'recycle' && from) entries = [entry(A.payable(from), -a), entry(A.credit(from), a)];
++ else note = `unreplayable ${t.type} (from ${t.from_user_id || '-'}, to ${t.to_user_id || '-'})`;
++ if (note) report.unreplayable.push({ live_txn: t.id, type: t.type, status: t.status, amount: t.amount, reason: note });
++ const r = importTxn({
++ idempotencyKey: `import:live:txn:${t.id}`, entries, test, createdAt: created,
++ fromSubject: t.from_user_id ? subjectOf(t.from_user_id) : null, toSubject: t.to_user_id ? subjectOf(t.to_user_id) : null,
++ metadata: { live: { id: t.id, type: t.type, status: t.status, amount: t.amount, from_user_id: t.from_user_id, to_user_id: t.to_user_id, stream_id: t.stream_id, message: t.message }, note, rates: snap },
++ });
++ if (!r.replay) {
++ historyNew++;
++ if (t.type === 'cashout' && from && a > 0) openCashout(r.txn, t, t.status === 'escrow' ? 'requested' : t.status === 'completed' ? 'paid' : t.status === 'refunded' ? 'denied' : null);
++ }
++ }
++ report.counts.history_new = historyNew;
++
++ // ── 4. opening balances ──────────────────────────────
++ const upsertHold = db.prepare(`INSERT INTO import_holds (live_user_id, owner, reason, credit_bits, payable_bits, first_seen_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (live_user_id) DO UPDATE SET credit_bits = excluded.credit_bits, payable_bits = excluded.payable_bits, updated_at = excluded.updated_at`);
++ const byId = new Map(users.map((u) => [String(u.id), u]));
++ const openingIds = new Set([...ids].filter((i) => byId.has(i)));
++ for (const i of openingIds) {
++ const u = byId.get(i);
++ const subject = subjectOf(i);
++ const own = owner(i);
++ const hold = `hold:live:${i}`;
++ // A user the Network now knows: move what an earlier run parked on the hold.
++ if (subject) {
++ for (const [acct, name] of [[A.credit, 'user_credit'], [A.payable, 'creator_payable'], [A.pending, 'payouts_pending']]) {
++ const held = balance(db, acct(hold));
++ if (held !== 0) {
++ importTxn({ idempotencyKey: `import:release-hold:${runId}:${i}:${name}`, entries: [entry(acct(hold), -held), entry(acct(subject), held)], metadata: { release_hold: i, account: name, amount: held, rates: snap } });
++ }
++ }
++ db.prepare('UPDATE cashouts SET subject = ? WHERE subject = ?').run(subject, hold);
++ db.prepare('UPDATE import_holds SET resolved_subject = ?, updated_at = ? WHERE live_user_id = ? AND resolved_subject IS NULL').run(subject, at, Number(i));
++ }
++ for (const [acct, name, raw] of [[A.credit, 'user_credit', u.credit], [A.payable, 'creator_payable', u.payable]]) {
++ const target = Math.round(Number(raw) || 0);
++ if (target !== Number(raw)) report.anomalies.push({ kind: 'fractional_balance', live_user_id: u.id, account: name, value: raw, imported_as: target });
++ const current = balance(db, acct(own));
++ const d = target - current;
++ if (d !== 0) {
++ importTxn({
++ idempotencyKey: `import:opening:${runId}:${own}:${name}`,
++ entries: [entry(acct(own), d), entry(A.importAdj('vibes-bits'), -d)],
++ fromSubject: null, toSubject: subject,
++ metadata: { opening_balance: { live_user_id: u.id, account: name, replayed_history: current, live_column: target, adjustment: d }, rates: snap },
++ });
++ report.adjustments.push({ live_user_id: u.id, username: u.username, subject, account: name, replayed_history: current, live_column: target, adjustment: d });
++ }
++ }
++ if (!subject) {
++ upsertHold.run(Number(i), hold, 'no Network subject for this Live user (resolve-batch returned null)', Math.round(u.credit), Math.round(u.payable), at, at);
++ report.holds.push({ live_user_id: u.id, username: u.username, credit_bits: Math.round(u.credit), payable_bits: Math.round(u.payable) });
++ }
++ }
++ // Unmapped ids that only appear in money rows (no users row left) are held too.
++ for (const i of ids) {
++ if (!subjectOf(i) && !byId.has(i)) {
++ upsertHold.run(Number(i), `hold:live:${i}`, 'no Live users row and no Network subject', 0, 0, at, at);
++ report.holds.push({ live_user_id: Number(i), username: null, credit_bits: 0, payable_bits: 0, note: 'no users row' });
++ }
++ }
++
++ // ── 5. subscriptions → entitlements ──────────────────
++ for (const s of subs) {
++ const subscriber = subjectOf(s.subscriber_id);
++ const streamer = subjectOf(s.streamer_id);
++ if (!subscriber || !streamer) { report.subscriptions.skipped.push({ live_sub: s.id, reason: 'unmapped subscriber or streamer (held)' }); continue; }
++ const end = liveTs(s.current_period_end || s.expires_at);
++ const liveStatus = String(s.status || (s.is_active ? 'active' : 'expired'));
++ const status = liveStatus === 'active' && end && Date.parse(end) > ms ? 'active' : (liveStatus === 'canceled' ? 'canceled' : (liveStatus === 'active' && !end ? 'active' : 'expired'));
++ const provider = s.provider === 'bucks' ? 'credit' : (s.provider || 'legacy');
++ const existing = db.prepare('SELECT * FROM subscriptions WHERE legacy_live_id = ?').get(s.id);
++ let subId;
++ if (existing) {
++ subId = existing.id;
++ const changed = existing.status !== status || existing.current_period_end !== end || !!existing.auto_renew !== !!s.auto_renew || !!existing.cancel_at_period_end !== !!s.cancel_at_period_end;
++ if (changed) {
++ db.prepare('UPDATE subscriptions SET status = ?, current_period_end = ?, auto_renew = ?, cancel_at_period_end = ?, updated_at = ? WHERE id = ?')
++ .run(status, end, s.auto_renew ? 1 : 0, s.cancel_at_period_end ? 1 : 0, at, subId);
++ report.subscriptions.updated++;
++ }
++ } else {
++ if (db.prepare('SELECT 1 FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subscriber, streamer)) {
++ report.subscriptions.skipped.push({ live_sub: s.id, reason: 'a Billing subscription for this pair already exists' });
++ continue;
++ }
++ subId = prefixedId('sub', Date.parse(liveTs(s.created_at || s.started_at) || at));
++ db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
++ price_cents, current_period_end, legacy_live_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(subId, subscriber, streamer, s.tier || 1, provider, s.provider_ref || null, status, s.auto_renew ? 1 : 0, s.cancel_at_period_end ? 1 : 0,
++ s.price_cents || 0, end, s.id, liveTs(s.created_at || s.started_at) || at, at);
++ report.subscriptions.imported++;
++ }
++ // Entitlement for the paid period Live shows, extended when a later snapshot extends it.
++ if (status !== 'expired' && end && Date.parse(end) > ms) {
++ const covered = db.prepare("SELECT MAX(ends_at) AS e FROM entitlements WHERE subscription_id = ? AND revoked_at IS NULL").get(subId).e;
++ if (!covered || covered < end) {
++ const start = covered && covered > at ? covered : (liveTs(s.started_at || s.created_at) || at);
++ db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
++ VALUES (?, ?, 'channel_subscription', ?, ?, ?, ?, NULL, ?)`).run(prefixedId('ent', ms), subscriber, streamer, subId, start < end ? start : at, end, at);
++ }
++ }
++ }
++
++ report.new_transactions = db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n - before;
++ if (dryRun) throw new DryRun();
++ };
++
++ try { db.transaction(run)(); }
++ catch (e) { if (!(e instanceof DryRun)) throw e; }
++
++ report.finished_at = iso(ctx.now());
++ report.usernames_held = report.holds.map((h) => h.username || username.get(String(h.live_user_id)) || null);
++ db.prepare('INSERT INTO import_runs (id, source, dry_run, started_at, finished_at, report) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(runId, 'live', dryRun ? 1 : 0, report.started_at, report.finished_at, JSON.stringify(report));
++ log.log && log.log(`[Billing] import ${runId}${dryRun ? ' (dry run, rolled back)' : ''}: ${report.new_transactions} new transactions, ${report.adjustments.length} opening adjustments, ${report.holds.length} holds, ${report.duplicate_provider_refs.length} duplicate refs`);
++ return report;
++}
++
++module.exports = { importLive, liveTs };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..95762fd
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,52 @@
++'use strict';
++
++/**
++ * OpenVibe.Billing — process entry. `node server/index.js`
++ * Listens on PORT (4600) behind nginx (billing.openvibe.network); see deploy/.
++ *
++ * Background jobs (BILLING_JOBS=off disables them): stored-but-unprocessed provider events are
++ * retried every minute; the subscription renewal sweep runs hourly; the outbox relay publishes to
++ * OpenVibe.Events when EVENTS_URL is set. Nothing runs while the economy is frozen except the relay.
++ */
++const { loadConfig } = require('./config');
++const { createApp } = require('./app');
++const providers = require('./providers');
++const subscriptions = require('./ops/subscriptions');
++const { createRelay } = require('./outbox');
++const { isFrozen } = require('./ops/common');
++
++const config = loadConfig();
++const app = createApp({ config });
++const { ctx, adapters, keys } = app.locals;
++keys.start();
++
++const timers = [];
++const relay = createRelay({ db: ctx.db, config });
++if (config.jobs.enabled) {
++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Billing] job:', e.message)); }, ms); t.unref(); timers.push(t); };
++ every(config.jobs.webhookRetryMs, () => providers.processPending(ctx, adapters));
++ every(config.jobs.sweepIntervalMs, () => {
++ if (isFrozen(ctx.db)) return;
++ const out = subscriptions.sweep(ctx);
++ if (out.renewed.length || out.expired.length || out.canceled.length) console.log(`[Billing] subscription sweep: ${out.renewed.length} renewed, ${out.expired.length} expired, ${out.canceled.length} canceled`);
++ });
++ relay.start();
++}
++
++const server = app.listen(config.port, config.host, () => {
++ const enabled = Object.values(adapters).filter((a) => a.enabled).map((a) => a.name);
++ console.log(`[Billing] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${config.dbPath})`);
++ console.log(`[Billing] providers enabled: ${enabled.length ? enabled.join(', ') : 'none'}; economy ${isFrozen(ctx.db) ? 'FROZEN' : 'open'}; events relay ${config.events.url ? `→ ${config.events.url}` : 'off (outbox accumulates)'}`);
++});
++server.keepAliveTimeout = 65_000;
++
++function shutdown(signal) {
++ console.log(`[Billing] ${signal} — closing`);
++ timers.forEach(clearInterval);
++ relay.stop();
++ keys.stop();
++ server.close(() => { try { ctx.db.close(); } catch { /* */ } process.exit(0); });
++ setTimeout(() => process.exit(0), 5000).unref();
++}
++process.on('SIGTERM', () => shutdown('SIGTERM'));
++process.on('SIGINT', () => shutdown('SIGINT'));
ddiff --git a/server/ledger.js b/server/ledger.js
nnew file mode 100644
iindex 0000000..d1a0171
---- /dev/null
++++ b/server/ledger.js
@@@ -0,0 +1,148 @@
++'use strict';
++
++/**
++ * The double-entry journal.
++ *
++ * post(ctx, { type, idempotencyKey, entries: [{ kind, owner, currency, amount }], ... })
++ *
++ * Amounts are signed integers in minor units (bits or cents). Value moves FROM an account with a
++ * negative entry TO an account with a positive entry, so an account's balance is SUM(entries):
++ * a user_credit balance of 500 is 500 spendable bits; provider_clearing goes negative as money
++ * arrives from a provider and positive as money leaves through it.
++ *
++ * Invariants (enforced here, verified again by reconcile.js):
++ * - every transaction's entries sum to zero per currency;
++ * - entries are never updated or deleted (db triggers); corrections are reversing transactions;
++ * - account_balances changes only here, in the same SQLite transaction as the entries;
++ * - a transaction's idempotency_key is unique: posting the same key again returns the
++ * original transaction and moves nothing.
++ *
++ * Callers run post() inside db.transaction() together with their funds checks, so a check and
++ * the movement it guards are one atomic unit.
++ */
++const { ids } = require('openvibe-contracts');
++
++class BillingError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.status = status;
++ this.code = code;
++ this.detail = detail;
++ this.extra = extra;
++ }
++}
++
++const iso = (ms) => new Date(ms).toISOString();
++const newTxnId = (ms) => `txn_${ids.ulid(ms)}`;
++const prefixedId = (prefix, ms) => `${prefix}_${ids.ulid(ms)}`;
++
++function accountId(db, { kind, owner = null, currency }, nowIso) {
++ const row = db.prepare('SELECT id FROM accounts WHERE kind = ? AND owner_subject IS ? AND currency = ?').get(kind, owner, currency);
++ if (row) return row.id;
++ const r = db.prepare('INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES (?, ?, ?, ?)').run(kind, owner, currency, nowIso || new Date().toISOString());
++ db.prepare('INSERT INTO account_balances (account_id, balance, updated_at) VALUES (?, 0, ?)').run(r.lastInsertRowid, nowIso || null);
++ return Number(r.lastInsertRowid);
++}
++
++/** Cached balance of an account (0 when it does not exist yet). */
++function balance(db, { kind, owner = null, currency }) {
++ const row = db.prepare(`SELECT b.balance FROM accounts a JOIN account_balances b ON b.account_id = a.id
++ WHERE a.kind = ? AND a.owner_subject IS ? AND a.currency = ?`).get(kind, owner, currency);
++ return row ? row.balance : 0;
++}
++
++function parseTxn(row) {
++ if (!row) return null;
++ return { ...row, test: !!row.test, actor: JSON.parse(row.actor || '{}'), metadata: JSON.parse(row.metadata || '{}') };
++}
++
++function entriesOf(db, txnId) {
++ return db.prepare(`SELECT a.kind, a.owner_subject AS owner, a.currency, e.amount FROM ledger_entries e
++ JOIN accounts a ON a.id = e.account_id WHERE e.txn_id = ? ORDER BY a.currency, a.kind, a.owner_subject`).all(txnId);
++}
++
++function getTxn(db, id) {
++ const t = parseTxn(db.prepare('SELECT * FROM transactions WHERE id = ?').get(id));
++ if (t) t.entries = entriesOf(db, id);
++ return t;
++}
++
++function getTxnByKey(db, key) {
++ const row = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(key);
++ return row ? getTxn(db, row.id) : null;
++}
++
++/**
++ * Post one balanced transaction. Returns { txn, replay }.
++ * Throws BillingError(500, 'ledger.unbalanced') when entries do not sum to zero per currency.
++ */
++function post(ctx, spec) {
++ const { db } = ctx;
++ const run = () => {
++ if (!spec.idempotencyKey) throw new BillingError(500, 'ledger.no_key', 'every transaction needs an idempotency key');
++ const existing = getTxnByKey(db, spec.idempotencyKey);
++ if (existing) return { txn: existing, replay: true };
++ const ms = ctx.now();
++ const nowIso = iso(ms);
++
++ // Merge entries per account and check the per-currency balance.
++ const merged = new Map();
++ const sums = new Map();
++ for (const e of spec.entries || []) {
++ if (!Number.isInteger(e.amount)) throw new BillingError(500, 'ledger.bad_amount', `non-integer amount ${e.amount}`);
++ if (e.amount === 0) continue;
++ const key = `${e.kind}|${e.owner == null ? '' : e.owner}|${e.currency}`;
++ const m = merged.get(key) || { kind: e.kind, owner: e.owner == null ? null : e.owner, currency: e.currency, amount: 0 };
++ m.amount += e.amount;
++ merged.set(key, m);
++ sums.set(e.currency, (sums.get(e.currency) || 0) + e.amount);
++ }
++ for (const [currency, sum] of sums) {
++ if (sum !== 0) throw new BillingError(500, 'ledger.unbalanced', `entries sum to ${sum} ${currency}, not zero`, { entries: spec.entries });
++ }
++
++ const id = newTxnId(ms);
++ db.prepare(`INSERT INTO transactions (id, type, status, idempotency_key, reverses_txn, test, actor, metadata,
++ from_subject, to_subject, provider, receipt_ref, source_event_id, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
++ id, spec.type, spec.status || 'settled', spec.idempotencyKey, spec.reversesTxn || null, spec.test ? 1 : 0,
++ JSON.stringify(spec.actor || {}), JSON.stringify(spec.metadata || {}),
++ spec.fromSubject || null, spec.toSubject || null, spec.provider || null, spec.receiptRef || null,
++ spec.sourceEventId || null, spec.createdAt || nowIso,
++ );
++ const insEntry = db.prepare('INSERT INTO ledger_entries (txn_id, account_id, amount) VALUES (?, ?, ?)');
++ const bump = db.prepare('UPDATE account_balances SET balance = balance + ?, updated_at = ? WHERE account_id = ?');
++ for (const m of merged.values()) {
++ if (m.amount === 0) continue;
++ const acct = accountId(db, m, nowIso);
++ insEntry.run(id, acct, m.amount);
++ bump.run(m.amount, nowIso, acct);
++ }
++ return { txn: getTxn(db, id), replay: false };
++ };
++ return db.inTransaction ? run() : db.transaction(run)();
++}
++
++/** Throws billing.insufficient_funds unless the account holds at least `amount`. */
++function requireFunds(db, account, amount, what = 'balance') {
++ const have = balance(db, account);
++ if (have < amount) {
++ throw new BillingError(409, 'billing.insufficient_funds', `insufficient ${what}: ${have} < ${amount} ${account.currency}`, { available: have, required: amount });
++ }
++ return have;
++}
++
++/** Public shape of a transaction. */
++function present(txn) {
++ if (!txn) return null;
++ return {
++ id: txn.id, type: txn.type, status: txn.status, test: !!txn.test,
++ reverses_txn: txn.reverses_txn || null,
++ from_subject: txn.from_subject || null, to_subject: txn.to_subject || null,
++ provider: txn.provider || null, receipt_ref: txn.receipt_ref || null,
++ actor: txn.actor, metadata: txn.metadata, created_at: txn.created_at,
++ entries: (txn.entries || []).map((e) => ({ account: { kind: e.kind, owner: e.owner, currency: e.currency }, amount: e.amount })),
++ };
++}
++
++module.exports = { BillingError, post, balance, accountId, getTxn, getTxnByKey, entriesOf, requireFunds, present, iso, prefixedId, newTxnId };
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..cea1e54
---- /dev/null
++++ b/server/network.js
@@@ -0,0 +1,88 @@
++'use strict';
++
++/**
++ * OpenVibe.Network client.
++ *
++ * keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
++ * refreshed every 6 h and retried every 30 s until it loads) — verifies service tokens
++ * identity Live user ids → canonical subjects via POST /internal/identity/resolve-batch
++ * { system: 'live', type: 'user', ids } with a client-credentials token
++ * (audience openvibe.network, capability identity.subject.resolve)
++ */
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++
++const BATCH = 500;
++
++function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
++ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
++ let timer = null;
++ async function load() {
++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
++ try {
++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
++ if (!res.ok) throw new Error(`JWKS ${res.status}`);
++ const body = await res.json();
++ const jwk = (body.keys || []).find((k) => k.kty === 'RSA') || (body.keys || [])[0];
++ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
++ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
++ else throw new Error('JWKS contained no keys');
++ return pem;
++ } catch (e) {
++ log.warn(`[Billing] Network key not loaded from ${url}: ${e.message}`);
++ return null;
++ }
++ }
++ function start() {
++ if (config.network.publicKey || timer) return;
++ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
++ retry();
++ timer = setInterval(() => { load(); }, 6 * 60 * 60 * 1000);
++ timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { get: () => pem, load, start, stop };
++}
++
++function createIdentity(config, { fetchImpl = globalThis.fetch } = {}) {
++ const base = config.network.internalUrl;
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve',
++ fetchImpl,
++ });
++
++ async function post(body, retried = false) {
++ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(10000),
++ });
++ if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || typeof data.results !== 'object') throw new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
++ return data.results;
++ }
++
++ /** Live user ids → Map(String(id) → subject id | null). */
++ async function resolveLiveUsers(liveIds) {
++ const out = new Map();
++ const list = [...new Set(liveIds.map(String))];
++ for (let i = 0; i < list.length; i += BATCH) {
++ const chunk = list.slice(i, i + BATCH);
++ const results = await post({ system: 'live', type: 'user', ids: chunk });
++ for (const k of chunk) {
++ const p = results[k];
++ out.set(k, p && p.subject && p.subject.type === 'user' ? p.subject.id : null);
++ }
++ }
++ return out;
++ }
++ return { resolveLiveUsers };
++}
++
++module.exports = { createKeyProvider, createIdentity };
ddiff --git a/server/ops/admin.js b/server/ops/admin.js
nnew file mode 100644
iindex 0000000..983beb2
---- /dev/null
++++ b/server/ops/admin.js
@@@ -0,0 +1,60 @@
++'use strict';
++
++/**
++ * Operator controls: the economy freeze (ADR-012 rule 11) and manual adjustments.
++ *
++ * An adjustment is an explicit balanced transaction between two accounts of one currency, with a
++ * mandatory reason — the only way an operator changes a balance (e.g. resolving a chargeback
++ * flagged for review, or settling an import hold once its owner is known).
++ */
++const { post, getTxn, iso } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { entry, fail, positiveInt, text } = require('./common');
++const { ACCOUNT_KINDS, CURRENCIES } = require('../db');
++const { summary } = require('./purchases');
++
++function freezeState(db) {
++ const r = db.prepare('SELECT * FROM settings WHERE id = 1').get();
++ return { frozen: !!r.freeze, reason: r.freeze_reason || null, frozen_at: r.frozen_at || null, frozen_by: r.frozen_by ? JSON.parse(r.frozen_by) : null };
++}
++
++function setFreeze(ctx, { on, reason, actor }) {
++ const { db } = ctx;
++ db.prepare('UPDATE settings SET freeze = ?, freeze_reason = ?, frozen_at = ?, frozen_by = ? WHERE id = 1')
++ .run(on ? 1 : 0, on ? text(reason, 'reason', 300) : null, on ? iso(ctx.now()) : null, on ? JSON.stringify(actor || {}) : null);
++ return freezeState(db);
++}
++
++function account(v, field) {
++ if (!v || typeof v !== 'object') fail(422, 'billing.invalid_input', `${field} must be { kind, owner?, currency }`);
++ if (!ACCOUNT_KINDS.includes(v.kind)) fail(422, 'billing.invalid_input', `${field}.kind must be one of ${ACCOUNT_KINDS.join(', ')}`);
++ if (!CURRENCIES.includes(v.currency)) fail(422, 'billing.invalid_input', `${field}.currency must be one of ${CURRENCIES.join(', ')}`);
++ return { kind: v.kind, owner: v.owner == null || v.owner === '' ? null : String(v.owner), currency: v.currency };
++}
++
++/** Move `amount` from `from` to `to` (same currency). */
++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');
++ 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');
++ return ctx.db.transaction(() => {
++ const existing = ctx.db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: getTxn(ctx.db, existing.id), replay: true };
++ const { txn } = post(ctx, {
++ type: 'adjustment',
++ idempotencyKey: input.idempotencyKey,
++ entries: [entry(from, -amount), entry(to, amount)],
++ actor: input.actor,
++ fromSubject: from.owner && from.owner.startsWith('usr_') ? from.owner : null,
++ toSubject: to.owner && to.owner.startsWith('usr_') ? to.owner : null,
++ metadata: { reason, amount, currency: from.currency, relates_to: input.relatesTo || null },
++ });
++ enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ return { txn, replay: false };
++ })();
++}
++
++module.exports = { freezeState, setFreeze, adjust };
ddiff --git a/server/ops/cashouts.js b/server/ops/cashouts.js
nnew file mode 100644
iindex 0000000..4620dc4
---- /dev/null
++++ b/server/ops/cashouts.js
@@@ -0,0 +1,160 @@
++'use strict';
++
++/**
++ * Cashouts (MONEY leaving the platform) and recycling (MONEY → CREDIT, ADR-012 rule 4).
++ *
++ * request() creator_payable → payouts_pending; escrow_until = now + escrowDays
++ * approve() payouts_pending → out through the payout provider; needs a payout reference from
++ * the provider and is refused while the escrow period runs (rule 10)
++ * deny() reverses the request: payouts_pending → creator_payable
++ * recycle() creator_payable → user_credit, an explicit journal entry
++ */
++const { post, getTxn, requireFunds, iso, prefixedId } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { A, entry, fail, positiveInt, text } = require('./common');
++const { summary } = require('./purchases');
++
++function parse(row) {
++ return row ? { ...row, payout_method: JSON.parse(row.payout_method || '{}'), decided_by: row.decided_by ? JSON.parse(row.decided_by) : null } : null;
++}
++function find(db, id) { return parse(db.prepare('SELECT * FROM cashouts WHERE id = ?').get(id)); }
++
++function present(c) {
++ if (!c) return null;
++ return {
++ id: c.id, subject: { type: 'user', id: c.subject }, amount_bits: c.amount_bits, value_cents: c.value_cents,
++ status: c.status, payout_method: c.payout_method, escrow_until: c.escrow_until,
++ request_txn: c.request_txn, settle_txn: c.settle_txn || null,
++ payout_provider: c.payout_provider || null, payout_reference: c.payout_reference || null,
++ reason: c.reason || null, created_at: c.created_at, updated_at: c.updated_at,
++ };
++}
++
++function payoutMethod(v) {
++ if (!v || typeof v !== 'object') fail(422, 'billing.invalid_input', "payout_method is required, e.g. { type: 'paypal', address: '[email protected]' }");
++ const type = String(v.type || '').toLowerCase();
++ if (!/^[a-z][a-z0-9_-]{1,39}$/.test(type)) fail(422, 'billing.invalid_input', 'payout_method.type is required');
++ const address = text(v.address, 'payout_method.address', 254);
++ if (!address) fail(422, 'billing.invalid_input', 'payout_method.address is required');
++ if (type === 'paypal' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(address)) fail(422, 'billing.invalid_input', 'payout_method.address must be a PayPal email');
++ return { type, address };
++}
++
++function request(ctx, input) {
++ const { db, rates } = ctx;
++ const amount = positiveInt(input.amount, 'amount', rates.maxBits);
++ if (amount < rates.minCashoutBits) fail(422, 'billing.amount_too_small', `the minimum cashout is ${rates.minCashoutBits} bits`);
++ const method = payoutMethod(input.payout_method);
++ return db.transaction(() => {
++ const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { cashout: parse(db.prepare('SELECT * FROM cashouts WHERE request_txn = ?').get(existing.id)), replay: true };
++ requireFunds(db, A.payable(input.subject), amount, 'creator payable (only money given to you can be cashed out)');
++ const ms = ctx.now();
++ const id = prefixedId('co', ms);
++ const escrowUntil = iso(ms + rates.escrowDays * 86_400_000);
++ const { txn } = post(ctx, {
++ type: 'cashout_request',
++ idempotencyKey: input.idempotencyKey,
++ entries: [entry(A.payable(input.subject), -amount), entry(A.pending(input.subject), amount)],
++ actor: input.actor,
++ fromSubject: input.subject,
++ metadata: { cashout_id: id, amount_bits: amount, value_cents: rates.valueCents(amount), escrow_until: escrowUntil, escrow_days: rates.escrowDays, rates: rates.snapshot() },
++ });
++ db.prepare(`INSERT INTO cashouts (id, subject, amount_bits, value_cents, status, payout_method, escrow_until, request_txn, created_at, updated_at)
++ VALUES (?, ?, ?, ?, 'requested', ?, ?, ?, ?, ?)`).run(id, input.subject, amount, rates.valueCents(amount), JSON.stringify(method), escrowUntil, txn.id, iso(ms), iso(ms));
++ const cashout = find(db, id);
++ enqueue(ctx, { event_type: 'billing.cashout.requested', subject: { type: 'cashout', id }, payload: { cashout: present(cashout), transaction_id: txn.id } });
++ return { cashout, replay: false };
++ })();
++}
++
++function approve(ctx, input) {
++ const { db, rates } = ctx;
++ const ref = text(input.payout_reference, 'payout_reference', 200);
++ if (!ref) fail(422, 'billing.payout_reference_required', 'approving a payout needs the payout reference from the provider');
++ const provider = String(input.payout_provider || 'paypal').toLowerCase();
++ return db.transaction(() => {
++ const c = find(db, input.id);
++ if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${input.id}`);
++ if (c.status === 'paid' && c.payout_reference === ref) return { cashout: c, replay: true };
++ if (c.status !== 'requested') fail(409, 'billing.cashout_not_pending', `cashout ${c.id} is ${c.status}`);
++ if (ctx.now() < Date.parse(c.escrow_until)) fail(409, 'billing.escrow_active', `cashout ${c.id} is in escrow until ${c.escrow_until}`, { escrow_until: c.escrow_until });
++ const value = c.value_cents;
++ const { txn } = post(ctx, {
++ type: 'cashout_paid',
++ idempotencyKey: input.idempotencyKey,
++ entries: [
++ entry(A.pending(c.subject), -c.amount_bits), entry(A.fxBits(), c.amount_bits),
++ entry(A.fxCents(), -value), entry(A.clearing(`${provider}_payouts`), value),
++ ],
++ actor: input.actor,
++ fromSubject: c.subject,
++ provider: `${provider}_payouts`,
++ receiptRef: `${provider}_payouts:${ref}`,
++ metadata: { cashout_id: c.id, amount_bits: c.amount_bits, value_cents: value, payout_reference: ref, rates: rates.snapshot() },
++ });
++ db.prepare(`UPDATE cashouts SET status = 'paid', settle_txn = ?, payout_provider = ?, payout_reference = ?, decided_by = ?, updated_at = ? WHERE id = ?`)
++ .run(txn.id, provider, ref, JSON.stringify(input.actor || {}), iso(ctx.now()), c.id);
++ const cashout = find(db, c.id);
++ enqueue(ctx, { event_type: 'billing.cashout.paid', subject: { type: 'cashout', id: c.id }, payload: { cashout: present(cashout), transaction_id: txn.id } });
++ return { cashout, replay: false };
++ })();
++}
++
++function deny(ctx, input) {
++ const { db } = ctx;
++ return db.transaction(() => {
++ const c = find(db, input.id);
++ if (!c) fail(404, 'billing.cashout_not_found', `no cashout ${input.id}`);
++ if (c.status === 'denied') return { cashout: c, replay: true };
++ if (c.status !== 'requested') fail(409, 'billing.cashout_not_pending', `cashout ${c.id} is ${c.status}`);
++ const reason = text(input.reason, 'reason', 300);
++ const { txn } = post(ctx, {
++ type: 'cashout_denied',
++ idempotencyKey: input.idempotencyKey,
++ reversesTxn: c.request_txn,
++ entries: [entry(A.pending(c.subject), -c.amount_bits), entry(A.payable(c.subject), c.amount_bits)],
++ actor: input.actor,
++ toSubject: c.subject,
++ metadata: { cashout_id: c.id, amount_bits: c.amount_bits, reason },
++ });
++ db.prepare(`UPDATE cashouts SET status = 'denied', settle_txn = ?, reason = ?, decided_by = ?, updated_at = ? WHERE id = ?`)
++ .run(txn.id, reason, JSON.stringify(input.actor || {}), iso(ctx.now()), c.id);
++ const cashout = find(db, c.id);
++ enqueue(ctx, { event_type: 'billing.cashout.denied', subject: { type: 'cashout', id: c.id }, payload: { cashout: present(cashout), transaction_id: txn.id } });
++ enqueue(ctx, { event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id }, payload: { ...summary(txn), reverses_txn: c.request_txn } });
++ return { cashout, replay: false };
++ })();
++}
++
++function recycle(ctx, input) {
++ const { db, rates } = ctx;
++ const amount = positiveInt(input.amount, 'amount', rates.maxBits);
++ return db.transaction(() => {
++ const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: getTxn(db, existing.id), replay: true };
++ requireFunds(db, A.payable(input.subject), amount, 'creator payable');
++ const { txn } = post(ctx, {
++ type: 'recycle',
++ idempotencyKey: input.idempotencyKey,
++ entries: [entry(A.payable(input.subject), -amount), entry(A.credit(input.subject), amount)],
++ actor: input.actor,
++ fromSubject: input.subject,
++ toSubject: input.subject,
++ metadata: { amount_bits: amount },
++ });
++ enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ return { txn, replay: false };
++ })();
++}
++
++function list(db, { status, subject, limit = 100 } = {}) {
++ const where = [];
++ const args = [];
++ if (status) { where.push('status = ?'); args.push(status); }
++ if (subject) { where.push('subject = ?'); args.push(subject); }
++ return db.prepare(`SELECT * FROM cashouts ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at ASC LIMIT ?`)
++ .all(...args, Math.min(500, limit)).map(parse);
++}
++
++module.exports = { request, approve, deny, recycle, find, list, present };
ddiff --git a/server/ops/common.js b/server/ops/common.js
nnew file mode 100644
iindex 0000000..5df46e1
---- /dev/null
++++ b/server/ops/common.js
@@@ -0,0 +1,82 @@
++'use strict';
++
++/**
++ * Shared pieces of every money operation: account names, input checks, the freeze guard and
++ * the "money arrived from a provider" entry shape.
++ */
++const { validate } = require('openvibe-contracts');
++const { BillingError } = require('../ledger');
++
++const BITS = 'vibes-bits';
++const CENTS = 'usd-cents';
++
++/** 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 }),
++ 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 }),
++ revenue: (currency) => ({ kind: 'platform_revenue', owner: null, currency }),
++ refunds: () => ({ kind: 'refunds', owner: null, currency: CENTS }),
++ loss: () => ({ kind: 'chargeback_loss', owner: null, currency: CENTS }),
++ fxBits: () => ({ kind: 'fx_conversion', owner: null, currency: BITS }),
++ fxCents: () => ({ kind: 'fx_conversion', owner: null, currency: CENTS }),
++ importAdj: (currency) => ({ kind: 'import_adjustment', owner: null, currency }),
++};
++const entry = (account, amount) => ({ ...account, amount });
++
++/**
++ * Money received through `provider` (paidCents) that becomes `bits` in `target`; whatever the
++ * payment exceeds the bits' value by is platform revenue. Balanced per currency:
++ * usd-cents: clearing −paid, revenue +(paid − value), fx +value
++ * vibes-bits: fx −bits, target +bits
++ */
++function receiptEntries(rates, { provider, paidCents, bits, target }) {
++ const value = rates.valueCents(bits);
++ const revenue = paidCents - value;
++ if (revenue < 0) throw new BillingError(422, 'billing.overcredit', `payment of ${paidCents} cents cannot back ${bits} bits (${value} cents of value)`);
++ return [
++ entry(A.clearing(provider), -paidCents),
++ entry(A.revenue(CENTS), revenue),
++ entry(A.fxCents(), value),
++ entry(A.fxBits(), -bits),
++ entry(target, bits),
++ ];
++}
++
++function fail(status, code, detail, extra) { throw new BillingError(status, code, detail, extra); }
++
++/** A SubjectRef (or its id string) of a user → the subject id. */
++function userSubject(v, field = 'subject') {
++ const ref = typeof v === 'string' ? { type: v.startsWith('gst_') ? 'guest' : 'user', id: v } : v;
++ const r = validate('identity.subject-ref@1', ref);
++ if (!r.valid) fail(422, 'billing.invalid_subject', `${field} must be a SubjectRef ({ type: 'user', id: 'usr_…' })`);
++ if (ref.type !== 'user') fail(422, 'billing.invalid_subject', `${field} must be a user subject; ${ref.type} subjects hold no money`);
++ return ref.id;
++}
++
++function positiveInt(v, field, max) {
++ const n = Number(v);
++ if (!Number.isInteger(n) || n <= 0) fail(422, 'billing.invalid_amount', `${field} must be a positive integer`);
++ if (max && n > max) fail(422, 'billing.invalid_amount', `${field} exceeds the maximum of ${max}`);
++ return n;
++}
++
++function text(v, field, max = 300) {
++ if (v == null || v === '') return null;
++ const s = String(v).trim();
++ if (s.length > max) fail(422, 'billing.invalid_input', `${field} must be ${max} characters or fewer`);
++ return s || null;
++}
++
++function isFrozen(db) {
++ return !!db.prepare('SELECT freeze FROM settings WHERE id = 1').get().freeze;
++}
++function assertNotFrozen(ctx) {
++ if (isFrozen(ctx.db)) fail(503, 'billing.frozen', 'the economy is frozen: writes are refused, reads are served');
++}
++
++/** Movement summary for a subject from a transaction's entries (used in API responses). */
++function subjectRef(id) { return id ? { type: 'user', id } : null; }
++
++module.exports = { A, BITS, CENTS, entry, receiptEntries, fail, userSubject, positiveInt, text, isFrozen, assertNotFrozen, subjectRef };
ddiff --git a/server/ops/intents.js b/server/ops/intents.js
nnew file mode 100644
iindex 0000000..9247e2e
---- /dev/null
++++ b/server/ops/intents.js
@@@ -0,0 +1,88 @@
++'use strict';
++
++/**
++ * Payment intents: a checkout that was started. The amount is computed here from the configured
++ * rates (never taken from the client), and the provider reference is unique once set.
++ *
++ * kind purchase bits → price under the tiers
++ * kind subscription sub price, plus the site-route fee when PowerChat routes through the site
++ */
++const { prefixedId, iso } = require('../ledger');
++const { fail, positiveInt, userSubject } = require('./common');
++
++function parse(row) {
++ return row ? { ...row, auto_renew: !!row.auto_renew, metadata: JSON.parse(row.metadata || '{}') } : null;
++}
++
++/** By id, or by a Live order id for intents imported from payment_orders. */
++function find(db, ref) {
++ const s = String(ref || '');
++ if (/^\d+$/.test(s)) return parse(db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = ?').get(Number(s)));
++ return parse(db.prepare('SELECT * FROM payment_intents WHERE id = ?').get(s));
++}
++
++function findByProviderRef(db, provider, ref) {
++ return parse(db.prepare('SELECT * FROM payment_intents WHERE provider = ? AND provider_ref = ?').get(provider, ref));
++}
++
++function create(ctx, input) {
++ const { db, rates } = ctx;
++ const provider = String(input.provider || '').toLowerCase();
++ if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider)) fail(422, 'billing.invalid_input', 'provider is required');
++ const kind = input.kind;
++ const subject = userSubject(input.subject, 'subject');
++ const ms = ctx.now();
++ const row = {
++ id: prefixedId('pi', ms), provider, provider_ref: null, kind, subject, streamer_subject: null,
++ amount_cents: 0, fee_cents: 0, bits: 0, route: null, auto_renew: input.auto_renew ? 1 : 0,
++ metadata: {},
++ };
++ if (kind === 'purchase') {
++ row.bits = positiveInt(input.bits, 'bits', rates.maxBits);
++ if (row.bits < rates.minPurchaseBits) fail(422, 'billing.amount_too_small', `the minimum purchase is ${rates.minPurchaseBits} bits`);
++ row.amount_cents = rates.priceCents(row.bits);
++ } else if (kind === 'subscription') {
++ row.streamer_subject = userSubject(input.streamer, 'streamer');
++ if (row.streamer_subject === subject) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');
++ const base = rates.subPriceCents;
++ row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);
++ row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;
++ row.amount_cents = base + row.fee_cents;
++ } else {
++ fail(422, 'billing.invalid_input', "kind must be 'purchase' or 'subscription'");
++ }
++ row.metadata = { rates: rates.snapshot({ price_tiers: kind === 'purchase' ? rates.priceTiers : undefined, sub_share_pct: rates.subSharePct, site_route_fee_pct: rates.siteRouteFeePct }) };
++ db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,
++ auto_renew, status, metadata, created_at, updated_at)
++ VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
++ row.amount_cents, row.fee_cents, row.bits, row.route, row.auto_renew, JSON.stringify(row.metadata), iso(ms), iso(ms));
++ return find(db, row.id);
++}
++
++function setProviderRef(ctx, id, ref) {
++ ctx.db.prepare('UPDATE payment_intents SET provider_ref = ?, updated_at = ? WHERE id = ?').run(ref, iso(ctx.now()), id);
++}
++function mergeMetadata(ctx, id, extra) {
++ const row = find(ctx.db, id);
++ ctx.db.prepare('UPDATE payment_intents SET metadata = ?, updated_at = ? WHERE id = ?').run(JSON.stringify({ ...row.metadata, ...extra }), iso(ctx.now()), id);
++}
++function setStatus(ctx, id, status) {
++ ctx.db.prepare('UPDATE payment_intents SET status = ?, updated_at = ? WHERE id = ?').run(status, iso(ctx.now()), id);
++}
++function markSettled(ctx, id, txnId) {
++ ctx.db.prepare("UPDATE payment_intents SET status = 'settled', settled_txn = ?, updated_at = ? WHERE id = ?").run(txnId, iso(ctx.now()), id);
++}
++
++function present(i) {
++ if (!i) return null;
++ return {
++ id: i.id, provider: i.provider, provider_ref: i.provider_ref, kind: i.kind,
++ subject: { type: 'user', id: i.subject }, streamer: i.streamer_subject ? { type: 'user', id: i.streamer_subject } : null,
++ amount_cents: i.amount_cents, fee_cents: i.fee_cents, bits: i.bits, route: i.route, auto_renew: i.auto_renew,
++ status: i.status, settled_txn: i.settled_txn || null, created_at: i.created_at,
++ // PowerChat checkouts carry this in app_ref; the webhook echoes it back.
++ checkout_ref: i.provider === 'powerchat' ? `${i.kind === 'purchase' ? 'pcorder' : 'pcsub'}:${i.id}` : undefined,
++ };
++}
++
++module.exports = { find, findByProviderRef, create, setProviderRef, mergeMetadata, setStatus, markSettled, present, parse };
ddiff --git a/server/ops/purchases.js b/server/ops/purchases.js
nnew file mode 100644
iindex 0000000..76df051
---- /dev/null
++++ b/server/ops/purchases.js
@@@ -0,0 +1,78 @@
++'use strict';
++
++/**
++ * Purchases: a provider receipt becomes spendable credit (CREDIT, ADR-012).
++ *
++ * settle(ctx, { provider, receiptRef, subject?, paidCents, bits?, intentId?, test, ... })
++ *
++ * The receipt reference is unique across the journal, so the same provider payment settles once
++ * whatever event, retry or API call carries it. Bits credited:
++ * - the intent's package when the payment covers its price (one cent of slack, as Live);
++ * - an explicit `bits` when its value is covered by the payment;
++ * - otherwise what the payment buys under the price tiers (never the package on faith).
++ */
++const { post, getTxn, BillingError, present } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { A, receiptEntries, fail, positiveInt } = require('./common');
++const intents = require('./intents');
++
++function settle(ctx, input) {
++ const { db, rates } = ctx;
++ return db.transaction(() => {
++ const receiptRef = input.receiptRef;
++ if (!receiptRef) fail(422, 'billing.invalid_input', 'a provider receipt reference is required');
++ const dup = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(receiptRef);
++ if (dup) return { txn: getTxn(db, dup.id), replay: true, duplicateReceipt: true };
++
++ const paidCents = positiveInt(input.paidCents, 'amount_cents', 100_000_000);
++ let intent = null;
++ if (input.intentId) {
++ intent = intents.find(db, input.intentId);
++ if (!intent) fail(404, 'billing.intent_not_found', `no payment intent ${input.intentId}`);
++ if (intent.kind !== 'purchase') fail(422, 'billing.intent_mismatch', `intent ${intent.id} is a ${intent.kind}, not a purchase`);
++ if (intent.provider !== input.provider) fail(422, 'billing.intent_mismatch', `intent ${intent.id} belongs to ${intent.provider}, not ${input.provider}`);
++ if (input.subject && input.subject !== intent.subject) fail(422, 'billing.intent_mismatch', 'the intent belongs to another subject');
++ if (intent.status === 'settled' && intent.settled_txn) return { txn: getTxn(db, intent.settled_txn), replay: true };
++ }
++ const subject = intent ? intent.subject : input.subject;
++ if (!subject) fail(422, 'billing.invalid_subject', 'a purchase needs a subject or an intent');
++
++ let bits;
++ let basis;
++ if (intent && intent.bits && paidCents >= intent.amount_cents - 1) {
++ bits = intent.bits; basis = 'intent_package';
++ } else if (input.bits != null) {
++ bits = positiveInt(input.bits, 'bits', rates.maxBits); basis = 'explicit';
++ } else {
++ bits = rates.bitsForPriceCents(paidCents); basis = intent ? 'paid_amount_underpaid_intent' : 'paid_amount_tiers';
++ }
++ if (bits < 1) fail(422, 'billing.amount_too_small', `a payment of ${paidCents} cents buys no Vibes`);
++ if (bits > rates.maxBits) fail(422, 'billing.invalid_amount', `bits exceed the maximum of ${rates.maxBits}`);
++
++ const { txn } = post(ctx, {
++ type: 'purchase',
++ idempotencyKey: input.idempotencyKey,
++ entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.credit(subject) }),
++ test: !!input.test,
++ actor: input.actor,
++ toSubject: subject,
++ provider: input.provider,
++ receiptRef,
++ sourceEventId: input.sourceEventId,
++ metadata: {
++ paid_cents: paidCents, bits, value_cents: rates.valueCents(bits), revenue_cents: paidCents - rates.valueCents(bits),
++ pricing: basis, intent_id: intent ? intent.id : null, rates: rates.snapshot(), ...(input.metadata || {}),
++ },
++ });
++ if (intent) intents.markSettled(ctx, intent.id, txn.id);
++ enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn), traceId: input.traceId });
++ return { txn, replay: false };
++ })();
++}
++
++function summary(txn) {
++ const t = present(txn);
++ return { transaction_id: t.id, type: t.type, test: t.test, from_subject: t.from_subject, to_subject: t.to_subject, provider: t.provider, metadata: t.metadata };
++}
++
++module.exports = { settle, summary, BillingError };
ddiff --git a/server/ops/reversals.js b/server/ops/reversals.js
nnew file mode 100644
iindex 0000000..f45ae9f
---- /dev/null
++++ b/server/ops/reversals.js
@@@ -0,0 +1,106 @@
++'use strict';
++
++/**
++ * Refunds, reversals and chargebacks of provider receipts (ADR-012 rule 2). Each is a new
++ * transaction with reverses_txn → the original; history is never edited.
++ *
++ * purchase the buyer's remaining credit is clawed back (up to the bits the reversed part
++ * bought). Credit already given away stays with its recipient: the recipient's
++ * payable is never touched; the unrecovered value is booked to chargeback_loss
++ * and the transaction is flagged for review.
++ * subscription paid periods granted by the payment are revoked; the creator's share stays
++ * (flagged for review); the money goes back out against refunds / chargeback_loss.
++ * donation (site-routed tip) the creator's payable stays; loss booked, flagged for review.
++ *
++ * Partial reversals are supported; the cumulative reversed amount never exceeds what was paid.
++ */
++const { post, getTxn, balance } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { A, entry, fail } = require('./common');
++const { summary } = require('./purchases');
++const intents = require('./intents');
++
++function reversedCents(db, txnId) {
++ return db.prepare(`SELECT COALESCE(SUM(json_extract(metadata, '$.cents')), 0) AS n FROM transactions WHERE reverses_txn = ? AND type IN ('refund', 'chargeback')`).get(txnId).n;
++}
++
++/**
++ * input: { kind: 'refund'|'chargeback', provider, originalReceiptRef, reversalRef, cents?, cumulativeCents?,
++ * idempotencyKey, sourceEventId, actor, reason }
++ */
++function reverseReceipt(ctx, input) {
++ const { db, rates } = ctx;
++ return db.transaction(() => {
++ const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ? OR (receipt_ref IS NOT NULL AND receipt_ref = ?)').get(input.idempotencyKey, input.reversalRef || null);
++ if (existing) return { txn: getTxn(db, existing.id), replay: true };
++ const origRow = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(input.originalReceiptRef);
++ if (!origRow) fail(404, 'billing.original_not_found', `no settled receipt ${input.originalReceiptRef}`);
++ const orig = getTxn(db, origRow.id);
++ const paid = Number(orig.metadata.paid_cents) || 0;
++ const done = reversedCents(db, orig.id);
++ const remaining = paid - done;
++ let cents = input.cumulativeCents != null ? Number(input.cumulativeCents) - done : (input.cents != null ? Number(input.cents) : remaining);
++ cents = Math.min(Math.round(cents), remaining);
++ if (!(cents > 0)) return { txn: null, replay: false, noop: 'already_reversed' };
++
++ const kind = input.kind === 'chargeback' ? 'chargeback' : 'refund';
++ const lossOrRefund = kind === 'chargeback' ? A.loss() : A.refunds();
++ const entries = [];
++ const meta = { cents, original_paid_cents: paid, reason: input.reason || null };
++ let review = false;
++ let revoked = 0;
++
++ if (orig.type === 'purchase') {
++ const B = Number(orig.metadata.bits) || 0;
++ const b = Math.round((B * (done + cents)) / paid) - Math.round((B * done) / paid);
++ const owner = orig.to_subject;
++ const clawed = Math.max(0, Math.min(balance(db, A.credit(owner)), b));
++ const unrecovered = b - clawed;
++ const nonFx = cents - rates.valueCents(clawed);
++ entries.push(entry(A.credit(owner), -clawed), entry(A.fxBits(), clawed), entry(A.fxCents(), -rates.valueCents(clawed)), entry(A.clearing(orig.provider), cents));
++ if (kind === 'chargeback') {
++ entries.push(entry(A.loss(), -nonFx));
++ } else {
++ const lossPart = Math.min(nonFx, rates.valueCents(unrecovered));
++ entries.push(entry(A.loss(), -lossPart), entry(A.refunds(), -(nonFx - lossPart)));
++ }
++ Object.assign(meta, { bits_reversed: b, bits_clawed_back: clawed, unrecovered_bits: unrecovered });
++ review = unrecovered > 0;
++ } else if (orig.type === 'subscription' || orig.type === 'donation') {
++ if (orig.entries.length) {
++ entries.push(entry(A.clearing(orig.provider), cents), entry(lossOrRefund, -cents));
++ review = true; // the creator's share/payable was kept
++ meta.creator_payable_kept_bits = Number(orig.metadata.share_bits || orig.metadata.amount_bits) || 0;
++ } else {
++ meta.external = true; // the money never touched OpenVibe
++ }
++ } else {
++ fail(422, 'billing.not_reversible', `${orig.type} transactions are not provider receipts`);
++ }
++ if (review) meta.review = 'required';
++
++ const { txn } = post(ctx, {
++ type: kind,
++ idempotencyKey: input.idempotencyKey,
++ reversesTxn: orig.id,
++ entries,
++ test: orig.test,
++ actor: input.actor,
++ fromSubject: orig.to_subject,
++ toSubject: orig.from_subject,
++ provider: orig.provider,
++ receiptRef: input.reversalRef || null,
++ sourceEventId: input.sourceEventId,
++ metadata: { ...meta, rates: rates.snapshot() },
++ });
++ if (orig.type === 'subscription') revoked = require('./subscriptions').revokeForTxn(ctx, orig.id, kind);
++ if (done + cents >= paid && orig.metadata.intent_id) intents.setStatus(ctx, orig.metadata.intent_id, 'refunded');
++ enqueue(ctx, {
++ event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id },
++ payload: { ...summary(txn), reverses_txn: orig.id, review: review ? 'required' : null, entitlements_revoked: revoked },
++ });
++ return { txn, replay: false, review };
++ })();
++}
++
++module.exports = { reverseReceipt, reversedCents };
ddiff --git a/server/ops/subscriptions.js b/server/ops/subscriptions.js
nnew file mode 100644
iindex 0000000..6e10e2e
---- /dev/null
++++ b/server/ops/subscriptions.js
@@@ -0,0 +1,285 @@
++'use strict';
++
++/**
++ * Channel subscriptions and the entitlements they grant (ENTITLEMENT, ADR-012).
++ *
++ * pay() starts or renews a subscription period, paid either
++ * - from credit: user_credit:<subscriber> −cost → creator_payable:<streamer> +share,
++ * platform_revenue +rest;
++ * - from a receipt: money received for the sub (Stripe invoice, site-routed PowerChat tip,
++ * PayPal …) → creator_payable:<streamer> +share of the base price (the
++ * site-route fee is platform revenue), the rest platform revenue;
++ * - EXTERNAL: a tip on the streamer's own PowerChat (route 'direct'): the money never
++ * touched OpenVibe, so the period is granted with no journal entries.
++ * The streamer's share is credited on EVERY paid period — first payment and renewals alike.
++ *
++ * Entitlement truth is the entitlements table: one row per granted period; a subject is
++ * entitled to a streamer while a non-revoked row covers now. It needs nothing from Live.
++ */
++const { post, getTxn, requireFunds, iso, prefixedId } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { A, entry, receiptEntries, fail, positiveInt } = require('./common');
++const { summary } = require('./purchases');
++const intents = require('./intents');
++
++const KIND = 'channel_subscription';
++
++function parse(row) { return row ? { ...row, auto_renew: !!row.auto_renew, cancel_at_period_end: !!row.cancel_at_period_end } : null; }
++function find(db, id) { return parse(db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id)); }
++function findPair(db, subscriber, streamer) { return parse(db.prepare('SELECT * FROM subscriptions WHERE subscriber = ? AND streamer = ?').get(subscriber, streamer)); }
++function findByProviderRef(db, provider, ref) { return ref ? parse(db.prepare('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ?').get(provider, ref)) : null; }
++
++function present(s) {
++ if (!s) return null;
++ return {
++ id: s.id, subscriber: { type: 'user', id: s.subscriber }, streamer: { type: 'user', id: s.streamer }, tier: s.tier,
++ provider: s.provider, provider_ref: s.provider_ref || null, route: s.route || null, status: s.status,
++ auto_renew: !!s.auto_renew, cancel_at_period_end: !!s.cancel_at_period_end, price_cents: s.price_cents,
++ current_period_end: s.current_period_end, created_at: s.created_at, updated_at: s.updated_at,
++ };
++}
++
++/** Entitlement of `subject` to `streamer` at `ms`. */
++function entitlement(db, subject, streamer, ms) {
++ const at = iso(ms);
++ const row = db.prepare(`SELECT MAX(ends_at) AS ends_at, MIN(starts_at) AS starts_at FROM entitlements
++ WHERE subject = ? AND kind = ? AND scope = ? AND revoked_at IS NULL AND starts_at <= ? AND ends_at > ?`).get(subject, KIND, streamer, at, at);
++ // Chained future periods (a renewal paid early) extend the expiry.
++ let expires = row && row.ends_at;
++ if (expires) {
++ for (;;) {
++ const next = db.prepare(`SELECT MAX(ends_at) AS e FROM entitlements WHERE subject = ? AND kind = ? AND scope = ? AND revoked_at IS NULL
++ AND starts_at <= ? AND ends_at > ?`).get(subject, KIND, streamer, expires, expires).e;
++ if (!next) break;
++ expires = next;
++ }
++ }
++ const sub = findPair(db, subject, streamer);
++ return {
++ subject: { type: 'user', id: subject }, streamer: { type: 'user', id: streamer }, kind: KIND,
++ active: !!expires, expires_at: expires || null,
++ subscription: sub ? { id: sub.id, status: sub.status, auto_renew: sub.auto_renew, cancel_at_period_end: sub.cancel_at_period_end, provider: sub.provider } : null,
++ };
++}
++
++function activeEntitlements(db, subject, ms) {
++ const at = iso(ms);
++ return db.prepare(`SELECT DISTINCT scope FROM entitlements WHERE subject = ? AND kind = ? AND revoked_at IS NULL AND starts_at <= ? AND ends_at > ?`)
++ .all(subject, KIND, at, at).map((r) => entitlement(db, subject, r.scope, ms));
++}
++
++/**
++ * Pay one period. input: { subscriber, streamer, source: 'credit'|'receipt', priceCents?, autoRenew?,
++ * receipt?: { provider, receiptRef, paidCents, feeCents, route, providerRef, test, periodEnd },
++ * intentId?, idempotencyKey, actor, sourceEventId, renewal? }
++ */
++function pay(ctx, input) {
++ const { db, rates } = ctx;
++ const { subscriber, streamer } = input;
++ if (subscriber === streamer) fail(422, 'billing.self_dealing', 'you cannot subscribe to yourself');
++ return db.transaction(() => {
++ const existingTxn = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existingTxn) return result(db, getTxn(db, existingTxn.id), ctx, true);
++ const r = input.receipt;
++ if (r) {
++ const dup = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(r.receiptRef);
++ if (dup) return { ...result(db, getTxn(db, dup.id), ctx, true), duplicateReceipt: true };
++ }
++ const ms = ctx.now();
++ const priceCents = input.priceCents != null ? positiveInt(input.priceCents, 'price_cents', 10_000_000) : rates.subPriceCents;
++ const prior = findPair(db, subscriber, streamer);
++
++ let entries = [];
++ let provider = 'credit';
++ let meta;
++ let route = null;
++ if (input.source === 'credit') {
++ const cost = rates.bitsForValueCents(priceCents);
++ const share = Math.min(cost, rates.subShareBits(priceCents));
++ requireFunds(db, A.credit(subscriber), cost, 'credit');
++ entries = [entry(A.credit(subscriber), -cost), entry(A.payable(streamer), share), entry(A.revenue('vibes-bits'), cost - share)];
++ meta = { source: 'credit', price_cents: priceCents, cost_bits: cost, share_bits: share, sub_share_pct: rates.subSharePct };
++ } else if (input.source === 'receipt' && r) {
++ provider = r.provider;
++ route = r.route || null;
++ const paid = positiveInt(r.paidCents, 'amount_cents', 100_000_000);
++ const fee = Math.max(0, Math.round(Number(r.feeCents) || 0));
++ if (route === 'direct') {
++ // EXTERNAL: the streamer holds the money already; nothing touches the journal.
++ meta = { source: 'external', route, paid_cents: paid, external: true };
++ } else {
++ const base = Math.max(0, paid - fee);
++ const share = rates.subShareBits(base);
++ entries = receiptEntries(rates, { provider, paidCents: paid, bits: share, target: A.payable(streamer) });
++ meta = { source: 'receipt', route, paid_cents: paid, fee_cents: fee, share_base_cents: base, share_bits: share, sub_share_pct: rates.subSharePct };
++ }
++ } else {
++ fail(422, 'billing.invalid_input', "source must be 'credit' or 'receipt' (with a receipt)");
++ }
++
++ // Period: starts now, or at the end of a still-running period (an early renewal).
++ const running = prior && prior.current_period_end && Date.parse(prior.current_period_end) > ms ? Date.parse(prior.current_period_end) : ms;
++ const start = running;
++ const end = r && r.periodEnd ? Math.max(Date.parse(r.periodEnd), start + 1) : start + rates.subPeriodDays * 86_400_000;
++ const renewal = !!(prior && (prior.status === 'active' || input.renewal));
++
++ const { txn } = post(ctx, {
++ type: 'subscription',
++ idempotencyKey: input.idempotencyKey,
++ entries,
++ test: !!(r && r.test),
++ actor: input.actor,
++ fromSubject: subscriber,
++ toSubject: streamer,
++ provider: r ? r.provider : null,
++ receiptRef: r ? r.receiptRef : null,
++ sourceEventId: input.sourceEventId,
++ metadata: { ...meta, renewal, period_start: iso(start), period_end: iso(end), intent_id: input.intentId || null, rates: rates.snapshot() },
++ });
++
++ const nowIso = iso(ms);
++ const autoRenew = input.autoRenew != null ? (input.autoRenew ? 1 : 0) : (prior ? (prior.auto_renew ? 1 : 0) : (input.source === 'credit' ? 1 : 0));
++ let sub;
++ if (prior) {
++ db.prepare(`UPDATE subscriptions SET provider = ?, provider_ref = COALESCE(?, provider_ref), route = ?, status = 'active', auto_renew = ?,
++ cancel_at_period_end = 0, price_cents = ?, current_period_end = ?, updated_at = ? WHERE id = ?`)
++ .run(provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, prior.id);
++ sub = find(db, prior.id);
++ } else {
++ const id = prefixedId('sub', ms);
++ db.prepare(`INSERT INTO subscriptions (id, subscriber, streamer, tier, provider, provider_ref, route, status, auto_renew, cancel_at_period_end,
++ price_cents, current_period_end, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?, ?, 'active', ?, 0, ?, ?, ?, ?)`)
++ .run(id, subscriber, streamer, provider, r ? r.providerRef || null : null, route, autoRenew, priceCents, iso(end), nowIso, nowIso);
++ sub = find(db, id);
++ }
++ db.prepare(`INSERT INTO entitlements (id, subject, kind, scope, subscription_id, starts_at, ends_at, source_txn, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(prefixedId('ent', ms), subscriber, KIND, streamer, sub.id, iso(start), iso(end), txn.id, nowIso);
++ if (input.intentId) intents.markSettled(ctx, input.intentId, txn.id);
++
++ if (txn.entries.length) enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ enqueue(ctx, {
++ event_type: 'billing.entitlement.changed', subject: { type: 'entitlement', id: `${subscriber}:${KIND}:${streamer}` },
++ payload: { ...entitlement(db, subscriber, streamer, ms), reason: renewal ? 'renewed' : 'granted', transaction_id: txn.id },
++ });
++ return result(db, txn, ctx, false);
++ })();
++}
++
++function result(db, txn, ctx, replay) {
++ const ent = db.prepare('SELECT * FROM entitlements WHERE source_txn = ?').get(txn.id);
++ const sub = ent ? find(db, ent.subscription_id) : null;
++ return { txn, subscription: sub, entitlement: sub ? entitlement(db, sub.subscriber, sub.streamer, ctx.now()) : null, replay };
++}
++
++/**
++ * Cancel at period end. Stripe subscriptions are cancelled AT STRIPE through the adapter first
++ * (Live only flipped a local flag and Stripe kept billing); if Stripe refuses, nothing changes.
++ */
++async function cancel(ctx, { id, actor }, adapters) {
++ const { db } = ctx;
++ const sub = find(db, id);
++ if (!sub) fail(404, 'billing.subscription_not_found', `no subscription ${id}`);
++ if (sub.status !== 'active') return { subscription: sub, provider_sync: 'not_needed', replay: true };
++ let providerSync = 'not_needed';
++ if (sub.provider === 'stripe') {
++ const stripe = adapters && adapters.stripe;
++ if (!stripe || !stripe.enabled) fail(409, 'billing.provider_disabled', 'the Stripe adapter is disabled; cancelling here alone would leave Stripe billing the subscriber');
++ if (!sub.provider_ref) fail(409, 'billing.provider_ref_missing', 'this Stripe subscription has no Stripe id to cancel');
++ try { await stripe.cancelAtPeriodEnd(sub.provider_ref); }
++ catch (e) { fail(502, 'billing.provider_error', `Stripe refused the cancellation: ${e.message}`); }
++ providerSync = 'stripe_cancel_at_period_end';
++ }
++ db.transaction(() => {
++ db.prepare('UPDATE subscriptions SET cancel_at_period_end = 1, auto_renew = 0, updated_at = ? WHERE id = ?').run(iso(ctx.now()), id);
++ enqueue(ctx, {
++ event_type: 'billing.subscription.canceled', subject: { type: 'subscription', id },
++ payload: { subscription: present(find(db, id)), provider_sync: providerSync, actor },
++ });
++ })();
++ return { subscription: find(db, id), provider_sync: providerSync, replay: false };
++}
++
++function setStatus(ctx, id, status, reason) {
++ const { db } = ctx;
++ const sub = find(db, id);
++ if (!sub || sub.status === status) return sub;
++ db.prepare('UPDATE subscriptions SET status = ?, auto_renew = CASE WHEN ? = \'active\' THEN auto_renew ELSE 0 END, updated_at = ? WHERE id = ?')
++ .run(status, status, iso(ctx.now()), id);
++ enqueue(ctx, {
++ event_type: 'billing.entitlement.changed', subject: { type: 'entitlement', id: `${sub.subscriber}:${KIND}:${sub.streamer}` },
++ payload: { ...entitlement(db, sub.subscriber, sub.streamer, ctx.now()), reason: reason || status },
++ });
++ return find(db, id);
++}
++
++/** Revoke the periods a reversed payment granted. Returns the number of periods revoked. */
++function revokeForTxn(ctx, txnId, reason) {
++ const { db } = ctx;
++ const ents = db.prepare('SELECT * FROM entitlements WHERE source_txn = ? AND revoked_at IS NULL').all(txnId);
++ if (!ents.length) return 0;
++ const at = iso(ctx.now());
++ for (const e of ents) {
++ db.prepare('UPDATE entitlements SET revoked_at = ?, revoked_reason = ? WHERE id = ?').run(at, reason, e.id);
++ const sub = find(db, e.subscription_id);
++ if (sub) {
++ const left = db.prepare('SELECT MAX(ends_at) AS e FROM entitlements WHERE subscription_id = ? AND revoked_at IS NULL').get(sub.id).e;
++ const stillActive = left && Date.parse(left) > ctx.now();
++ db.prepare('UPDATE subscriptions SET current_period_end = ?, status = ?, auto_renew = CASE WHEN ? THEN auto_renew ELSE 0 END, updated_at = ? WHERE id = ?')
++ .run(left || at, stillActive ? sub.status : 'expired', stillActive ? 1 : 0, at, sub.id);
++ }
++ enqueue(ctx, {
++ event_type: 'billing.entitlement.changed', subject: { type: 'entitlement', id: `${e.subject}:${KIND}:${e.scope}` },
++ payload: { ...entitlement(db, e.subject, e.scope, ctx.now()), reason, revoked_period: { starts_at: e.starts_at, ends_at: e.ends_at } },
++ });
++ }
++ return ents.length;
++}
++
++/**
++ * Renewal sweep (hourly job). For every active subscription whose period ended:
++ * - Stripe: Stripe renews itself (invoice.paid); expire only after the grace window;
++ * - cancel-at-period-end / no auto-renew: end it;
++ * - otherwise renew from the subscriber's credit (deterministic key per period), or end it
++ * when the credit does not cover the price.
++ */
++function sweep(ctx, { limit = 200 } = {}) {
++ const { db, rates } = ctx;
++ const out = { renewed: [], expired: [], canceled: [], skipped: 0 };
++ const now = ctx.now();
++ const due = db.prepare("SELECT * FROM subscriptions WHERE status = 'active' AND current_period_end IS NOT NULL AND current_period_end <= ? ORDER BY current_period_end LIMIT ?")
++ .all(iso(now), limit).map(parse);
++ for (const sub of due) {
++ const end = Date.parse(sub.current_period_end);
++ if (sub.provider === 'stripe') {
++ if (now - end < rates.stripeGraceDays * 86_400_000) { out.skipped++; continue; }
++ setStatus(ctx, sub.id, 'expired', 'stripe_not_renewed'); out.expired.push(sub.id); continue;
++ }
++ if (sub.cancel_at_period_end || !sub.auto_renew) {
++ const st = sub.cancel_at_period_end ? 'canceled' : 'expired';
++ setStatus(ctx, sub.id, st, st); (st === 'canceled' ? out.canceled : out.expired).push(sub.id); continue;
++ }
++ try {
++ pay(ctx, {
++ subscriber: sub.subscriber, streamer: sub.streamer, source: 'credit', priceCents: sub.price_cents || rates.subPriceCents,
++ autoRenew: true, renewal: true, idempotencyKey: `renew:${sub.id}:${sub.current_period_end}`,
++ actor: { principal: 'svc:billing', job: 'renewal-sweep' },
++ });
++ out.renewed.push(sub.id);
++ } catch (e) {
++ if (e.code !== 'billing.insufficient_funds') throw e;
++ setStatus(ctx, sub.id, 'expired', 'renewal_insufficient_credit'); out.expired.push(sub.id);
++ }
++ }
++ return out;
++}
++
++function list(db, { subscriber, streamer, status } = {}) {
++ const where = [];
++ const args = [];
++ if (subscriber) { where.push('subscriber = ?'); args.push(subscriber); }
++ if (streamer) { where.push('streamer = ?'); args.push(streamer); }
++ if (status) { where.push('status = ?'); args.push(status); }
++ return db.prepare(`SELECT * FROM subscriptions ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC LIMIT 500`).all(...args).map(parse);
++}
++
++module.exports = { pay, cancel, sweep, entitlement, activeEntitlements, revokeForTxn, setStatus, find, findPair, findByProviderRef, list, present, KIND };
ddiff --git a/server/ops/transfers.js b/server/ops/transfers.js
nnew file mode 100644
iindex 0000000..b231c3d
---- /dev/null
++++ b/server/ops/transfers.js
@@@ -0,0 +1,109 @@
++'use strict';
++
++/**
++ * Transfers: the only way CREDIT becomes MONEY (ADR-012 rule 3) — a tip, donation or paid
++ * interaction moves the giver's spendable credit into the recipient's creator payable.
++ *
++ * create() user_credit:<from> → creator_payable:<to> (self-dealing refused by subject)
++ * refund() creator_payable:<to> → user_credit:<from> (e.g. a media request that never
++ * played; refused when the recipient no longer holds the amount)
++ * fromReceipt() a site-routed PowerChat tip: money received for a creator, straight into
++ * their payable (MONEY); the tipper may be unknown.
++ */
++const { validate } = require('openvibe-contracts');
++const { post, getTxn, requireFunds } = require('../ledger');
++const { enqueue } = require('../outbox');
++const { A, entry, receiptEntries, fail, positiveInt, text } = require('./common');
++const { summary } = require('./purchases');
++
++const KINDS = ['tip', 'donation', 'paid_interaction'];
++
++function create(ctx, input) {
++ const { db, rates } = ctx;
++ const amount = positiveInt(input.amount, 'amount', rates.maxBits);
++ const from = input.from;
++ const to = input.to;
++ if (from === to) fail(422, 'billing.self_dealing', 'a transfer to yourself would turn bought credit into withdrawable money');
++ const kind = input.kind || 'donation';
++ if (!KINDS.includes(kind)) fail(422, 'billing.invalid_input', `kind must be one of ${KINDS.join(', ')}`);
++ let target = null;
++ if (input.target != null) {
++ const v = validate('common.entity-ref@1', input.target);
++ if (!v.valid) fail(422, 'billing.invalid_input', 'target must be an EntityRef ({ service, type, id })');
++ target = input.target;
++ }
++ const message = text(input.message, 'message', 500);
++ return db.transaction(() => {
++ const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: getTxn(db, existing.id), replay: true };
++ requireFunds(db, A.credit(from), amount, 'credit');
++ const { txn } = post(ctx, {
++ type: 'donation',
++ idempotencyKey: input.idempotencyKey,
++ entries: [entry(A.credit(from), -amount), entry(A.payable(to), amount)],
++ actor: input.actor,
++ fromSubject: from,
++ toSubject: to,
++ metadata: { kind, amount_bits: amount, target, message, rates: rates.snapshot() },
++ });
++ enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn), traceId: input.traceId });
++ return { txn, replay: false };
++ })();
++}
++
++function refund(ctx, input) {
++ const { db } = ctx;
++ return db.transaction(() => {
++ const existing = db.prepare('SELECT id FROM transactions WHERE idempotency_key = ?').get(input.idempotencyKey);
++ if (existing) return { txn: getTxn(db, existing.id), replay: true };
++ const orig = getTxn(db, input.txnId);
++ if (!orig || orig.type !== 'donation') fail(404, 'billing.transaction_not_found', `no donation ${input.txnId}`);
++ if (!orig.from_subject || orig.receipt_ref) fail(422, 'billing.not_refundable', 'only credit-funded transfers are refunded this way; provider receipts are reversed by their provider events');
++ const done = db.prepare(`SELECT COALESCE(SUM(json_extract(metadata, '$.amount_bits')), 0) AS n FROM transactions WHERE reverses_txn = ? AND type = 'refund'`).get(orig.id).n;
++ const remaining = orig.metadata.amount_bits - done;
++ const amount = input.amount != null ? positiveInt(input.amount, 'amount') : remaining;
++ if (amount > remaining) fail(409, 'billing.already_reversed', `only ${remaining} bits of ${orig.id} remain refundable`);
++ requireFunds(db, A.payable(orig.to_subject), amount, "recipient's payable");
++ const { txn } = post(ctx, {
++ type: 'refund',
++ idempotencyKey: input.idempotencyKey,
++ reversesTxn: orig.id,
++ entries: [entry(A.payable(orig.to_subject), -amount), entry(A.credit(orig.from_subject), amount)],
++ actor: input.actor,
++ fromSubject: orig.to_subject,
++ toSubject: orig.from_subject,
++ test: orig.test,
++ metadata: { amount_bits: amount, reason: text(input.reason, 'reason', 300) },
++ });
++ enqueue(ctx, { event_type: 'billing.transaction.reversed', subject: { type: 'transaction', id: txn.id }, payload: { ...summary(txn), reverses_txn: orig.id } });
++ return { txn, replay: false };
++ })();
++}
++
++function fromReceipt(ctx, input) {
++ const { db, rates } = ctx;
++ return db.transaction(() => {
++ const dup = db.prepare('SELECT id FROM transactions WHERE receipt_ref = ?').get(input.receiptRef);
++ if (dup) return { txn: getTxn(db, dup.id), replay: true, duplicateReceipt: true };
++ const paidCents = positiveInt(input.paidCents, 'amount_cents');
++ if (input.from && input.from === input.to) fail(422, 'billing.self_dealing', 'a creator cannot route a tip to themselves');
++ const bits = rates.bitsForValueCents(paidCents);
++ const { txn } = post(ctx, {
++ type: 'donation',
++ idempotencyKey: input.idempotencyKey,
++ entries: receiptEntries(rates, { provider: input.provider, paidCents, bits, target: A.payable(input.to) }),
++ test: !!input.test,
++ actor: input.actor,
++ fromSubject: input.from || null,
++ toSubject: input.to,
++ provider: input.provider,
++ receiptRef: input.receiptRef,
++ sourceEventId: input.sourceEventId,
++ metadata: { kind: 'tip', route: 'site', paid_cents: paidCents, amount_bits: bits, rates: rates.snapshot(), ...(input.metadata || {}) },
++ });
++ enqueue(ctx, { event_type: 'billing.transaction.settled', subject: { type: 'transaction', id: txn.id }, payload: summary(txn) });
++ return { txn, replay: false };
++ })();
++}
++
++module.exports = { create, refund, fromReceipt };
ddiff --git a/server/outbox.js b/server/outbox.js
nnew file mode 100644
iindex 0000000..ae711e6
---- /dev/null
++++ b/server/outbox.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * Transactional outbox (events.event-envelope@1). enqueue() is called inside the same SQLite
++ * transaction as the money movement it announces, so an event exists exactly when its effect
++ * does. The relay publishes unsent rows to OpenVibe.Events (POST /api/v1/events, service token
++ * for audience openvibe.events with events.event.publish) and runs only when EVENTS_URL is set;
++ * without it rows accumulate and can be relayed later. Events deduplicates on event_id, so a
++ * relay retry after a lost response never publishes twice.
++ */
++const { ids, validate, serviceAuth } = require('openvibe-contracts');
++
++const ACTOR = { type: 'service', id: 'billing' };
++
++function enqueue(ctx, { event_type, subject, payload, priority = 'important', traceId }) {
++ const ms = ctx.now();
++ const env = {
++ event_id: ids.newId('event', ms),
++ event_type,
++ version: 1,
++ source: 'billing',
++ actor: ACTOR,
++ timestamp: new Date(ms).toISOString(),
++ priority,
++ visibility: 'internal',
++ subject,
++ payload: payload || {},
++ };
++ if (traceId && /^[0-9a-f]{32}$/.test(traceId)) env.trace_id = traceId;
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) throw new Error(`outbox: invalid envelope for ${event_type}: ${v.errors.map((e) => `${e.path} ${e.message}`).join('; ')}`);
++ ctx.db.prepare('INSERT INTO outbox (event_id, event, created_at) VALUES (?, ?, ?)').run(env.event_id, JSON.stringify(env), env.timestamp);
++ return env;
++}
++
++/** Relay loop. Returns { stop, flush }. */
++function createRelay({ db, config, fetchImpl = globalThis.fetch, tokenClient, log = console }) {
++ const tokens = tokenClient || serviceAuth.createTokenClient({
++ tokenUrl: `${config.network.internalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.events',
++ scope: 'events.event.publish',
++ fetchImpl,
++ });
++ let timer = null;
++ let busy = false;
++
++ async function flush() {
++ if (busy) return { sent: 0, busy: true };
++ busy = true;
++ let sent = 0;
++ try {
++ for (;;) {
++ const rows = db.prepare('SELECT seq, event_id, event FROM outbox WHERE sent_at IS NULL ORDER BY seq LIMIT 100').all();
++ if (!rows.length) break;
++ const body = { events: rows.map((r) => JSON.parse(r.event)) };
++ let res;
++ try {
++ res = await fetchImpl(`${config.events.url}/api/v1/events`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(10000),
++ });
++ } catch (e) {
++ markFailed(rows, e.message);
++ break;
++ }
++ if (res.status === 401) tokens.invalidate();
++ if (!res.ok) {
++ const text = await res.text().catch(() => '');
++ markFailed(rows, `${res.status} ${text.slice(0, 300)}`);
++ break;
++ }
++ const at = new Date().toISOString();
++ const mark = db.prepare('UPDATE outbox SET sent_at = ?, attempts = attempts + 1, last_error = NULL WHERE seq = ?');
++ db.transaction(() => { for (const r of rows) mark.run(at, r.seq); })();
++ sent += rows.length;
++ }
++ } finally {
++ busy = false;
++ }
++ return { sent };
++ }
++
++ function markFailed(rows, message) {
++ const mark = db.prepare('UPDATE outbox SET attempts = attempts + 1, last_error = ? WHERE seq = ?');
++ db.transaction(() => { for (const r of rows) mark.run(String(message).slice(0, 500), r.seq); })();
++ log.warn(`[Billing] outbox relay: ${rows.length} event(s) not published: ${message}`);
++ }
++
++ function start() {
++ if (timer || !config.events.url) return;
++ timer = setInterval(() => { flush().catch((e) => log.warn('[Billing] outbox relay:', e.message)); }, config.events.intervalMs);
++ if (timer.unref) timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { start, stop, flush };
++}
++
++module.exports = { enqueue, createRelay };
ddiff --git a/server/providers/ccbill.js b/server/providers/ccbill.js
nnew file mode 100644
iindex 0000000..78b0357
---- /dev/null
++++ b/server/providers/ccbill.js
@@@ -0,0 +1,83 @@
++'use strict';
++
++/**
++ * CCBill adapter (DISABLED unless CCBILL_WEBHOOK_SECRET is set). Ported from Live
++ * server/monetization/payments.js: FlexForms hosted payment + webhook authenticated by a shared
++ * secret in the query string (compared in constant time here).
++ *
++ * NewSaleSuccess purchase or one-period subscription (X-intent / X-order passthrough), receipt
++ * ccbill:<transactionId>. The price CCBill reports is required: the intent id rides
++ * in the buyer-editable form URL, the form digest covers only the price.
++ * Refund, Void refund of the sale; Chargeback chargeback of the sale.
++ */
++const crypto = require('crypto');
++const intents = require('../ops/intents');
++
++function createCcbill(cfg) {
++ const enabled = !!cfg.webhookSecret;
++
++ function verify(req) {
++ const given = Buffer.from(String(req.query.secret || ''));
++ const expected = Buffer.from(cfg.webhookSecret || '');
++ if (!expected.length) return { ok: false, reason: 'disabled' };
++ return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { ok: true } : { ok: false, reason: 'bad secret' };
++ }
++
++ function parse(req) {
++ const raw = req.rawBody.toString('utf8');
++ let body = {};
++ if (raw.trim().startsWith('{')) { try { body = JSON.parse(raw); } catch { body = {}; } }
++ else body = Object.fromEntries(new URLSearchParams(raw));
++ const { secret, ...query } = req.query; // never store the secret
++ const p = { ...query, ...body };
++ const type = String(p.eventType || p.transactionType || 'unknown');
++ const txId = p.transactionId || p.subscriptionId;
++ const eventId = txId ? `${type}:${txId}` : crypto.createHash('sha256').update(JSON.stringify(p)).digest('hex');
++ return { eventId, type, payload: p };
++ }
++
++ async function interpret(ctx, p, row) {
++ const key = `pe:ccbill:${row.provider_event_id}`;
++ const actor = { principal: 'provider:ccbill', provider_event: row.provider_event_id };
++ const type = String(p.eventType || p.transactionType || '');
++ const txId = p.transactionId || p.subscriptionId;
++ const price = [p.billedInitialPrice, p.subscriptionInitialPrice, p.accountingInitialPrice, p.initialPrice, p.accountingAmount, p.amount]
++ .find((v) => v != null && v !== '' && Number.isFinite(Number(v)));
++ if (/^(Refund|Void|Chargeback)$/i.test(type)) {
++ if (!txId) return { effect: 'none', reason: `${type} without a transaction id` };
++ return {
++ effect: 'reverse',
++ args: {
++ kind: /chargeback/i.test(type) ? 'chargeback' : 'refund', provider: 'ccbill', originalReceiptRef: `ccbill:${txId}`,
++ reversalRef: `ccbill:${type.toLowerCase()}:${txId}`, cents: price != null ? Math.round(Number(price) * 100) : undefined,
++ idempotencyKey: key, actor, reason: type,
++ },
++ };
++ }
++ if (type !== 'NewSaleSuccess') return { effect: 'none', reason: `${type || 'event'} is not handled` };
++ const intent = intents.find(ctx.db, p['X-intent'] || p['X-order'] || p.order);
++ if (!intent) return { effect: 'none', reason: 'sale without a Billing intent' };
++ if (price == null) return { effect: 'none', reason: 'sale carried no price — not credited', review: true };
++ if (!txId) return { effect: 'none', reason: 'sale without a transaction id', review: true };
++ const receipt = { provider: 'ccbill', receiptRef: `ccbill:${txId}`, paidCents: Math.round(Number(price) * 100) };
++ if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
++ if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription sale — review', review: true };
++ return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
++ }
++
++ async function createCheckout(ctx, intent) {
++ const price = (intent.amount_cents / 100).toFixed(2);
++ const period = '2';
++ const currency = '840';
++ const digest = crypto.createHash('md5').update(`${price}${period}${currency}${cfg.salt}`).digest('hex');
++ const qs = new URLSearchParams({
++ clientAccnum: cfg.clientAccount, clientSubacc: cfg.subAccount, initialPrice: price, initialPeriod: period,
++ currencyCode: currency, formDigest: digest, 'X-intent': intent.id,
++ });
++ return { url: `https://api.ccbill.com/wap-frontflex/flexforms/${cfg.flexformId}?${qs}`, providerRef: null };
++ }
++
++ return { name: 'ccbill', enabled, verify, parse, interpret, createCheckout };
++}
++
++module.exports = { createCcbill };
ddiff --git a/server/providers/index.js b/server/providers/index.js
nnew file mode 100644
iindex 0000000..b049b8a
---- /dev/null
++++ b/server/providers/index.js
@@@ -0,0 +1,137 @@
++'use strict';
++
++/**
++ * Provider adapters and the receipt pipeline.
++ *
++ * receive() verify → store the delivery in provider_events BEFORE doing anything with it
++ * (UNIQUE(provider, provider_event_id): a redelivery is stored once)
++ * process() interpret the stored payload into a plan, then apply the plan and mark the event
++ * processed in ONE SQLite transaction. Plans settle through the same operations the
++ * API uses, keyed pe:<provider>:<event id>, and every settling transaction carries its
++ * provider receipt reference (unique) and source_event_id.
++ * While the economy is frozen events are stored but not processed; processPending() runs them in
++ * arrival order after the freeze lifts (and on a timer for events that failed transiently).
++ *
++ * Result effects: settled (exactly one transaction points at the event) | duplicate_receipt (the
++ * payment was already settled by another event) | updated (state change, no money) | none (no
++ * money effect: test, echo, EXTERNAL, not handled; `review` marks ones an operator should see) |
++ * rejected (a 4xx business refusal, e.g. a refund for an unknown payment).
++ */
++const crypto = require('crypto');
++const { iso, BillingError } = require('../ledger');
++const { isFrozen } = require('../ops/common');
++const purchases = require('../ops/purchases');
++const transfers = require('../ops/transfers');
++const subscriptions = require('../ops/subscriptions');
++const reversals = require('../ops/reversals');
++const { createPowerchat } = require('./powerchat');
++const { createStripe } = require('./stripe');
++const { createPaypal } = require('./paypal');
++const { createCcbill } = require('./ccbill');
++const { createNowpayments } = require('./nowpayments');
++
++function createAdapters(config, deps = {}) {
++ const p = config.providers;
++ return {
++ powerchat: createPowerchat(p.powerchat, deps),
++ stripe: createStripe(p.stripe, deps),
++ paypal: createPaypal(p.paypal, deps),
++ ccbill: createCcbill(p.ccbill, deps),
++ nowpayments: createNowpayments(p.nowpayments, deps),
++ };
++}
++
++function parseRow(row) { return row ? { ...row, result: row.result ? JSON.parse(row.result) : null } : null; }
++
++/** Store a verified delivery. Returns { row, duplicate }. */
++function store(ctx, provider, { eventId, type, payload }) {
++ const text = JSON.stringify(payload);
++ const hash = crypto.createHash('sha256').update(text).digest('hex');
++ const r = ctx.db.prepare(`INSERT OR IGNORE INTO provider_events (provider, provider_event_id, type, payload_hash, payload, received_at)
++ VALUES (?, ?, ?, ?, ?, ?)`).run(provider, eventId, type, hash, text, iso(ctx.now()));
++ const row = parseRow(ctx.db.prepare('SELECT * FROM provider_events WHERE provider = ? AND provider_event_id = ?').get(provider, eventId));
++ return { row, duplicate: r.changes === 0 };
++}
++
++function applyPlan(ctx, plan, row) {
++ const withEvent = (args) => ({ ...args, sourceEventId: row.id });
++ const settled = (r) => (r.replay ? { effect: 'duplicate_receipt', txn_id: r.txn && r.txn.id } : { effect: 'settled', txn_id: r.txn.id });
++ switch (plan.effect) {
++ case 'none': return { effect: 'none', reason: plan.reason, review: plan.review || plan.hold || undefined };
++ case 'update': plan.apply(ctx); return { effect: 'updated', reason: plan.reason };
++ case 'purchase': return settled(purchases.settle(ctx, withEvent(plan.args)));
++ case 'subscription': return settled(subscriptions.pay(ctx, withEvent(plan.args)));
++ case 'tip': return settled(transfers.fromReceipt(ctx, withEvent(plan.args)));
++ case 'reverse': {
++ const r = reversals.reverseReceipt(ctx, withEvent(plan.args));
++ if (r.noop) return { effect: 'none', reason: r.noop };
++ return { ...settled(r), review: r.review || undefined };
++ }
++ default: throw new Error(`unknown plan effect ${plan.effect}`);
++ }
++}
++
++/** Process one stored event. Returns the updated row (processed or not). */
++async function process(ctx, adapters, rowOrId) {
++ const { db } = ctx;
++ let row = typeof rowOrId === 'object' ? rowOrId : parseRow(db.prepare('SELECT * FROM provider_events WHERE id = ?').get(rowOrId));
++ if (!row || row.processed_at) return row;
++ if (isFrozen(db)) return row;
++ const adapter = adapters[row.provider];
++ try {
++ if (!adapter) throw new Error(`no adapter for ${row.provider}`);
++ const plan = await adapter.interpret(ctx, JSON.parse(row.payload), row);
++ db.transaction(() => {
++ const fresh = db.prepare('SELECT processed_at FROM provider_events WHERE id = ?').get(row.id);
++ if (fresh.processed_at) return;
++ let result;
++ try {
++ result = db.transaction(() => applyPlan(ctx, plan, row))();
++ } catch (e) {
++ if (!(e instanceof BillingError) || e.status >= 500) throw e;
++ result = { effect: 'rejected', code: e.code, reason: e.detail || e.message };
++ }
++ db.prepare('UPDATE provider_events SET processed_at = ?, result = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?')
++ .run(iso(ctx.now()), JSON.stringify(result), row.id);
++ })();
++ } catch (e) {
++ db.prepare('UPDATE provider_events SET attempts = attempts + 1, last_error = ? WHERE id = ?').run(String(e.message).slice(0, 500), row.id);
++ (ctx.log || console).warn(`[Billing] ${row.provider} event ${row.provider_event_id} not processed: ${e.message}`);
++ }
++ row = parseRow(db.prepare('SELECT * FROM provider_events WHERE id = ?').get(row.id));
++ return row;
++}
++
++/** Process every stored-but-unprocessed event in arrival order. */
++async function processPending(ctx, adapters, { limit = 500 } = {}) {
++ if (isFrozen(ctx.db)) return { processed: 0, frozen: true };
++ const rows = ctx.db.prepare('SELECT * FROM provider_events WHERE processed_at IS NULL ORDER BY id LIMIT ?').all(limit).map(parseRow);
++ let processed = 0;
++ for (const r of rows) {
++ const after = await process(ctx, adapters, r);
++ if (after && after.processed_at) processed++;
++ if (isFrozen(ctx.db)) break;
++ }
++ return { processed, pending: rows.length - processed };
++}
++
++/** Reprocess an event (operator): only an unprocessed or rejected one; settled events never re-apply. */
++async function reprocess(ctx, adapters, id) {
++ const row = parseRow(ctx.db.prepare('SELECT * FROM provider_events WHERE id = ?').get(id));
++ if (!row) throw new BillingError(404, 'billing.event_not_found', `no provider event ${id}`);
++ if (row.processed_at && row.result && row.result.effect !== 'rejected' && row.result.effect !== 'none') {
++ throw new BillingError(409, 'billing.event_settled', `event ${id} already had effect ${row.result.effect}`);
++ }
++ if (row.processed_at) ctx.db.prepare('UPDATE provider_events SET processed_at = NULL WHERE id = ?').run(id);
++ return process(ctx, adapters, id);
++}
++
++function present(row) {
++ if (!row) return null;
++ return {
++ id: row.id, provider: row.provider, provider_event_id: row.provider_event_id, type: row.type, payload_hash: row.payload_hash,
++ received_at: row.received_at, processed_at: row.processed_at, result: row.result, attempts: row.attempts, last_error: row.last_error,
++ };
++}
++
++module.exports = { createAdapters, store, process, processPending, reprocess, applyPlan, present, parseRow };
ddiff --git a/server/providers/nowpayments.js b/server/providers/nowpayments.js
nnew file mode 100644
iindex 0000000..6e0780f
---- /dev/null
++++ b/server/providers/nowpayments.js
@@@ -0,0 +1,74 @@
++'use strict';
++
++/**
++ * NOWPayments (crypto) adapter (DISABLED unless NOWPAYMENTS_IPN_SECRET is set). Ported from Live
++ * server/monetization/payments.js: IPN verified by HMAC-SHA512 of the key-sorted JSON body
++ * (X-NOWPayments-Sig).
++ *
++ * payment_status finished | confirmed | sending settle (order_id = intent id), receipt nowpayments:<payment_id>
++ * payment_status refunded refund of that payment
++ * A payment reports several statuses; the receipt reference settles it exactly once.
++ */
++const crypto = require('crypto');
++const intents = require('../ops/intents');
++
++function sortObject(o) {
++ if (Array.isArray(o)) return o.map(sortObject);
++ if (o && typeof o === 'object') return Object.keys(o).sort().reduce((acc, k) => { acc[k] = sortObject(o[k]); return acc; }, {});
++ return o;
++}
++
++function createNowpayments(cfg, { fetchImpl = globalThis.fetch } = {}) {
++ const enabled = !!cfg.ipnSecret;
++
++ function verify(req) {
++ const sig = String(req.headers['x-nowpayments-sig'] || '');
++ if (!cfg.ipnSecret || !sig) return { ok: false, reason: 'missing signature' };
++ let obj;
++ try { obj = JSON.parse(req.rawBody.toString('utf8')); } catch { return { ok: false, reason: 'not JSON' }; }
++ const expected = Buffer.from(crypto.createHmac('sha512', cfg.ipnSecret).update(JSON.stringify(sortObject(obj))).digest('hex'));
++ const given = Buffer.from(sig);
++ return given.length === expected.length && crypto.timingSafeEqual(given, expected) ? { ok: true } : { ok: false, reason: 'signature mismatch' };
++ }
++
++ function parse(req) {
++ const body = JSON.parse(req.rawBody.toString('utf8'));
++ const status = String(body.payment_status || 'unknown');
++ return { eventId: body.payment_id != null ? `${body.payment_id}:${status}` : null, type: status, payload: body };
++ }
++
++ async function interpret(ctx, b, row) {
++ const key = `pe:nowpayments:${row.provider_event_id}`;
++ const actor = { principal: 'provider:nowpayments', provider_event: row.provider_event_id };
++ const status = String(b.payment_status);
++ if (status === 'refunded') {
++ return { effect: 'reverse', args: { kind: 'refund', provider: 'nowpayments', originalReceiptRef: `nowpayments:${b.payment_id}`, reversalRef: `nowpayments:refund:${b.payment_id}`, idempotencyKey: key, actor, reason: 'refunded' } };
++ }
++ if (!['finished', 'confirmed', 'sending'].includes(status)) return { effect: 'none', reason: `payment ${status}` };
++ const intent = intents.find(ctx.db, b.order_id);
++ if (!intent) return { effect: 'none', reason: 'payment without a Billing intent' };
++ if (String(b.price_currency || 'usd').toLowerCase() !== 'usd' || b.price_amount == null) return { effect: 'none', reason: 'payment without a USD price', review: true };
++ const receipt = { provider: 'nowpayments', receiptRef: `nowpayments:${b.payment_id}`, paidCents: Math.round(Number(b.price_amount) * 100) };
++ if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
++ if (receipt.paidCents + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription — review', review: true };
++ return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
++ }
++
++ async function createCheckout(ctx, intent, { successUrl, cancelUrl, name, ipnUrl }) {
++ const res = await fetchImpl(`${cfg.apiBase}/invoice`, {
++ method: 'POST', headers: { 'x-api-key': cfg.apiKey, 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ price_amount: intent.amount_cents / 100, price_currency: 'usd', order_id: intent.id, order_description: name,
++ ipn_callback_url: ipnUrl, success_url: successUrl, cancel_url: cancelUrl,
++ }),
++ signal: AbortSignal.timeout(15000),
++ });
++ const j = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(`NOWPayments invoice → ${res.status}`);
++ return { url: j.invoice_url, providerRef: String(j.id || j.invoice_id || '') || null };
++ }
++
++ return { name: 'nowpayments', enabled, verify, parse, interpret, createCheckout };
++}
++
++module.exports = { createNowpayments, sortObject };
ddiff --git a/server/providers/paypal.js b/server/providers/paypal.js
nnew file mode 100644
iindex 0000000..513274b
---- /dev/null
++++ b/server/providers/paypal.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++/**
++ * PayPal adapter (DISABLED unless PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET and PAYPAL_WEBHOOK_ID are
++ * set). Ported from Live server/monetization/payments.js.
++ *
++ * verify PayPal's verify-webhook-signature API (as Live)
++ * PAYMENT.CAPTURE.COMPLETED purchase or one-period subscription (custom_id = intent id), receipt paypal:<capture id>
++ * PAYMENT.CAPTURE.REFUNDED refund of the capture (the refund's "up" link names the capture)
++ * PAYMENT.CAPTURE.REVERSED chargeback/reversal of the capture
++ * capture() captures an approved order after the buyer returns (Live's /paypal/return);
++ * settlement still comes from the webhook, or from the capture response,
++ * and the receipt reference makes either one settle exactly once.
++ */
++const intents = require('../ops/intents');
++
++const cents = (v) => Math.round(Number(v) * 100);
++
++function createPaypal(cfg, { fetchImpl = globalThis.fetch } = {}) {
++ const enabled = !!(cfg.clientId && cfg.clientSecret && cfg.webhookId);
++
++ async function json(url, opts) {
++ const res = await fetchImpl(url, { ...opts, signal: AbortSignal.timeout(15000) });
++ const body = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(`${opts.method || 'GET'} ${url} → ${res.status}: ${body.message || body.error_description || body.error || 'error'}`);
++ return body;
++ }
++ async function token() {
++ const auth = Buffer.from(`${cfg.clientId}:${cfg.clientSecret}`).toString('base64');
++ const j = await json(`${cfg.apiBase}/v1/oauth2/token`, { method: 'POST', headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=client_credentials' });
++ return j.access_token;
++ }
++
++ async function verify(req) {
++ if (!enabled) return { ok: false, reason: 'disabled' };
++ const h = req.headers;
++ try {
++ const j = await json(`${cfg.apiBase}/v1/notifications/verify-webhook-signature`, {
++ method: 'POST', headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ auth_algo: h['paypal-auth-algo'], cert_url: h['paypal-cert-url'], transmission_id: h['paypal-transmission-id'],
++ transmission_sig: h['paypal-transmission-sig'], transmission_time: h['paypal-transmission-time'],
++ webhook_id: cfg.webhookId, webhook_event: JSON.parse(req.rawBody.toString('utf8')),
++ }),
++ });
++ return j.verification_status === 'SUCCESS' ? { ok: true } : { ok: false, reason: 'verification failed' };
++ } catch (e) { return { ok: false, reason: e.message }; }
++ }
++
++ function parse(req) {
++ const event = JSON.parse(req.rawBody.toString('utf8'));
++ return { eventId: event.id ? String(event.id) : null, type: String(event.event_type || 'unknown'), payload: event };
++ }
++
++ function settlePlan(ctx, capture, key, actor) {
++ const intent = intents.find(ctx.db, capture.custom_id || (capture.supplementary_data && capture.supplementary_data.related_ids && capture.supplementary_data.related_ids.order_id));
++ if (!intent) return { effect: 'none', reason: 'capture without a Billing intent' };
++ const paid = capture.amount && capture.amount.currency_code === 'USD' ? cents(capture.amount.value) : null;
++ if (!paid) return { effect: 'none', reason: 'capture without a USD amount' };
++ const receipt = { provider: 'paypal', receiptRef: `paypal:${capture.id}`, paidCents: paid };
++ if (intent.kind === 'purchase') return { effect: 'purchase', args: { ...receipt, intentId: intent.id, idempotencyKey: key, actor } };
++ if (paid + 1 < intent.amount_cents) return { effect: 'none', reason: 'underpaid subscription capture — review', review: true };
++ return { effect: 'subscription', args: { subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, receipt, idempotencyKey: key, actor } };
++ }
++
++ async function interpret(ctx, event, row) {
++ const r = event.resource || {};
++ const key = `pe:paypal:${row.provider_event_id}`;
++ const actor = { principal: 'provider:paypal', provider_event: row.provider_event_id };
++ if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') return settlePlan(ctx, r, key, actor);
++ if (event.event_type === 'PAYMENT.CAPTURE.REFUNDED' || event.event_type === 'PAYMENT.CAPTURE.REVERSED') {
++ const up = (r.links || []).find((l) => l.rel === 'up');
++ const captureId = up ? String(up.href).split('/').filter(Boolean).pop() : r.capture_id;
++ if (!captureId) return { effect: 'none', reason: 'refund without its capture' };
++ return {
++ effect: 'reverse',
++ args: {
++ kind: event.event_type === 'PAYMENT.CAPTURE.REVERSED' ? 'chargeback' : 'refund', provider: 'paypal',
++ originalReceiptRef: `paypal:${captureId}`, reversalRef: `paypal:refund:${r.id}`,
++ cents: r.amount ? cents(r.amount.value) : undefined, idempotencyKey: key, actor, reason: event.event_type,
++ },
++ };
++ }
++ return { effect: 'none', reason: `${event.event_type} is not handled` };
++ }
++
++ async function createCheckout(ctx, intent, { successUrl, cancelUrl, name }) {
++ const j = await json(`${cfg.apiBase}/v2/checkout/orders`, {
++ method: 'POST', headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ intent: 'CAPTURE',
++ purchase_units: [{ custom_id: intent.id, description: String(name).slice(0, 127), amount: { currency_code: 'USD', value: (intent.amount_cents / 100).toFixed(2) } }],
++ application_context: { brand_name: 'OpenVibe', user_action: 'PAY_NOW', return_url: successUrl, cancel_url: cancelUrl },
++ }),
++ });
++ const approve = (j.links || []).find((l) => l.rel === 'approve');
++ return { url: approve ? approve.href : null, providerRef: j.id };
++ }
++
++ /** Capture an approved order; returns a settlement plan built from the capture. */
++ async function capture(ctx, intent) {
++ const j = await json(`${cfg.apiBase}/v2/checkout/orders/${encodeURIComponent(intent.provider_ref)}/capture`, {
++ method: 'POST', headers: { Authorization: `Bearer ${await token()}`, 'Content-Type': 'application/json' }, body: '{}',
++ });
++ const cap = j.purchase_units && j.purchase_units[0] && j.purchase_units[0].payments && j.purchase_units[0].payments.captures && j.purchase_units[0].payments.captures[0];
++ if (j.status !== 'COMPLETED' || !cap) return { effect: 'none', reason: `order ${j.status}` };
++ return settlePlan(ctx, { ...cap, custom_id: cap.custom_id || intent.id }, `paypal-capture:${cap.id}`, { principal: 'provider:paypal', capture: cap.id });
++ }
++
++ return { name: 'paypal', enabled, verify, parse, interpret, createCheckout, capture };
++}
++
++module.exports = { createPaypal };
ddiff --git a/server/providers/powerchat.js b/server/providers/powerchat.js
nnew file mode 100644
iindex 0000000..6629ea9
---- /dev/null
++++ b/server/providers/powerchat.js
@@@ -0,0 +1,144 @@
++'use strict';
++
++/**
++ * PowerChat adapter — the one rail that carries money in production today.
++ * Ported from OpenVibe.Live server/integrations/powerchat-webhook.js + powerchat-checkout.js.
++ *
++ * Verification (unchanged from Live): HMAC-SHA256 over "<timestamp>.<raw body>" in
++ * X-PowerChat-Signature ("sha256=<hex>"), X-PowerChat-Timestamp (unix ms) within ±15 minutes,
++ * timing-safe compare. The delivery id (X-PowerChat-Delivery-Id) is the provider event id, so an
++ * at-least-once redelivery is stored once; the donation's own eventId is the receipt reference,
++ * so even a redelivery under a new delivery id settles once.
++ *
++ * donation.completed, by the checkout ref we minted into app_ref (appExternalRef):
++ * pcorder:<intent> Vibes purchase on the site account → CREDIT for the buyer
++ * pcsub:<intent> subscription: route 'site' → MONEY (share to the creator, fee +
++ * rest platform revenue); route 'direct' → EXTERNAL (entitlement
++ * only, the streamer holds the money). Underpaid: site → a plain
++ * site-routed tip to the streamer; direct → EXTERNAL tip, no effect.
++ * pcdon:<streamer>:<donor> site-routed tip for a creator → MONEY into their payable
++ * (legacy links carry Live user ids; they are resolved to subjects)
++ * anything else a tip on a streamer's own PowerChat → EXTERNAL, no Billing effect
++ * (recorded as an interaction by OpenVibe.Tips).
++ * Test deliveries (isTest, or source manual_test) move no money and are ignored unless
++ * POWERCHAT_ALLOW_TEST_FULFILLMENT is on, in which case they settle flagged test. App-sourced
++ * echoes of our own forwarded tips (source developer_app) are ignored.
++ *
++ * Refund/dispute events: PowerChat's documented webhook set (as used by Live) has none today.
++ * donation.refunded → refund and donation.disputed | donation.chargeback → chargeback are
++ * handled against the original donation's eventId (data.originalEventId | donationEventId |
++ * donationId) so they work the day PowerChat sends them; the names are an assumption to confirm.
++ */
++const crypto = require('crypto');
++const intents = require('../ops/intents');
++
++const REFUND_TYPES = { 'donation.refunded': 'refund', 'donation.disputed': 'chargeback', 'donation.chargeback': 'chargeback', 'donation.charged_back': 'chargeback' };
++
++function createPowerchat(cfg, { network } = {}) {
++ const enabled = !!cfg.webhookSecret;
++
++ function verify(req) {
++ const secret = cfg.webhookSecret;
++ if (!secret) return { ok: false, reason: 'webhook secret not configured' };
++ const sig = String(req.headers['x-powerchat-signature'] || '');
++ const ts = String(req.headers['x-powerchat-timestamp'] || '');
++ if (!sig || !ts) return { ok: false, reason: 'missing signature/timestamp headers' };
++ const t = Number(ts);
++ if (!Number.isFinite(t) || Math.abs(Date.now() - t) > cfg.maxSkewMs) return { ok: false, reason: 'timestamp outside allowed window' };
++ const expected = Buffer.from('sha256=' + crypto.createHmac('sha256', secret).update(ts + '.').update(req.rawBody).digest('hex'));
++ const given = Buffer.from(sig);
++ if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return { ok: false, reason: 'signature mismatch' };
++ return { ok: true };
++ }
++
++ function parse(req) {
++ const body = JSON.parse(req.rawBody.toString('utf8'));
++ const data = body.data || {};
++ const type = String(req.headers['x-powerchat-event-type'] || body.type || 'unknown');
++ const eventId = req.headers['x-powerchat-delivery-id'] || body.id || (data.eventId ? `${type}:${data.eventId}` : null);
++ return { eventId: eventId ? String(eventId) : null, type, payload: body };
++ }
++
++ async function resolveUser(ref) {
++ if (!ref || ref === '0') return null;
++ if (/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(ref)) return ref;
++ if (/^\d+$/.test(ref) && network) {
++ const map = await network.resolveLiveUsers([ref]);
++ return map.get(String(ref)) || null;
++ }
++ return null;
++ }
++
++ async function interpret(ctx, envelope, row) {
++ const type = envelope.type || row.type;
++ const data = envelope.data || {};
++ const actor = { principal: 'provider:powerchat', provider_event: row.provider_event_id };
++ const key = `pe:powerchat:${row.provider_event_id}`;
++ if (data.source === 'developer_app') return { effect: 'none', reason: 'echo of our own forwarded event (source developer_app)' };
++ const isTest = !!data.isTest || data.source === 'manual_test';
++ if (isTest && !cfg.allowTest) return { effect: 'none', reason: 'test delivery — no money moved (POWERCHAT_ALLOW_TEST_FULFILLMENT is off)' };
++ const cents = Math.max(0, Math.round(Number(data.amountUsdCents != null ? data.amountUsdCents : data.amountCents) || 0));
++
++ if (REFUND_TYPES[type]) {
++ const orig = data.originalEventId || data.donationEventId || data.donationId;
++ if (!orig) return { effect: 'none', reason: `${type} without a reference to the original donation` };
++ return {
++ effect: 'reverse',
++ args: {
++ kind: REFUND_TYPES[type], provider: 'powerchat', originalReceiptRef: `powerchat:${orig}`,
++ reversalRef: `powerchat:${type}:${data.eventId || row.provider_event_id}`,
++ cents: data.refundedUsdCents != null ? Number(data.refundedUsdCents) : (cents || undefined),
++ idempotencyKey: key, actor, reason: type,
++ },
++ };
++ }
++ if (type !== 'donation.completed') return { effect: 'none', reason: `${type} carries no money` };
++
++ const paymentId = data.eventId || data.donationId || row.provider_event_id;
++ const receiptRef = `powerchat:${paymentId}`;
++ const ref = String(data.appExternalRef || '');
++ const meta = { powerchat_event: paymentId, donor_name: data.donorName ? String(data.donorName).slice(0, 80) : null, app_ref: ref || null };
++ let m;
++ if ((m = ref.match(/^pcorder:(.+)$/))) {
++ const intent = intents.find(ctx.db, m[1]);
++ if (!intent || intent.kind !== 'purchase') return { effect: 'none', reason: `purchase checkout ${ref} has no purchase intent` };
++ if (cents < 1) return { effect: 'none', reason: 'zero-amount purchase' };
++ return { effect: 'purchase', args: { provider: 'powerchat', receiptRef, intentId: intent.id, paidCents: cents, test: isTest, idempotencyKey: key, actor, metadata: meta } };
++ }
++ if ((m = ref.match(/^pcsub:(.+)$/))) {
++ const intent = intents.find(ctx.db, m[1]);
++ if (!intent || intent.kind !== 'subscription' || !intent.streamer_subject) return { effect: 'none', reason: `subscription checkout ${ref} has no subscription intent` };
++ const route = intent.route === 'direct' ? 'direct' : 'site';
++ if (cents + 1 < intent.amount_cents) {
++ if (route === 'direct') return { effect: 'none', reason: 'underpaid direct subscription: an EXTERNAL tip to the streamer' };
++ return { effect: 'tip', args: { provider: 'powerchat', receiptRef, paidCents: cents, from: intent.subject, to: intent.streamer_subject, test: isTest, idempotencyKey: key, actor, metadata: { ...meta, underpaid_subscription_intent: intent.id } } };
++ }
++ return {
++ effect: 'subscription',
++ args: {
++ subscriber: intent.subject, streamer: intent.streamer_subject, source: 'receipt', intentId: intent.id, autoRenew: intent.auto_renew,
++ receipt: { provider: 'powerchat', receiptRef, paidCents: cents, feeCents: intent.fee_cents, route, test: isTest },
++ idempotencyKey: key, actor,
++ },
++ };
++ }
++ if ((m = ref.match(/^pcdon:([^:]+):([^:]*)$/))) {
++ const to = await resolveUser(m[1]);
++ if (!to) return { effect: 'none', reason: `site-routed tip for unknown streamer ${m[1]} — held for review`, hold: true };
++ const from = await resolveUser(m[2]);
++ if (cents < 1) return { effect: 'none', reason: 'zero-amount tip' };
++ return { effect: 'tip', args: { provider: 'powerchat', receiptRef, paidCents: cents, from, to, test: isTest, idempotencyKey: key, actor, metadata: meta } };
++ }
++ const host = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
++ if (cfg.siteUsername && host === cfg.siteUsername) {
++ // Money on the SITE account without one of our refs: Live ignored these. They are
++ // recorded here and listed by reconciliation instead of vanishing.
++ return { effect: 'none', reason: 'unattributed tip to the site PowerChat account — review', review: true };
++ }
++ return { effect: 'none', reason: 'EXTERNAL: a tip on the streamer\'s own PowerChat — no Billing liability' };
++ }
++
++ return { name: 'powerchat', enabled, verify, parse, interpret };
++}
++
++module.exports = { createPowerchat };
ddiff --git a/server/providers/stripe.js b/server/providers/stripe.js
nnew file mode 100644
iindex 0000000..e75eb53
---- /dev/null
++++ b/server/providers/stripe.js
@@@ -0,0 +1,153 @@
++'use strict';
++
++/**
++ * Stripe adapter (DISABLED unless STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET are set; card rails
++ * are off in production). Ported from Live server/monetization/payments.js, with what Live lacked:
++ *
++ * checkout.session.completed mode=payment → purchase settle (receipt stripe:<payment_intent>);
++ * mode=subscription → links the Stripe subscription to the intent
++ * invoice.paid every paid period (first and renewals) settles a subscription
++ * payment and credits the creator's share (Live only moved the date)
++ * charge.refunded refund of the purchase/invoice (cumulative amount_refunded)
++ * charge.dispute.funds_withdrawn chargeback
++ * customer.subscription.deleted subscription ended at Stripe
++ * cancelAtPeriodEnd() POST /subscriptions/:id cancel_at_period_end=true (Live never told Stripe)
++ */
++const crypto = require('crypto');
++const intents = require('../ops/intents');
++const subscriptions = require('../ops/subscriptions');
++
++function createStripe(cfg, { fetchImpl = globalThis.fetch } = {}) {
++ const enabled = !!(cfg.secretKey && cfg.webhookSecret);
++
++ async function api(method, path, params) {
++ const res = await fetchImpl(`${cfg.apiBase}${path}`, {
++ method,
++ headers: { Authorization: `Bearer ${cfg.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' },
++ body: params ? params.toString() : undefined,
++ signal: AbortSignal.timeout(15000),
++ });
++ const json = await res.json().catch(() => ({}));
++ if (!res.ok) throw new Error(`${method} ${path} → ${res.status}: ${(json.error && json.error.message) || 'error'}`);
++ return json;
++ }
++
++ function verify(req) {
++ const header = String(req.headers['stripe-signature'] || '');
++ if (!cfg.webhookSecret || !header) return { ok: false, reason: 'missing signature' };
++ const parts = header.split(',').map((kv) => kv.split('='));
++ const t = (parts.find(([k]) => k === 't') || [])[1];
++ const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
++ if (!t || !sigs.length) return { ok: false, reason: 'malformed signature header' };
++ if (Math.abs(Date.now() / 1000 - Number(t)) > cfg.toleranceSec) return { ok: false, reason: 'timestamp outside tolerance' };
++ const expected = Buffer.from(crypto.createHmac('sha256', cfg.webhookSecret).update(`${t}.`).update(req.rawBody).digest('hex'));
++ const ok = sigs.some((s) => { const b = Buffer.from(s); return b.length === expected.length && crypto.timingSafeEqual(b, expected); });
++ return ok ? { ok: true } : { ok: false, reason: 'signature mismatch' };
++ }
++
++ function parse(req) {
++ const event = JSON.parse(req.rawBody.toString('utf8'));
++ return { eventId: event.id ? String(event.id) : null, type: String(event.type || 'unknown'), payload: event };
++ }
++
++ function intentFrom(db, meta) {
++ if (!meta) return null;
++ return intents.find(db, meta.intent_id || meta.order_id);
++ }
++
++ async function interpret(ctx, event, row) {
++ const obj = (event.data && event.data.object) || {};
++ const key = `pe:stripe:${row.provider_event_id}`;
++ const actor = { principal: 'provider:stripe', provider_event: row.provider_event_id };
++ switch (event.type) {
++ case 'checkout.session.completed': {
++ const intent = intentFrom(ctx.db, obj.metadata) || intents.find(ctx.db, obj.client_reference_id);
++ if (!intent) return { effect: 'none', reason: 'checkout without a Billing intent' };
++ if (obj.mode === 'subscription') {
++ return { effect: 'update', reason: 'linked Stripe subscription', apply: (c) => intents.mergeMetadata(c, intent.id, { stripe_subscription: obj.subscription }) };
++ }
++ if (obj.payment_status && obj.payment_status !== 'paid') return { effect: 'none', reason: `checkout ${obj.payment_status}` };
++ return { effect: 'purchase', args: { provider: 'stripe', receiptRef: `stripe:${obj.payment_intent || obj.id}`, intentId: intent.id, paidCents: obj.amount_total, idempotencyKey: key, actor } };
++ }
++ case 'invoice.paid':
++ case 'invoice.payment_succeeded': {
++ if (!(obj.amount_paid > 0)) return { effect: 'none', reason: 'zero-amount invoice' };
++ const stripeSub = obj.subscription;
++ const existing = subscriptions.findByProviderRef(ctx.db, 'stripe', stripeSub);
++ let subscriber; let streamer; let intentId = null;
++ if (existing) { subscriber = existing.subscriber; streamer = existing.streamer; }
++ else {
++ const meta = (obj.subscription_details && obj.subscription_details.metadata) || obj.metadata || {};
++ let intent = intentFrom(ctx.db, meta);
++ if (!intent && stripeSub) intent = intents.parse(ctx.db.prepare("SELECT * FROM payment_intents WHERE provider = 'stripe' AND json_extract(metadata, '$.stripe_subscription') = ?").get(stripeSub));
++ if (!intent) return { effect: 'none', reason: `invoice for unknown Stripe subscription ${stripeSub}` };
++ subscriber = intent.subject; streamer = intent.streamer_subject; intentId = intent.id;
++ }
++ const line = obj.lines && obj.lines.data && obj.lines.data[0];
++ const periodEnd = line && line.period && line.period.end ? new Date(line.period.end * 1000).toISOString() : null;
++ return {
++ effect: 'subscription',
++ args: {
++ subscriber, streamer, source: 'receipt', intentId, autoRenew: true,
++ receipt: { provider: 'stripe', receiptRef: `stripe:${obj.id}`, paidCents: obj.amount_paid, feeCents: 0, providerRef: stripeSub, periodEnd },
++ idempotencyKey: key, actor,
++ },
++ };
++ }
++ case 'charge.refunded':
++ return {
++ effect: 'reverse',
++ args: {
++ kind: 'refund', provider: 'stripe', originalReceiptRef: `stripe:${obj.invoice || obj.payment_intent}`,
++ reversalRef: `stripe:refund:${obj.id}:${obj.amount_refunded}`, cumulativeCents: obj.amount_refunded,
++ idempotencyKey: key, actor, reason: 'charge.refunded',
++ },
++ };
++ case 'charge.dispute.funds_withdrawn':
++ return {
++ effect: 'reverse',
++ args: {
++ kind: 'chargeback', provider: 'stripe', originalReceiptRef: `stripe:${obj.payment_intent}`,
++ reversalRef: `stripe:dispute:${obj.id}`, cents: obj.amount, idempotencyKey: key, actor, reason: obj.reason || 'dispute',
++ },
++ };
++ case 'customer.subscription.deleted': {
++ const sub = subscriptions.findByProviderRef(ctx.db, 'stripe', obj.id);
++ if (!sub) return { effect: 'none', reason: 'unknown Stripe subscription' };
++ return { effect: 'update', reason: 'Stripe subscription ended', apply: (c) => subscriptions.setStatus(c, sub.id, 'canceled', 'stripe_deleted') };
++ }
++ default:
++ return { effect: 'none', reason: `${event.type} is not handled` };
++ }
++ }
++
++ async function createCheckout(ctx, intent, { successUrl, cancelUrl, name }) {
++ const p = new URLSearchParams();
++ p.set('success_url', successUrl);
++ p.set('cancel_url', cancelUrl);
++ p.set('client_reference_id', intent.id);
++ p.set('metadata[intent_id]', intent.id);
++ p.set('line_items[0][quantity]', '1');
++ p.set('line_items[0][price_data][currency]', 'usd');
++ p.set('line_items[0][price_data][unit_amount]', String(intent.amount_cents));
++ p.set('line_items[0][price_data][product_data][name]', name);
++ if (intent.kind === 'subscription') {
++ p.set('mode', 'subscription');
++ p.set('line_items[0][price_data][recurring][interval]', 'month');
++ p.set('subscription_data[metadata][intent_id]', intent.id);
++ } else {
++ p.set('mode', 'payment');
++ }
++ const session = await api('POST', '/checkout/sessions', p);
++ return { url: session.url, providerRef: session.id };
++ }
++
++ async function cancelAtPeriodEnd(stripeSubscriptionId) {
++ const p = new URLSearchParams({ cancel_at_period_end: 'true' });
++ return api('POST', `/subscriptions/${encodeURIComponent(stripeSubscriptionId)}`, p);
++ }
++
++ return { name: 'stripe', enabled, verify, parse, interpret, createCheckout, cancelAtPeriodEnd };
++}
++
++module.exports = { createStripe };
ddiff --git a/server/rates.js b/server/rates.js
nnew file mode 100644
iindex 0000000..a573cc0
---- /dev/null
++++ b/server/rates.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * Money arithmetic, from configured rates (config.rates). Every helper is integer-in,
++ * integer-out; nothing here rounds twice.
++ *
++ * value rate bitsPerUsd bits = $1 of creator value (Live: 100, so 1 bit = 1 cent)
++ * price tiers what a viewer pays per bit when buying (Live's volume discounts)
++ */
++function createRates(r) {
++ const tiers = [...r.priceTiers].sort((a, b) => b.min - a.min);
++
++ /** Creator/cashout value of `bits`, in USD cents. */
++ const valueCents = (bits) => Math.round((bits * 100) / r.bitsPerUsd);
++ /** Bits worth `cents` at the value rate (income converted into bits, e.g. a sub share). */
++ const bitsForValueCents = (cents) => Math.max(0, Math.round((cents * r.bitsPerUsd) / 100));
++
++ /** Purchase price (cents) of `bits` under the tiers. */
++ function priceCents(bits) {
++ const tier = tiers.find((t) => bits >= t.min) || tiers[tiers.length - 1];
++ return Math.round(bits * tier.usd_per_bit * 100);
++ }
++
++ /** The most bits `cents` buys under the tiers (0 when too little). */
++ function bitsForPriceCents(cents) {
++ let best = 0;
++ for (const t of tiers) {
++ const b = Math.floor((cents + 1e-9) / (t.usd_per_bit * 100));
++ if (b >= t.min && b > best && priceCents(b) <= cents) best = b;
++ }
++ return best;
++ }
++
++ /** Subscription split: the creator's share (bits) of a base price (cents). */
++ const subShareBits = (baseCents) => bitsForValueCents(Math.floor((baseCents * r.subSharePct) / 100));
++ const siteFeeCents = (baseCents) => Math.round((baseCents * r.siteRouteFeePct) / 100);
++
++ /** Snapshot recorded on each transaction. */
++ const snapshot = (extra = {}) => ({ bits_per_usd: r.bitsPerUsd, ...extra });
++
++ return { ...r, valueCents, bitsForValueCents, priceCents, bitsForPriceCents, subShareBits, siteFeeCents, snapshot };
++}
++
++module.exports = { createRates };
ddiff --git a/server/reconcile.js b/server/reconcile.js
nnew file mode 100644
iindex 0000000..95559c3
---- /dev/null
++++ b/server/reconcile.js
@@@ -0,0 +1,106 @@
++'use strict';
++
++/**
++ * Reconciliation. Re-derives everything the journal promises and reports what does not hold.
++ *
++ * Checks (any failure makes ok=false):
++ * journal.zero_sum all entries sum to zero per currency
++ * journal.txn_balanced every transaction's entries sum to zero per currency
++ * balances.cache account_balances equals SUM(entries) for every account
++ * fx.mirror in every transaction, the usd-cents fx entry equals minus the value of
++ * the vibes-bits fx entry at the rate that transaction recorded
++ * events.settlement every processed provider event with effect 'settled' has exactly one
++ * transaction pointing at it, and every other event has none
++ * payouts.references every paid cashout has a payout reference and a settling transaction
++ * payouts.pending payouts_pending per creator equals their requested cashouts
++ * Warnings (reported, do not fail): negative user balances, unprocessed/rejected events, events and
++ * transactions flagged for review (chargebacks on donated credit, unattributed site tips), import
++ * holds. Totals exclude test transactions (ADR-012 rule 9).
++ */
++const { iso, prefixedId } = require('./ledger');
++
++function reconcile(ctx, { store = true } = {}) {
++ const { db } = ctx;
++ const started = ctx.now();
++ const checks = [];
++ const check = (id, ok, detail) => checks.push({ id, ok, ...(detail ? { detail } : {}) });
++
++ const sums = db.prepare('SELECT a.currency, SUM(e.amount) AS total, COUNT(*) AS n FROM ledger_entries e JOIN accounts a ON a.id = e.account_id GROUP BY a.currency').all();
++ check('journal.zero_sum', sums.every((s) => s.total === 0), { per_currency: sums.map((s) => ({ currency: s.currency, sum: s.total, entries: s.n })) });
++
++ const unbalanced = db.prepare(`SELECT e.txn_id, a.currency, SUM(e.amount) AS total FROM ledger_entries e JOIN accounts a ON a.id = e.account_id
++ GROUP BY e.txn_id, a.currency HAVING SUM(e.amount) <> 0 LIMIT 100`).all();
++ check('journal.txn_balanced', !unbalanced.length, unbalanced.length ? { offenders: unbalanced } : null);
++
++ const cache = db.prepare(`SELECT a.id, a.kind, a.owner_subject AS owner, a.currency, COALESCE(b.balance, 0) AS cached,
++ COALESCE((SELECT SUM(amount) FROM ledger_entries WHERE account_id = a.id), 0) AS derived
++ FROM accounts a LEFT JOIN account_balances b ON b.account_id = a.id`).all();
++ const mismatches = cache.filter((r) => r.cached !== r.derived);
++ check('balances.cache', !mismatches.length, mismatches.length ? { mismatches } : { accounts: cache.length });
++
++ const fxRows = db.prepare(`SELECT t.id, json_extract(t.metadata, '$.rates.bits_per_usd') AS rate,
++ SUM(CASE WHEN a.currency = 'vibes-bits' THEN e.amount ELSE 0 END) AS bits,
++ SUM(CASE WHEN a.currency = 'usd-cents' THEN e.amount ELSE 0 END) AS cents
++ FROM ledger_entries e JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
++ WHERE a.kind = 'fx_conversion' GROUP BY t.id`).all();
++ const fxBad = fxRows.filter((r) => {
++ const rate = Number(r.rate) || 100;
++ const value = Math.round((Math.abs(r.bits) * 100) / rate);
++ return r.cents !== (r.bits >= 0 ? -value : value);
++ });
++ check('fx.mirror', !fxBad.length, fxBad.length ? { offenders: fxBad } : { transactions: fxRows.length });
++
++ const evRows = db.prepare(`SELECT p.id, p.provider, p.provider_event_id, json_extract(p.result, '$.effect') AS effect,
++ (SELECT COUNT(*) FROM transactions t WHERE t.source_event_id = p.id) AS txns
++ FROM provider_events p WHERE p.processed_at IS NOT NULL`).all();
++ const evBad = evRows.filter((r) => (r.effect === 'settled' ? r.txns !== 1 : r.txns !== 0));
++ check('events.settlement', !evBad.length, evBad.length ? { offenders: evBad } : { processed_events: evRows.length });
++
++ const paidBad = db.prepare("SELECT id, subject, payout_reference, settle_txn FROM cashouts WHERE status = 'paid' AND (payout_reference IS NULL OR TRIM(payout_reference) = '' OR settle_txn IS NULL)").all();
++ check('payouts.references', !paidBad.length, paidBad.length ? { offenders: paidBad } : null);
++
++ const pendingBad = db.prepare(`SELECT a.owner_subject AS subject, b.balance,
++ COALESCE((SELECT SUM(amount_bits) FROM cashouts c WHERE c.subject = a.owner_subject AND c.status = 'requested'), 0) AS requested
++ FROM accounts a JOIN account_balances b ON b.account_id = a.id WHERE a.kind = 'payouts_pending'`).all().filter((r) => r.balance !== r.requested);
++ check('payouts.pending', !pendingBad.length, pendingBad.length ? { offenders: pendingBad } : null);
++
++ const warnings = {
++ negative_balances: 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(),
++ unprocessed_events: 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: 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) })),
++ events_for_review: db.prepare("SELECT id, provider, provider_event_id, type, result FROM provider_events WHERE json_extract(result, '$.review') = 1")
++ .all().map((r) => ({ ...r, result: JSON.parse(r.result) })),
++ transactions_for_review: db.prepare("SELECT id, type, reverses_txn, created_at, metadata FROM transactions WHERE json_extract(metadata, '$.review') = 'required' ORDER BY created_at")
++ .all().map((r) => ({ ...r, metadata: JSON.parse(r.metadata) })),
++ import_holds: db.prepare('SELECT live_user_id, owner, reason, credit_bits, payable_bits FROM import_holds WHERE resolved_subject IS NULL').all(),
++ };
++
++ const sumKind = (kind, currency, excludeTest = true) => db.prepare(`SELECT COALESCE(SUM(e.amount), 0) AS n FROM ledger_entries e
++ JOIN accounts a ON a.id = e.account_id JOIN transactions t ON t.id = e.txn_id
++ WHERE a.kind = ? AND a.currency = ? ${excludeTest ? 'AND t.test = 0' : ''}`).get(kind, currency).n;
++ const totals = {
++ excludes_test_transactions: true,
++ platform_revenue_cents: sumKind('platform_revenue', 'usd-cents'),
++ platform_revenue_bits: sumKind('platform_revenue', 'vibes-bits'),
++ refunds_cents: sumKind('refunds', 'usd-cents'),
++ chargeback_loss_cents: sumKind('chargeback_loss', 'usd-cents'),
++ credit_outstanding_bits: sumKind('user_credit', 'vibes-bits', false),
++ payable_outstanding_bits: sumKind('creator_payable', 'vibes-bits', false),
++ payouts_pending_bits: sumKind('payouts_pending', 'vibes-bits', false),
++ import_adjustment_bits: sumKind('import_adjustment', 'vibes-bits', false),
++ transactions: db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n,
++ test_transactions: db.prepare('SELECT COUNT(*) AS n FROM transactions WHERE test = 1').get().n,
++ };
++
++ const finished = ctx.now();
++ const report = { id: prefixedId('rec', finished), ok: checks.every((c) => c.ok), started_at: iso(started), finished_at: iso(finished), checks, warnings, totals };
++ if (store) {
++ db.prepare('INSERT INTO reconciliation_runs (id, started_at, finished_at, ok, report) VALUES (?, ?, ?, ?, ?)')
++ .run(report.id, report.started_at, report.finished_at, report.ok ? 1 : 0, JSON.stringify(report));
++ }
++ return report;
++}
++
++module.exports = { reconcile };
ddiff --git a/test/auth-outbox.test.js b/test/auth-outbox.test.js
nnew file mode 100644
iindex 0000000..47e546d
---- /dev/null
++++ b/test/auth-outbox.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * Service-token authentication and capability grants; the transactional outbox and its relay to
++ * OpenVibe.Events; the reconciliation endpoint catching a corrupted cached balance.
++ */
++const assert = require('assert');
++const { validate, serviceAuth } = require('openvibe-contracts');
++const { boot, fund, check, done } = require('./helpers/app');
++const { startEvents } = require('./helpers/stubs');
++
++(async () => {
++ const t = await boot();
++ const a = t.user(51);
++ const b = t.user(52);
++ console.log('auth, outbox, reconciliation');
++
++ await check('no token, wrong audience, wrong issuer and expired tokens are refused', async () => {
++ const none = await t.call('GET', `/api/v1/balances/${a.id}`, { token: null });
++ assert.strictEqual(none.status, 401);
++ const aud = await t.call('GET', `/api/v1/balances/${a.id}`, { token: t.network.signService({ aud: ['openvibe.media'], cap: ['billing.*'] }) });
++ assert.strictEqual(aud.json.code, 'token.wrong_audience');
++ const iss = await t.call('GET', `/api/v1/balances/${a.id}`, { token: t.network.signService({ iss: 'https://evil.example', cap: ['billing.*'] }) });
++ assert.strictEqual(iss.json.code, 'token.wrong_issuer');
++ const exp = await t.call('GET', `/api/v1/balances/${a.id}`, { token: t.network.signService({ cap: ['billing.*'], expSec: -120 }) });
++ assert.strictEqual(exp.json.code, 'token.expired');
++ });
++
++ await check('capabilities: exact ids and .* families are honoured, nothing else', async () => {
++ assert.strictEqual((await t.call('GET', `/api/v1/balances/${a.id}`, { cap: ['billing.balance.read'] })).status, 200);
++ assert.strictEqual((await t.call('GET', `/api/v1/balances/${a.id}`, { cap: ['billing.balance.*'] })).status, 200);
++ const denied = await t.call('GET', `/api/v1/balances/${a.id}`, { cap: ['billing.entitlement.check', 'network.coins.credit'] });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.json.code, 'capability.denied');
++ assert.strictEqual((await t.call('GET', '/api/v1/admin/freeze', { cap: ['billing.cashout.manage'] })).status, 403);
++ assert.strictEqual((await t.call('GET', '/api/v1/admin/freeze', { cap: ['billing.ledger.admin'] })).status, 200);
++ const bad = await t.call('GET', '/api/v1/balances/42', { cap: ['billing.balance.read'] });
++ assert.strictEqual(bad.json.code, 'billing.invalid_subject');
++ });
++
++ await check('every settled operation writes a valid event envelope in the same transaction', async () => {
++ await fund(t, a, 1500);
++ assert.strictEqual((await t.call('POST', '/api/v1/transfers', { body: { from: a, to: b, amount: 600 } })).status, 201);
++ await t.call('POST', '/api/v1/cashouts', { body: { subject: b, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual((await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: a, streamer: b, source: 'credit', auto_renew: false } })).status, 201);
++ const rows = t.db.prepare('SELECT event FROM outbox ORDER BY seq').all().map((r) => JSON.parse(r.event));
++ const types = rows.map((e) => e.event_type);
++ for (const type of ['billing.transaction.settled', 'billing.cashout.requested', 'billing.entitlement.changed']) assert.ok(types.includes(type), `${type} in ${types}`);
++ for (const e of rows) {
++ const v = validate('events.event-envelope@1', e);
++ assert.ok(v.valid, JSON.stringify(v.errors));
++ assert.deepStrictEqual([e.source, e.actor.type, e.actor.id], ['billing', 'service', 'billing']);
++ assert.ok(['transaction', 'entitlement', 'cashout', 'subscription'].includes(e.subject.type));
++ }
++ const txnCount = t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n;
++ const refused = await t.call('POST', '/api/v1/transfers', { body: { from: a, to: b, amount: 999999 } });
++ assert.strictEqual(refused.status, 409);
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM outbox').get().n, rows.length, 'a refused operation writes no event');
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n, txnCount);
++ });
++
++ await check('the relay publishes unsent events to OpenVibe.Events with an events.event.publish token', async () => {
++ const events = await startEvents();
++ const { createRelay } = require('../server/outbox');
++ const relay = createRelay({ db: t.db, config: { ...t.config, events: { url: events.url, intervalMs: 1000 } }, log: { warn() {} } });
++ const n = t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get().n;
++ const r = await relay.flush();
++ assert.strictEqual(r.sent, n);
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM outbox WHERE sent_at IS NULL').get().n, 0);
++ const token = events.tokens[0].slice(7);
++ const v = serviceAuth.verifyServiceToken(token, { publicKey: t.network.publicPem, audience: 'openvibe.events' });
++ assert.ok(v.ok, v.reason);
++ assert.deepStrictEqual(v.claims.cap, ['events.event.publish']);
++ assert.strictEqual(v.claims.sub, 'svc:billing');
++ assert.strictEqual((await relay.flush()).sent, 0);
++ await events.close();
++ });
++
++ await check('GET /admin/reconcile stores a run and detects a deliberately corrupted cached balance', async () => {
++ const ok = await t.call('GET', '/api/v1/admin/reconcile', { cap: ['billing.ledger.admin'] });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json.ok, true, JSON.stringify(ok.json.checks.filter((c) => !c.ok)));
++ assert.strictEqual(ok.json.totals.excludes_test_transactions, true);
++ t.db.prepare("UPDATE account_balances SET balance = balance - 7 WHERE account_id = (SELECT id FROM accounts WHERE kind = 'creator_payable' AND owner_subject = ?)").run(b.id);
++ const bad = await t.call('GET', '/api/v1/admin/reconcile', { cap: ['billing.ledger.admin'] });
++ assert.strictEqual(bad.json.ok, false);
++ const c = bad.json.checks.find((x) => x.id === 'balances.cache');
++ assert.strictEqual(c.detail.mismatches[0].owner, b.id);
++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs').get().n, 2);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..1cc5fb1
---- /dev/null
++++ b/test/helpers/app.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * Boots Billing against the stubs on a random port with a temp database, and returns a small
++ * client: call() signs a service token with the capabilities asked for and adds an
++ * Idempotency-Key to every POST unless told otherwise.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const assert = require('assert');
++const { startNetwork, startStripe } = require('./stubs');
++
++async function boot(opts = {}) {
++ const network = opts.network || await startNetwork();
++ const stripe = opts.stripe ? await startStripe() : null;
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'billing-test-'));
++ const env = {
++ NODE_ENV: 'test',
++ BILLING_DB_PATH: path.join(dir, 'billing.db'),
++ OV_NETWORK_INTERNAL_URL: network.url,
++ OV_NETWORK_ISSUER: network.url,
++ OV_OAUTH_CLIENT_ID: 'billing',
++ OV_OAUTH_CLIENT_SECRET: 'shh',
++ POWERCHAT_WEBHOOK_SECRET: 'pc-secret',
++ POWERCHAT_SITE_USERNAME: 'openvibe',
++ ...(stripe ? { STRIPE_SECRET_KEY: 'sk_test_x', STRIPE_WEBHOOK_SECRET: 'whsec_x', STRIPE_API_BASE: stripe.url } : {}),
++ ...(opts.env || {}),
++ };
++ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
++ const { loadConfig } = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const config = loadConfig(env);
++ const clock = { offset: 0 };
++ const logs = [];
++ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
++ const app = createApp({ config, now: () => Date.now() + clock.offset, log });
++ await app.locals.keys.load();
++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const ctx = app.locals.ctx;
++
++ let n = 0;
++ async function call(method, p, { body, cap = ['billing.*'], key, token, headers = {}, sub = 'svc:live' } = {}) {
++ const h = { ...headers };
++ if (token !== null) h.Authorization = `Bearer ${token || network.signService({ sub, cap })}`;
++ if (body !== undefined) h['Content-Type'] = 'application/json';
++ if (method === 'POST' && key !== null) h['Idempotency-Key'] = key || `test-key-${process.pid}-${++n}-${crypto.randomBytes(4).toString('hex')}`;
++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
++ const text = await res.text();
++ let json = null; try { json = JSON.parse(text); } catch { /* */ }
++ return { status: res.status, headers: res.headers, json, text };
++ }
++
++ /** POST a signed PowerChat delivery. */
++ async function powerchat(envelope, { deliveryId = crypto.randomUUID(), secret = 'pc-secret', ts = Date.now() } = {}) {
++ const raw = JSON.stringify(envelope);
++ const sig = 'sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.`).update(raw).digest('hex');
++ const res = await fetch(`${base}/webhooks/powerchat`, {
++ method: 'POST', body: raw,
++ headers: { 'Content-Type': 'application/json', 'X-PowerChat-Signature': sig, 'X-PowerChat-Timestamp': String(ts), 'X-PowerChat-Delivery-Id': deliveryId, 'X-PowerChat-Event-Type': envelope.type },
++ });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ }
++
++ /** POST a signed Stripe event. */
++ async function stripeEvent(event, { secret = 'whsec_x' } = {}) {
++ const raw = JSON.stringify(event);
++ const t = Math.floor(Date.now() / 1000);
++ const sig = crypto.createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');
++ const res = await fetch(`${base}/webhooks/stripe`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', 'Stripe-Signature': `t=${t},v1=${sig}` } });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ }
++
++ /** Reconciliation must pass (journal balanced, caches right, events settled once). */
++ function assertReconciled(label = '') {
++ const { reconcile } = require('../../server/reconcile');
++ const r = reconcile(ctx, { store: false });
++ const failed = r.checks.filter((c) => !c.ok);
++ assert.deepStrictEqual(failed, [], `reconciliation failed ${label}: ${JSON.stringify(failed)}`);
++ return r;
++ }
++
++ async function balances(subject) {
++ const r = await call('GET', `/api/v1/balances/${subject}`, { cap: ['billing.balance.read'] });
++ assert.strictEqual(r.status, 200, r.text);
++ return r.json;
++ }
++
++ const user = (liveId) => ({ type: 'user', id: network.addUser(liveId) });
++
++ return {
++ app, base, call, ctx, db: ctx.db, config, clock, network, stripe, powerchat, stripeEvent, assertReconciled, balances, user, logs, dir,
++ close: async () => {
++ await new Promise((r) => server.close(r));
++ if (!opts.network) await network.close();
++ if (stripe) await stripe.close();
++ try { ctx.db.close(); } catch { /* */ }
++ fs.rmSync(dir, { recursive: true, force: true });
++ },
++ };
++}
++
++/**
++ * Give `subject` spendable credit the way production does: a settled provider receipt
++ * (operator-grade settle endpoint), so every test balance has a real journal origin.
++ */
++async function fund(t, subject, bits, provider = 'powerchat') {
++ const r = await t.call('POST', '/api/v1/purchases/settle', {
++ body: { provider, provider_ref: `fund-${crypto.randomBytes(6).toString('hex')}`, subject, amount_cents: t.ctx.rates.valueCents(bits), bits },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json.transaction;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, fund, check, done };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
nnew file mode 100644
iindex 0000000..5f9c76c
---- /dev/null
++++ b/test/helpers/stubs.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Stand-ins for the services Billing talks to, each on a random port with a real RS256 key pair.
++ *
++ * startNetwork() JWKS, client-credentials token endpoint (scope → cap), resolve-batch
++ * (insists on a service token for openvibe.network holding identity.subject.resolve)
++ * startStripe() /checkout/sessions and /subscriptions/:id, recording calls; `fail` makes it 500
++ * startEvents() POST /api/v1/events recording batches and the bearer tokens it saw
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(server) {
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));
++}
++function readBody(req) {
++ return new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c).toString('utf8'))); });
++}
++const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const legacy = {}; // live user id → subject id
++ const resolveCalls = [];
++ const grants = [];
++ let issuer = 'http://network.test';
++ const state = { down: false };
++
++ function signService({ sub = 'svc:live', aud = ['openvibe.billing'], cap = [], expSec = 300, iss } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: iss || issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
++ }
++ function addUser(liveId) {
++ const subject = ids.newId('user');
++ if (liveId != null) legacy[String(liveId)] = subject;
++ return subject;
++ }
++
++ const server = http.createServer(async (req, res) => {
++ const raw = await readBody(req);
++ if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
++ if (req.url === '/oauth/token' && req.method === 'POST') {
++ const body = Object.fromEntries(new URLSearchParams(raw));
++ grants.push(body);
++ if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
++ return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
++ }
++ if (req.url === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const auth = String(req.headers.authorization || '');
++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok) return send(res, 401, { code: v.code });
++ if (!(v.claims.cap || []).includes('identity.subject.resolve')) return send(res, 403, { code: 'capability.denied' });
++ if (state.down) return send(res, 503, { error: 'down' });
++ const body = JSON.parse(raw || '{}');
++ resolveCalls.push(body);
++ const results = {};
++ for (const id of body.ids || []) {
++ const s = body.system === 'live' ? legacy[String(id)] : null;
++ results[String(id)] = s ? { subject: { type: 'user', id: s }, username: `u${id}` } : null;
++ }
++ return send(res, 200, { results });
++ }
++ send(res, 404, { error: 'not found' });
++ });
++ const url = await listen(server);
++ issuer = url;
++ return { url, publicPem, signService, addUser, legacy, resolveCalls, grants, state, close: () => new Promise((r) => server.close(r)) };
++}
++
++async function startStripe() {
++ const calls = [];
++ const state = { fail: false };
++ const server = http.createServer(async (req, res) => {
++ const raw = await readBody(req);
++ calls.push({ method: req.method, url: req.url, body: Object.fromEntries(new URLSearchParams(raw)), auth: req.headers.authorization });
++ if (state.fail) return send(res, 500, { error: { message: 'stripe is down' } });
++ if (req.url === '/checkout/sessions') return send(res, 200, { id: `cs_test_${calls.length}`, url: `https://checkout.stripe.test/cs_${calls.length}` });
++ const m = req.url.match(/^\/subscriptions\/([^/]+)$/);
++ if (m) return send(res, 200, { id: decodeURIComponent(m[1]), cancel_at_period_end: true });
++ send(res, 404, { error: { message: 'no route' } });
++ });
++ const url = await listen(server);
++ return { url, calls, state, close: () => new Promise((r) => server.close(r)) };
++}
++
++async function startEvents() {
++ const batches = [];
++ const tokens = [];
++ const server = http.createServer(async (req, res) => {
++ const raw = await readBody(req);
++ if (req.url === '/api/v1/events' && req.method === 'POST') {
++ tokens.push(req.headers.authorization);
++ const body = JSON.parse(raw);
++ batches.push(body);
++ return send(res, 201, { results: (body.events || []).map((e, i) => ({ event_id: e.event_id, seq: i + 1, duplicate: false })) });
++ }
++ send(res, 404, {});
++ });
++ const url = await listen(server);
++ return { url, batches, tokens, close: () => new Promise((r) => server.close(r)) };
++}
++
++module.exports = { startNetwork, startStripe, startEvents };
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..e091964
---- /dev/null
++++ b/test/import.test.js
@@@ -0,0 +1,165 @@
++'use strict';
++/**
++ * Import from a fabricated live.db snapshot (Live's own table shapes): opening balances equal the
++ * Live columns, differences against the replayed history are adjustments and listed, unmapped users
++ * are held (never dropped) and released once mapped, test-era rows are flagged, duplicate provider
++ * refs and route markers are handled, dry runs keep nothing, and a re-run changes nothing.
++ */
++const assert = require('assert');
++const path = require('path');
++const Database = require('better-sqlite3');
++const { boot, check, done } = require('./helpers/app');
++
++function fabricateLive(file) {
++ const live = new Database(file);
++ live.exec(`
++ CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, openvibe_bucks_balance REAL DEFAULT 0.00,
++ openvibe_coins_balance INTEGER DEFAULT 0, openvibe_bucks_cashout_balance REAL DEFAULT 0.00);
++ CREATE TABLE transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, from_user_id INTEGER, to_user_id INTEGER, stream_id INTEGER,
++ amount INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('donation', 'purchase', 'subscription', 'cashout', 'refund', 'bonus')),
++ status TEXT DEFAULT 'completed' CHECK(status IN ('pending', 'completed', 'failed', 'escrow', 'refunded')), message TEXT,
++ paypal_transaction_id TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
++ CREATE TABLE payment_orders (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, provider TEXT NOT NULL, provider_ref TEXT,
++ kind TEXT NOT NULL DEFAULT 'bucks', amount_cents INTEGER NOT NULL DEFAULT 0, currency TEXT DEFAULT 'usd', bucks INTEGER DEFAULT 0,
++ streamer_id INTEGER, status TEXT DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);
++ CREATE TABLE subscriptions (id INTEGER PRIMARY KEY AUTOINCREMENT, subscriber_id INTEGER NOT NULL, streamer_id INTEGER NOT NULL, tier INTEGER DEFAULT 1,
++ is_active INTEGER DEFAULT 1, started_at DATETIME DEFAULT CURRENT_TIMESTAMP, expires_at DATETIME, provider TEXT, provider_ref TEXT,
++ price_cents INTEGER DEFAULT 0, currency TEXT DEFAULT 'usd', status TEXT DEFAULT 'active', cancel_at_period_end INTEGER DEFAULT 0,
++ auto_renew INTEGER DEFAULT 0, current_period_end DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);
++ CREATE TABLE site_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', description TEXT DEFAULT '', type TEXT DEFAULT 'string', updated_at DATETIME);
++ INSERT INTO site_settings (key, value) VALUES ('stats_vibes_reset_at', '2026-06-01T00:00:00Z');
++ INSERT INTO users (id, username, openvibe_bucks_balance, openvibe_bucks_cashout_balance, openvibe_coins_balance) VALUES
++ (1, 'alice', 1500, 0, 999), (2, 'bob', 0, 700, 0), (3, 'carol', 250, 0, 0), (4, 'dave', 100.5, 0, 0), (5, 'erin', 0, 0, 0);
++ INSERT INTO transactions (id, from_user_id, to_user_id, amount, type, status, message, created_at) VALUES
++ (1, NULL, 1, 1000, 'purchase', 'completed', 'test era', '2026-05-01 10:00:00'),
++ (2, NULL, 1, 1000, 'purchase', 'completed', NULL, '2026-07-01 10:00:00'),
++ (3, 1, 2, 500, 'donation', 'completed', 'gg', '2026-07-02 10:00:00'),
++ (4, NULL, 2, 300, 'donation', 'completed', 'PowerChat tip via site account ($3.00)', '2026-07-03 10:00:00'),
++ (5, 2, NULL, 500, 'cashout', 'escrow', 'Cashout to PayPal: [email protected]', '2026-07-04 10:00:00'),
++ (6, 2, NULL, 100, 'cashout', 'refunded', 'Cashout to PayPal: [email protected]', '2026-07-05 10:00:00'),
++ (7, NULL, 3, 250, 'purchase', 'completed', NULL, '2026-07-06 10:00:00'),
++ (8, NULL, 4, 100, 'bonus', 'completed', NULL, '2026-07-07 10:00:00'),
++ (9, 1, 2, 499, 'subscription', 'completed', NULL, '2026-07-08 10:00:00');
++ INSERT INTO payment_orders (id, user_id, provider, provider_ref, kind, amount_cents, bucks, streamer_id, status, created_at) VALUES
++ (1, 1, 'powerchat', NULL, 'bucks', 1300, 1000, NULL, 'credited', '2026-07-01 09:59:00'),
++ (2, 1, 'stripe', 'cs_dup', 'bucks', 1300, 1000, NULL, 'credited', '2026-07-01 11:00:00'),
++ (3, 1, 'stripe', 'cs_dup', 'bucks', 1300, 1000, NULL, 'pending', '2026-07-01 11:05:00'),
++ (4, 1, 'powerchat', 'site:fee=50:renew', 'subscription', 549, 0, 2, 'credited', '2026-07-08 09:00:00'),
++ (5, 3, 'powerchat', NULL, 'bucks', 325, 250, NULL, 'paid', '2026-07-06 09:00:00');
++ `);
++ const future = new Date(Date.now() + 20 * 86_400_000).toISOString().replace('T', ' ').slice(0, 19);
++ live.prepare(`INSERT INTO subscriptions (id, subscriber_id, streamer_id, provider, price_cents, status, auto_renew, current_period_end, created_at)
++ VALUES (1, 1, 2, 'powerchat', 549, 'active', 1, ?, '2026-07-08 09:00:00'), (2, 4, 2, 'bucks', 499, 'expired', 0, '2026-07-01 00:00:00', '2026-06-01 00:00:00')`).run(future);
++ live.close();
++ return new Database(file, { readonly: true, fileMustExist: true });
++}
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser(1);
++ const bob = t.network.addUser(2);
++ const dave = t.network.addUser(4);
++ t.network.addUser(5);
++ const live = fabricateLive(path.join(t.dir, 'live-snapshot.db'));
++ const { importLive } = require('../server/importer/live');
++ const { createIdentity } = require('../server/network');
++ const identity = createIdentity(t.config);
++ const run = (opts = {}) => importLive(t.ctx, { live, resolveLiveUsers: identity.resolveLiveUsers, log: { log() {} }, ...opts });
++ const bal = (kind, owner) => { const { balance } = require('../server/ledger'); return balance(t.db, { kind, owner, currency: 'vibes-bits' }); };
++ const count = (table) => t.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
++ console.log('import from live.db');
++
++ await check('a dry run reports everything and keeps nothing but its report', async () => {
++ const r = await run({ dryRun: true });
++ assert.strictEqual(r.dry_run, true);
++ assert.ok(r.new_transactions > 0);
++ assert.strictEqual(count('transactions'), 0);
++ assert.strictEqual(count('payment_intents'), 0);
++ assert.strictEqual(t.db.prepare('SELECT dry_run FROM import_runs').get().dry_run, 1);
++ });
++
++ let report;
++ await check('opening balances equal the Live columns', async () => {
++ report = await run();
++ assert.strictEqual(bal('user_credit', alice), 1500);
++ assert.strictEqual(bal('creator_payable', bob), 700);
++ assert.strictEqual(bal('payouts_pending', bob), 500, 'the escrowed cashout sits in payouts_pending');
++ assert.strictEqual(bal('user_credit', dave), 101);
++ assert.strictEqual(bal('user_credit', 'hold:live:3'), 250);
++ assert.ok(t.network.resolveCalls.every((c) => c.system === 'live' && c.type === 'user'));
++ });
++
++ await check('differences against the replayed history are import adjustments, listed', async () => {
++ const adj = report.adjustments.map((a) => [a.live_user_id, a.account, a.replayed_history, a.live_column, a.adjustment]);
++ assert.deepStrictEqual(adj.sort(), [[2, 'creator_payable', 300, 700, 400], [4, 'user_credit', 100, 101, 1]].sort());
++ assert.ok(report.anomalies.some((a) => a.kind === 'fractional_balance' && a.live_user_id === 4));
++ assert.strictEqual(bal('import_adjustment', null), -401);
++ });
++
++ await check('unmapped users are held, never dropped', async () => {
++ assert.deepStrictEqual(report.holds.map((h) => [h.live_user_id, h.credit_bits]), [[3, 250]]);
++ const hold = t.db.prepare('SELECT * FROM import_holds WHERE live_user_id = 3').get();
++ assert.strictEqual(hold.resolved_subject, null);
++ const i = t.db.prepare('SELECT subject FROM payment_intents WHERE legacy_order_id = 5').get();
++ assert.strictEqual(i.subject, 'hold:live:3');
++ });
++
++ await check('history rows become import transactions; test-era rows are flagged test', async () => {
++ const txn = (id) => t.db.prepare('SELECT * FROM transactions WHERE idempotency_key = ?').get(`import:live:txn:${id}`);
++ assert.deepStrictEqual([txn(1).test, txn(2).test], [1, 0]);
++ assert.deepStrictEqual([txn(1).type, txn(1).status], ['import', 'imported']);
++ assert.strictEqual(txn(3).created_at, '2026-07-02T10:00:00.000Z');
++ assert.ok(report.unreplayable.some((u) => u.live_txn === 9));
++ const co = t.db.prepare('SELECT status, amount_bits FROM cashouts ORDER BY legacy_live_txn').all();
++ assert.deepStrictEqual(co.map((c) => [c.status, c.amount_bits]), [['requested', 500], ['denied', 100]]);
++ });
++
++ await check('payment orders become intents and receipts; duplicate refs and route markers handled', async () => {
++ assert.deepStrictEqual(report.duplicate_provider_refs.map((d) => [d.order, d.kept_on_order]), [[3, 2]]);
++ assert.strictEqual(report.route_markers, 1);
++ const sub = t.db.prepare('SELECT * FROM payment_intents WHERE legacy_order_id = 4').get();
++ assert.deepStrictEqual([sub.provider_ref, sub.route, sub.fee_cents, sub.auto_renew, sub.status], [null, 'site', 50, 1, 'settled']);
++ assert.strictEqual(t.db.prepare('SELECT status FROM payment_intents WHERE legacy_order_id = 3').get().status, 'expired');
++ assert.ok(report.anomalies.some((a) => a.kind === 'order_paid_not_credited' && a.order === 5));
++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE type = 'live.payment_order'").get().n, 4);
++ });
++
++ await check('subscriptions become entitlements', async () => {
++ const e = await t.call('GET', `/api/v1/entitlements/${alice}?streamer=${bob}`);
++ assert.strictEqual(e.json.active, true);
++ assert.strictEqual(e.json.subscription.auto_renew, true);
++ const d = await t.call('GET', `/api/v1/entitlements/${dave}?streamer=${bob}`);
++ assert.strictEqual(d.json.active, false);
++ assert.strictEqual(report.subscriptions.imported, 2);
++ });
++
++ await check('the journal reconciles after import', async () => {
++ const rec = t.assertReconciled('after import');
++ assert.strictEqual(rec.warnings.import_holds.length, 1);
++ assert.ok(rec.totals.test_transactions >= 1);
++ });
++
++ await check('a re-run over the same snapshot changes nothing', async () => {
++ const before = { txns: count('transactions'), entries: count('ledger_entries'), intents: count('payment_intents'), subs: count('subscriptions'), ents: count('entitlements'), cashouts: count('cashouts') };
++ const again = await run();
++ assert.strictEqual(again.new_transactions, 0);
++ assert.deepStrictEqual(again.adjustments, []);
++ assert.deepStrictEqual({ txns: count('transactions'), entries: count('ledger_entries'), intents: count('payment_intents'), subs: count('subscriptions'), ents: count('entitlements'), cashouts: count('cashouts') }, before);
++ assert.strictEqual(bal('creator_payable', bob), 700);
++ });
++
++ await check('a hold is released to the subject once the Network maps the user', async () => {
++ const carol = t.network.addUser(3);
++ const r = await run();
++ assert.deepStrictEqual(r.holds, []);
++ assert.strictEqual(bal('user_credit', carol), 250);
++ assert.strictEqual(bal('user_credit', 'hold:live:3'), 0);
++ assert.strictEqual(t.db.prepare('SELECT resolved_subject FROM import_holds WHERE live_user_id = 3').get().resolved_subject, carol);
++ t.assertReconciled('after release');
++ assert.strictEqual((await run()).new_transactions, 0);
++ });
++
++ live.close();
++ await t.close();
++ done();
++})();
ddiff --git a/test/ledger.test.js b/test/ledger.test.js
nnew file mode 100644
iindex 0000000..9e35d9f
---- /dev/null
++++ b/test/ledger.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/** The journal itself: balance invariant, append-only, idempotent posting, cached balances. */
++const assert = require('assert');
++const { openDb } = require('../server/db');
++const { loadConfig } = require('../server/config');
++const { createRates } = require('../server/rates');
++const { post, balance, BillingError } = require('../server/ledger');
++const { A, entry } = require('../server/ops/common');
++const { reconcile } = require('../server/reconcile');
++const { check, done } = require('./helpers/app');
++
++const db = openDb(':memory:');
++const config = loadConfig({ NODE_ENV: 'test' });
++const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now() };
++const U = 'usr_01JAAAAAAAAAAAAAAAAAAAAAAA';
++
++(async () => {
++ console.log('ledger');
++
++ await check('an unbalanced transaction is refused and leaves nothing behind', () => {
++ assert.throws(() => post(ctx, { type: 'adjustment', idempotencyKey: 'k1', entries: [entry(A.credit(U), 5), entry(A.importAdj('vibes-bits'), -4)] }),
++ (e) => e instanceof BillingError && e.code === 'ledger.unbalanced');
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM transactions').get().n, 0);
++ assert.strictEqual(balance(db, A.credit(U)), 0);
++ });
++
++ await check('balance is per currency: bits and cents never net against each other', () => {
++ assert.throws(() => post(ctx, { type: 'adjustment', idempotencyKey: 'k2', entries: [entry(A.credit(U), 5), entry(A.refunds(), -5)] }),
++ (e) => e.code === 'ledger.unbalanced');
++ });
++
++ await check('a balanced transaction moves the cached balances', () => {
++ const { txn } = post(ctx, { type: 'adjustment', idempotencyKey: 'k3', entries: [entry(A.credit(U), 70), entry(A.importAdj('vibes-bits'), -70)] });
++ assert.strictEqual(txn.entries.length, 2);
++ assert.strictEqual(balance(db, A.credit(U)), 70);
++ assert.strictEqual(balance(db, A.importAdj('vibes-bits')), -70);
++ });
++
++ await check('posting the same idempotency key again returns the original and moves nothing', () => {
++ const first = db.prepare("SELECT id FROM transactions WHERE idempotency_key = 'k3'").get().id;
++ const r = post(ctx, { type: 'adjustment', idempotencyKey: 'k3', entries: [entry(A.credit(U), 999), entry(A.importAdj('vibes-bits'), -999)] });
++ assert.strictEqual(r.replay, true);
++ assert.strictEqual(r.txn.id, first);
++ assert.strictEqual(balance(db, A.credit(U)), 70);
++ });
++
++ await check('ledger entries and transactions cannot be updated or deleted', () => {
++ assert.throws(() => db.prepare('UPDATE ledger_entries SET amount = amount + 1').run(), /append-only/);
++ assert.throws(() => db.prepare('DELETE FROM ledger_entries').run(), /append-only/);
++ assert.throws(() => db.prepare("UPDATE transactions SET status = 'imported'").run(), /append-only/);
++ assert.throws(() => db.prepare('DELETE FROM transactions').run(), /append-only/);
++ });
++
++ await check('platform accounts (no owner) are unique despite NULL owners', () => {
++ post(ctx, { type: 'adjustment', idempotencyKey: 'k4', entries: [entry(A.refunds(), 1), entry(A.loss(), -1)] });
++ post(ctx, { type: 'adjustment', idempotencyKey: 'k5', entries: [entry(A.refunds(), 1), entry(A.loss(), -1)] });
++ assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM accounts WHERE kind = 'refunds'").get().n, 1);
++ assert.throws(() => db.prepare("INSERT INTO accounts (kind, owner_subject, currency, created_at) VALUES ('refunds', NULL, 'usd-cents', 'x')").run(), /UNIQUE/);
++ });
++
++ await check('rates: tiers price like Live and invert without overcharging', () => {
++ const r = ctx.rates;
++ assert.strictEqual(r.priceCents(100), 150);
++ assert.strictEqual(r.priceCents(1000), 1300);
++ assert.strictEqual(r.priceCents(25000), 27500);
++ assert.strictEqual(r.bitsForPriceCents(1300), 1000);
++ assert.strictEqual(r.bitsForPriceCents(149), 99);
++ for (const c of [50, 150, 700, 1299, 1300, 3100, 27500, 99999]) assert.ok(r.priceCents(r.bitsForPriceCents(c)) <= c, `price of bits for ${c}`);
++ assert.strictEqual(r.subShareBits(499), 349); // Live: bucksForUsd(4.99 * 0.70)
++ assert.strictEqual(r.siteFeeCents(499), 50); // Live: round(499 * 10 / 100)
++ });
++
++ await check('reconciliation passes on a consistent journal and catches a corrupted cache', () => {
++ assert.strictEqual(reconcile(ctx).ok, true);
++ db.prepare("UPDATE account_balances SET balance = balance + 1 WHERE account_id = (SELECT id FROM accounts WHERE kind = 'user_credit')").run();
++ const r = reconcile(ctx);
++ assert.strictEqual(r.ok, false);
++ const c = r.checks.find((x) => x.id === 'balances.cache');
++ assert.strictEqual(c.ok, false);
++ assert.strictEqual(c.detail.mismatches[0].cached - c.detail.mismatches[0].derived, 1);
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM reconciliation_runs WHERE ok = 0').get().n, 1);
++ });
++
++ done();
++})();
ddiff --git a/test/operations.test.js b/test/operations.test.js
nnew file mode 100644
iindex 0000000..d0aa49c
---- /dev/null
++++ b/test/operations.test.js
@@@ -0,0 +1,203 @@
++'use strict';
++/**
++ * The operations API end to end: purchases, transfers, recycle, cashouts, refunds of transfers,
++ * idempotent replays — with a full reconciliation after every operation type.
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const viewer = t.user(11);
++ const streamer = t.user(12);
++ console.log('operations');
++
++ await check('health and rates are served', async () => {
++ const h = await t.call('GET', '/api/health', { token: null });
++ assert.strictEqual(h.status, 200);
++ assert.strictEqual(h.json.frozen, false);
++ assert.deepStrictEqual(h.json.providers, { powerchat: true, stripe: false, paypal: false, ccbill: false, nowpayments: false });
++ const r = await t.call('GET', '/api/v1/rates', { token: null });
++ assert.strictEqual(r.json.packages.find((p) => p.bits === 1000).price_cents, 1300);
++ assert.strictEqual((await t.call('GET', '/api/ready', { token: null })).status, 200);
++ });
++
++ await check('purchase settle credits the buyer and books the spread as revenue', async () => {
++ const r = await t.call('POST', '/api/v1/purchases/settle', { body: { provider: 'powerchat', provider_ref: 'pay-1', subject: viewer, amount_cents: 1300 } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.transaction.metadata.bits, 1000);
++ assert.strictEqual(r.json.transaction.metadata.revenue_cents, 300);
++ assert.strictEqual(r.json.transaction.metadata.rates.bits_per_usd, 100);
++ assert.strictEqual((await t.balances(viewer.id)).credit, 1000);
++ t.assertReconciled('after purchase');
++ });
++
++ await check('the same provider payment settles once, whatever key carries it', async () => {
++ const r = await t.call('POST', '/api/v1/purchases/settle', { body: { provider: 'powerchat', provider_ref: 'pay-1', subject: viewer, amount_cents: 1300 } });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.duplicate_receipt, true);
++ assert.strictEqual((await t.balances(viewer.id)).credit, 1000);
++ });
++
++ let donation;
++ await check('a transfer moves credit into the creator payable', async () => {
++ const r = await t.call('POST', '/api/v1/transfers', {
++ key: 'tip-key-0001', cap: ['billing.transfer.create'],
++ body: { from: viewer, to: streamer, amount: 400, kind: 'tip', message: 'gg', target: { service: 'live', type: 'stream', id: '77' } },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ donation = r.json.transaction;
++ assert.strictEqual(donation.type, 'donation');
++ assert.deepStrictEqual(donation.metadata.target, { service: 'live', type: 'stream', id: '77' });
++ assert.strictEqual(r.json.balance.credit, 600);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 400);
++ t.assertReconciled('after transfer');
++ });
++
++ await check('replaying an Idempotency-Key returns the original response and moves nothing', async () => {
++ const r = await t.call('POST', '/api/v1/transfers', {
++ key: 'tip-key-0001', cap: ['billing.transfer.create'],
++ body: { from: viewer, to: streamer, amount: 400, kind: 'tip', message: 'gg', target: { service: 'live', type: 'stream', id: '77' } },
++ });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.headers.get('idempotent-replayed'), 'true');
++ assert.strictEqual(r.json.transaction.id, donation.id);
++ assert.strictEqual((await t.balances(viewer.id)).credit, 600);
++ const other = await t.call('POST', '/api/v1/transfers', { key: 'tip-key-0001', cap: ['billing.transfer.create'], body: { from: viewer, to: streamer, amount: 1 } });
++ assert.strictEqual(other.status, 422);
++ assert.strictEqual(other.json.code, 'idempotency.key_reused');
++ });
++
++ await check('mutating calls without an Idempotency-Key are refused', async () => {
++ const r = await t.call('POST', '/api/v1/transfers', { key: null, body: { from: viewer, to: streamer, amount: 1 } });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.json.code, 'idempotency.key_required');
++ });
++
++ await check('self-dealing is refused by subject', async () => {
++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: viewer, to: { type: 'user', id: viewer.id }, amount: 10 } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json.code, 'billing.self_dealing');
++ const sub = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: viewer, streamer: viewer, source: 'credit' } });
++ assert.strictEqual(sub.json.code, 'billing.self_dealing');
++ });
++
++ await check('insufficient funds are refused atomically', async () => {
++ const r = await t.call('POST', '/api/v1/transfers', { body: { from: viewer, to: streamer, amount: 601 } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'billing.insufficient_funds');
++ assert.strictEqual(r.json.details.available, 600);
++ assert.strictEqual((await t.balances(viewer.id)).credit, 600);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 400);
++ });
++
++ await check('bought credit cannot be cashed out by its buyer', async () => {
++ const r = await t.call('POST', '/api/v1/cashouts', { body: { subject: viewer, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'billing.insufficient_funds');
++ });
++
++ await check('a credit-funded transfer can be refunded to its giver (media request that never played)', async () => {
++ const r = await t.call('POST', `/api/v1/transfers/${donation.id}/refund`, { body: { amount: 100, reason: 'request failed' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.transaction.reverses_txn, donation.id);
++ assert.strictEqual((await t.balances(viewer.id)).credit, 700);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 300);
++ const over = await t.call('POST', `/api/v1/transfers/${donation.id}/refund`, { body: { amount: 301 } });
++ assert.strictEqual(over.json.code, 'billing.already_reversed');
++ const tx = await t.call('GET', `/api/v1/transactions/${donation.id}`);
++ assert.deepStrictEqual(tx.json.reversed_by, [r.json.transaction.id]);
++ t.assertReconciled('after transfer refund');
++ });
++
++ await check('recycle moves payable back to spendable credit as a journal entry', async () => {
++ const r = await t.call('POST', '/api/v1/recycle', { body: { subject: streamer, amount: 50 } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.transaction.type, 'recycle');
++ assert.deepStrictEqual([r.json.balance.credit, r.json.balance.payable], [50, 250]);
++ t.assertReconciled('after recycle');
++ });
++
++ let cashout;
++ await check('cashout: minimum enforced, then payable goes to escrow', async () => {
++ await fund(t, viewer, 5000);
++ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: viewer, to: streamer, amount: 1000 } });
++ assert.strictEqual(tip.status, 201);
++ const small = await t.call('POST', '/api/v1/cashouts', { body: { subject: streamer, amount: 100, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual(small.json.code, 'billing.amount_too_small');
++ const r = await t.call('POST', '/api/v1/cashouts', { cap: ['billing.cashout.request'], body: { subject: streamer, amount: 1000, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual(r.status, 201, r.text);
++ cashout = r.json.cashout;
++ assert.strictEqual(cashout.status, 'requested');
++ assert.strictEqual(cashout.value_cents, 1000);
++ const b = await t.balances(streamer.id);
++ assert.deepStrictEqual([b.payable, b.pending_payouts], [250, 1000]);
++ t.assertReconciled('after cashout request');
++ });
++
++ await check('approval needs a payout reference and waits for the escrow period', async () => {
++ const noRef = await t.call('POST', `/api/v1/cashouts/${cashout.id}/approve`, { cap: ['billing.cashout.manage'], body: {} });
++ assert.strictEqual(noRef.json.code, 'billing.payout_reference_required');
++ const early = await t.call('POST', `/api/v1/cashouts/${cashout.id}/approve`, { cap: ['billing.cashout.manage'], body: { payout_reference: 'PAYPAL-BATCH-1' } });
++ assert.strictEqual(early.status, 409);
++ assert.strictEqual(early.json.code, 'billing.escrow_active');
++ const denied = await t.call('POST', `/api/v1/cashouts/${cashout.id}/approve`, { cap: ['billing.cashout.request'], body: { payout_reference: 'X' } });
++ assert.strictEqual(denied.status, 403);
++ t.clock.offset = 15 * 86_400_000;
++ const ok = await t.call('POST', `/api/v1/cashouts/${cashout.id}/approve`, { cap: ['billing.cashout.manage'], body: { payout_reference: 'PAYPAL-BATCH-1' } });
++ t.clock.offset = 0;
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json.cashout.status, 'paid');
++ assert.strictEqual(ok.json.cashout.payout_reference, 'PAYPAL-BATCH-1');
++ assert.strictEqual((await t.balances(streamer.id)).pending_payouts, 0);
++ const r = t.assertReconciled('after payout');
++ assert.strictEqual(r.checks.find((c) => c.id === 'payouts.references').ok, true);
++ });
++
++ await check('a denied cashout returns to the creator payable as a reversal', async () => {
++ assert.strictEqual((await t.call('POST', '/api/v1/transfers', { body: { from: viewer, to: streamer, amount: 500 } })).status, 201);
++ const r = await t.call('POST', '/api/v1/cashouts', { body: { subject: streamer, amount: 500, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 250);
++ const d = await t.call('POST', `/api/v1/cashouts/${r.json.cashout.id}/deny`, { body: { reason: 'suspicious' } });
++ assert.strictEqual(d.status, 200, d.text);
++ assert.strictEqual(d.json.cashout.status, 'denied');
++ const b = await t.balances(streamer.id);
++ assert.deepStrictEqual([b.payable, b.pending_payouts], [750, 0]);
++ const tx = await t.call('GET', `/api/v1/transactions/${d.json.cashout.settle_txn}`);
++ assert.strictEqual(tx.json.transaction.reverses_txn, r.json.cashout.request_txn);
++ const list = await t.call('GET', '/api/v1/cashouts?status=denied', { cap: ['billing.cashout.manage'] });
++ assert.strictEqual(list.json.cashouts.length, 1);
++ t.assertReconciled('after deny');
++ });
++
++ await check('an operator adjustment is a balanced transaction with a reason', async () => {
++ const noReason = await t.call('POST', '/api/v1/admin/adjustments', { cap: ['billing.ledger.admin'], body: { from: { kind: 'chargeback_loss', currency: 'usd-cents' }, to: { kind: 'refunds', currency: 'usd-cents' }, amount: 5 } });
++ assert.strictEqual(noReason.status, 422);
++ const mixed = await t.call('POST', '/api/v1/admin/adjustments', { body: { from: { kind: 'import_adjustment', currency: 'vibes-bits' }, to: { kind: 'refunds', currency: 'usd-cents' }, amount: 5, reason: 'x' } });
++ assert.strictEqual(mixed.status, 422);
++ const r = await t.call('POST', '/api/v1/admin/adjustments', { cap: ['billing.ledger.admin'], body: { from: { kind: 'import_adjustment', currency: 'vibes-bits' }, to: { kind: 'user_credit', owner: viewer.id, currency: 'vibes-bits' }, amount: 5, reason: 'goodwill after outage' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.transaction.type, 'adjustment');
++ assert.strictEqual(r.json.transaction.metadata.reason, 'goodwill after outage');
++ t.assertReconciled('after adjustment');
++ const denied = await t.call('POST', '/api/v1/admin/adjustments', { cap: ['billing.transfer.create'], body: {} });
++ assert.strictEqual(denied.status, 403);
++ });
++
++ await check('transaction history pages by cursor', async () => {
++ const all = [];
++ let cursor = null;
++ do {
++ const r = await t.call('GET', `/api/v1/transactions?subject=${streamer.id}&limit=2${cursor ? `&cursor=${cursor}` : ''}`);
++ assert.strictEqual(r.status, 200, r.text);
++ all.push(...r.json.transactions);
++ cursor = r.json.next_cursor;
++ } while (cursor);
++ assert.ok(all.length >= 7, `got ${all.length}`);
++ assert.strictEqual(new Set(all.map((x) => x.id)).size, all.length);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/providers.test.js b/test/providers.test.js
nnew file mode 100644
iindex 0000000..63272a1
---- /dev/null
++++ b/test/providers.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * The ported PayPal, CCBill and NOWPayments adapters (all OFF in production), enabled here against
++ * a stub provider API: verification, settlement exactly once, and refunds/chargebacks as reversals.
++ */
++const assert = require('assert');
++const http = require('http');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/app');
++const { sortObject } = require('../server/providers/nowpayments');
++
++async function startProviderStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
++ calls.push({ method: req.method, url: req.url, raw });
++ const json = (s, o) => { res.writeHead(s, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o)); };
++ if (req.url === '/v1/oauth2/token') return json(200, { access_token: 'pp-token' });
++ if (req.url === '/v1/notifications/verify-webhook-signature') {
++ const body = JSON.parse(raw);
++ return json(200, { verification_status: body.transmission_sig === 'good' ? 'SUCCESS' : 'FAILURE' });
++ }
++ if (req.url === '/v2/checkout/orders') return json(201, { id: 'PPORDER1', links: [{ rel: 'approve', href: 'https://paypal.test/approve/PPORDER1' }] });
++ const cap = req.url.match(/^\/v2\/checkout\/orders\/([^/]+)\/capture$/);
++ if (cap) {
++ const custom = calls.find((c) => c.url === '/v2/checkout/orders');
++ const intentId = JSON.parse(custom.raw).purchase_units[0].custom_id;
++ return json(201, { status: 'COMPLETED', purchase_units: [{ payments: { captures: [{ id: 'CAP-9', custom_id: intentId, amount: { currency_code: 'USD', value: '13.00' } }] } }] });
++ }
++ if (req.url === '/invoice') return json(200, { id: 777, invoice_url: 'https://nowpayments.test/invoice/777' });
++ json(404, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++(async () => {
++ const stub = await startProviderStub();
++ const t = await boot({
++ env: {
++ PAYPAL_CLIENT_ID: 'pp', PAYPAL_CLIENT_SECRET: 'pps', PAYPAL_WEBHOOK_ID: 'WH-1', PAYPAL_API_BASE: stub.url,
++ CCBILL_WEBHOOK_SECRET: 'cc-secret', CCBILL_FLEXFORM_ID: 'ff1', CCBILL_CLIENT_ACCOUNT: '900000', CCBILL_SUBACCOUNT: '0000', CCBILL_SALT: 'salt',
++ NOWPAYMENTS_IPN_SECRET: 'np-secret', NOWPAYMENTS_API_KEY: 'np-key', NOWPAYMENTS_API_BASE: stub.url,
++ },
++ });
++ const buyer = t.user(61);
++ console.log('providers (PayPal, CCBill, NOWPayments)');
++ const intent = async (provider, bits = 1000) => {
++ const r = await t.call('POST', '/api/v1/intents', { body: { provider, kind: 'purchase', subject: buyer, bits } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json;
++ };
++
++ await check('CCBill: shared secret, sale settles once with the reported price, chargeback reverses it', async () => {
++ const { intent: i, checkout_url: url } = await intent('ccbill');
++ assert.ok(url.includes(`X-intent=${i.id}`) && url.includes('initialPrice=13.00'));
++ const post = (qs, body) => fetch(`${t.base}/webhooks/ccbill?${qs}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) }).then(async (r) => ({ status: r.status, json: await r.json() }));
++ assert.strictEqual((await post('secret=nope&eventType=NewSaleSuccess', { transactionId: 'T1', 'X-intent': i.id, billedInitialPrice: '13.00' })).status, 401);
++ const noPrice = await post('secret=cc-secret&eventType=NewSaleSuccess', { transactionId: 'T0', 'X-intent': i.id });
++ assert.strictEqual(noPrice.json.result.review, true);
++ const ok = await post('secret=cc-secret&eventType=NewSaleSuccess', { transactionId: 'T1', 'X-intent': i.id, billedInitialPrice: '13.00' });
++ assert.strictEqual(ok.json.result.effect, 'settled', JSON.stringify(ok.json));
++ assert.strictEqual(t.db.prepare("SELECT payload FROM provider_events WHERE provider = 'ccbill' AND provider_event_id = 'NewSaleSuccess:T1'").get().payload.includes('cc-secret'), false, 'the secret is never stored');
++ const again = await post('secret=cc-secret&eventType=NewSaleSuccess', { transactionId: 'T1', 'X-intent': i.id, billedInitialPrice: '13.00' });
++ assert.strictEqual(again.json.duplicate, true);
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ const cb = await post('secret=cc-secret&eventType=Chargeback', { transactionId: 'T1', amount: '13.00' });
++ assert.strictEqual(cb.json.result.effect, 'settled', JSON.stringify(cb.json));
++ assert.strictEqual((await t.balances(buyer.id)).credit, 0);
++ t.assertReconciled('after ccbill');
++ });
++
++ await check('NOWPayments: HMAC-SHA512 IPN, several statuses settle one payment once, refunded reverses', async () => {
++ const { intent: i, checkout_url: url } = await intent('nowpayments');
++ assert.strictEqual(url, 'https://nowpayments.test/invoice/777');
++ const ipn = (body, secret = 'np-secret') => {
++ const sig = crypto.createHmac('sha512', secret).update(JSON.stringify(sortObject(body))).digest('hex');
++ return fetch(`${t.base}/webhooks/nowpayments`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nowpayments-sig': sig }, body: JSON.stringify(body) })
++ .then(async (r) => ({ status: r.status, json: await r.json() }));
++ };
++ const body = { payment_id: 55, order_id: i.id, price_amount: 13, price_currency: 'usd' };
++ assert.strictEqual((await ipn({ ...body, payment_status: 'finished' }, 'wrong')).status, 401);
++ const a = await ipn({ ...body, payment_status: 'confirmed' });
++ assert.strictEqual(a.json.result.effect, 'settled', JSON.stringify(a.json));
++ const b = await ipn({ ...body, payment_status: 'finished' });
++ assert.strictEqual(b.json.result.effect, 'duplicate_receipt');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ const r = await ipn({ ...body, payment_status: 'refunded' });
++ assert.strictEqual(r.json.result.effect, 'settled');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 0);
++ t.assertReconciled('after nowpayments');
++ });
++
++ await check('PayPal: verified webhooks, capture after return settles once, reversal is a chargeback', async () => {
++ const { intent: i, checkout_url: url } = await intent('paypal');
++ assert.strictEqual(url, 'https://paypal.test/approve/PPORDER1');
++ const cap = await t.call('POST', `/api/v1/intents/${i.id}/capture`, { body: {} });
++ assert.strictEqual(cap.status, 200, cap.text);
++ assert.strictEqual(cap.json.result.effect, 'settled');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ const hook = (event, sig = 'good') => fetch(`${t.base}/webhooks/paypal`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json', 'paypal-transmission-sig': sig, 'paypal-transmission-id': 'x', 'paypal-auth-algo': 'SHA256withRSA' }, body: JSON.stringify(event),
++ }).then(async (r) => ({ status: r.status, json: await r.json() }));
++ const completed = { id: 'WH-EVT-1', event_type: 'PAYMENT.CAPTURE.COMPLETED', resource: { id: 'CAP-9', custom_id: i.id, amount: { currency_code: 'USD', value: '13.00' } } };
++ assert.strictEqual((await hook(completed, 'bad')).status, 401);
++ const dup = await hook(completed);
++ assert.strictEqual(dup.json.result.effect, 'duplicate_receipt');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ const rev = await hook({ id: 'WH-EVT-2', event_type: 'PAYMENT.CAPTURE.REVERSED', resource: { id: 'RF-1', amount: { currency_code: 'USD', value: '13.00' }, links: [{ rel: 'up', href: 'https://api.paypal.test/v2/payments/captures/CAP-9' }] } });
++ assert.strictEqual(rev.json.result.effect, 'settled', JSON.stringify(rev.json));
++ const tx = (await t.call('GET', `/api/v1/transactions/${rev.json.result.txn_id}`)).json.transaction;
++ assert.strictEqual(tx.type, 'chargeback');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 0);
++ t.assertReconciled('after paypal');
++ });
++
++ await t.close();
++ await stub.close();
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..afc096e
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
++ * if any of them fails. They use in-process stubs of OpenVibe.Network, OpenVibe.Events and the
++ * payment providers, temp databases and random ports; none needs the network or a running service.
++ *
++ * npm test # everything
++ * npm test -- import webhooks # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(28)} ${String(r.ms).padStart(6)}ms`);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();
ddiff --git a/test/stripe.test.js b/test/stripe.test.js
nnew file mode 100644
iindex 0000000..735ccca
---- /dev/null
++++ b/test/stripe.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * The Stripe adapter against a stub Stripe API: checkout intents, purchase settlement, the
++ * creator share on EVERY paid invoice (Live only moved the date on renewals), refunds and
++ * disputes as reversals, cancel sent to Stripe, and adapters staying off without secrets.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ stripe: true });
++ const buyer = t.user(41);
++ const streamer = t.user(42);
++ console.log('stripe adapter');
++ const evt = (id, type, object) => ({ id, type, data: { object } });
++
++ await check('adapters are enabled only by their secrets', async () => {
++ const h = await t.call('GET', '/api/health', { token: null });
++ assert.deepStrictEqual(h.json.providers, { powerchat: true, stripe: true, paypal: false, ccbill: false, nowpayments: false });
++ const r = await t.call('POST', '/api/v1/intents', { body: { provider: 'paypal', kind: 'purchase', subject: buyer, bits: 1000 } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'billing.provider_disabled');
++ const w = await fetch(`${t.base}/webhooks/nowpayments`, { method: 'POST', body: '{}' });
++ assert.strictEqual(w.status, 404);
++ });
++
++ let buyIntent;
++ await check('a purchase intent opens a Stripe checkout priced by Billing', async () => {
++ const r = await t.call('POST', '/api/v1/intents', { body: { provider: 'stripe', kind: 'purchase', subject: buyer, bits: 2500, amount_cents: 1 } });
++ assert.strictEqual(r.status, 201, r.text);
++ buyIntent = r.json.intent;
++ assert.strictEqual(buyIntent.amount_cents, 3100);
++ assert.match(r.json.checkout_url, /checkout\.stripe\.test/);
++ const call = t.stripe.calls.find((c) => c.url === '/checkout/sessions');
++ assert.strictEqual(call.body['metadata[intent_id]'], buyIntent.id);
++ assert.strictEqual(call.body['line_items[0][price_data][unit_amount]'], '3100');
++ assert.strictEqual(call.auth, 'Bearer sk_test_x');
++ });
++
++ await check('checkout.session.completed settles once; a bad signature is refused', async () => {
++ const e = evt('evt_1', 'checkout.session.completed', { id: 'cs_1', mode: 'payment', payment_status: 'paid', payment_intent: 'pi_A', amount_total: 3100, metadata: { intent_id: buyIntent.id } });
++ const bad = await t.stripeEvent(e, { secret: 'nope' });
++ assert.strictEqual(bad.status, 401);
++ const r = await t.stripeEvent(e);
++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
++ assert.strictEqual((await t.balances(buyer.id)).credit, 2500);
++ const dup = await t.stripeEvent(e);
++ assert.strictEqual(dup.json.duplicate, true);
++ assert.strictEqual((await t.balances(buyer.id)).credit, 2500);
++ });
++
++ let subIntent;
++ await check('invoice.paid credits the streamer share on the first period AND on every renewal', async () => {
++ const r = await t.call('POST', '/api/v1/intents', { body: { provider: 'stripe', kind: 'subscription', subject: buyer, streamer } });
++ subIntent = r.json.intent;
++ assert.strictEqual(subIntent.amount_cents, 499);
++ assert.strictEqual(t.stripe.calls[t.stripe.calls.length - 1].body.mode, 'subscription');
++ const link = await t.stripeEvent(evt('evt_2', 'checkout.session.completed', { id: 'cs_2', mode: 'subscription', subscription: 'sub_S1', metadata: { intent_id: subIntent.id } }));
++ assert.strictEqual(link.json.result.effect, 'updated');
++ const end1 = Math.floor(Date.now() / 1000) + 30 * 86400;
++ const first = await t.stripeEvent(evt('evt_3', 'invoice.paid', { id: 'in_1', subscription: 'sub_S1', amount_paid: 499, lines: { data: [{ period: { end: end1 } }] } }));
++ assert.strictEqual(first.json.result.effect, 'settled', JSON.stringify(first.json));
++ assert.strictEqual((await t.balances(streamer.id)).payable, 349);
++ const renewal = await t.stripeEvent(evt('evt_4', 'invoice.paid', { id: 'in_2', subscription: 'sub_S1', amount_paid: 499, lines: { data: [{ period: { end: end1 + 30 * 86400 } }] } }));
++ assert.strictEqual(renewal.json.result.effect, 'settled');
++ assert.strictEqual((await t.balances(streamer.id)).payable, 698, 'share credited again on renewal');
++ const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`);
++ assert.strictEqual(e.json.active, true);
++ assert.strictEqual(Date.parse(e.json.expires_at), (end1 + 30 * 86400) * 1000);
++ t.assertReconciled('after stripe invoices');
++ });
++
++ await check('a refunded renewal is a reversal: the period is revoked, the share stays (review)', async () => {
++ const r = await t.stripeEvent(evt('evt_5', 'charge.refunded', { id: 'ch_2', invoice: 'in_2', payment_intent: 'pi_in2', amount_refunded: 499 }));
++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
++ const tx = (await t.call('GET', `/api/v1/transactions/${r.json.result.txn_id}`)).json.transaction;
++ assert.strictEqual(tx.type, 'refund');
++ assert.strictEqual(tx.metadata.review, 'required');
++ assert.strictEqual((await t.balances(streamer.id)).payable, 698);
++ const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`);
++ assert.strictEqual(e.json.active, true, 'the first paid period still holds');
++ assert.ok(Date.parse(e.json.expires_at) < Date.now() + 31 * 86_400_000);
++ t.assertReconciled('after stripe refund');
++ });
++
++ await check('a partial refund then a dispute on the purchase trace to the original', async () => {
++ const p = await t.stripeEvent(evt('evt_6', 'charge.refunded', { id: 'ch_A', payment_intent: 'pi_A', amount_refunded: 1240 }));
++ assert.strictEqual(p.json.result.effect, 'settled', JSON.stringify(p.json));
++ let tx = (await t.call('GET', `/api/v1/transactions/${p.json.result.txn_id}`)).json.transaction;
++ assert.deepStrictEqual([tx.metadata.cents, tx.metadata.bits_reversed], [1240, 1000]);
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1500);
++ const d = await t.stripeEvent(evt('evt_7', 'charge.dispute.funds_withdrawn', { id: 'dp_1', payment_intent: 'pi_A', amount: 3100, reason: 'fraudulent' }));
++ assert.strictEqual(d.json.result.effect, 'settled', JSON.stringify(d.json));
++ tx = (await t.call('GET', `/api/v1/transactions/${d.json.result.txn_id}`)).json.transaction;
++ assert.strictEqual(tx.type, 'chargeback');
++ assert.strictEqual(tx.metadata.cents, 1860, 'only what was not refunded yet');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 0);
++ const orig = (await t.call('GET', `/api/v1/transactions/${tx.reverses_txn}`)).json;
++ assert.strictEqual(orig.reversed_by.length, 2);
++ t.assertReconciled('after dispute');
++ });
++
++ await check('cancel is sent to Stripe (cancel_at_period_end) before Billing records it', async () => {
++ const sub = (await t.call('GET', `/api/v1/subscriptions?subscriber=${buyer.id}`)).json.subscriptions[0];
++ assert.strictEqual(sub.provider, 'stripe');
++ t.stripe.state.fail = true;
++ const failed = await t.call('POST', `/api/v1/subscriptions/${sub.id}/cancel`, { body: {} });
++ assert.strictEqual(failed.status, 502);
++ assert.strictEqual((await t.call('GET', `/api/v1/subscriptions/${sub.id}`)).json.subscription.cancel_at_period_end, false);
++ t.stripe.state.fail = false;
++ const ok = await t.call('POST', `/api/v1/subscriptions/${sub.id}/cancel`, { body: {} });
++ assert.strictEqual(ok.status, 200, ok.text);
++ assert.strictEqual(ok.json.provider_sync, 'stripe_cancel_at_period_end');
++ assert.strictEqual(ok.json.subscription.cancel_at_period_end, true);
++ const call = t.stripe.calls[t.stripe.calls.length - 1];
++ assert.deepStrictEqual([call.method, call.url, call.body.cancel_at_period_end], ['POST', '/subscriptions/sub_S1', 'true']);
++ const del = await t.stripeEvent(evt('evt_8', 'customer.subscription.deleted', { id: 'sub_S1' }));
++ assert.strictEqual(del.json.result.effect, 'updated');
++ assert.strictEqual((await t.call('GET', `/api/v1/subscriptions/${sub.id}`)).json.subscription.status, 'canceled');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/subscriptions.test.js b/test/subscriptions.test.js
nnew file mode 100644
iindex 0000000..8c6fd4f
---- /dev/null
++++ b/test/subscriptions.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * Subscriptions paid from credit: the creator share on every period (first and renewals),
++ * entitlements, the renewal sweep, cancel-at-period-end, and entitlement reads that need nothing
++ * but Billing (Live and even the Network may be down).
++ */
++const assert = require('assert');
++const { boot, fund, check, done } = require('./helpers/app');
++
++const DAY = 86_400_000;
++
++(async () => {
++ const t = await boot();
++ const fan = t.user(31);
++ const streamer = t.user(32);
++ console.log('subscriptions');
++
++ let sub;
++ await check('subscribing from credit credits the streamer share and grants an entitlement', async () => {
++ await fund(t, fan, 1000);
++ const r = await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: fan, streamer, source: 'credit' } });
++ assert.strictEqual(r.status, 201, r.text);
++ sub = r.json.subscription;
++ assert.strictEqual(sub.status, 'active');
++ assert.strictEqual(sub.auto_renew, true);
++ const m = r.json.transaction.metadata;
++ assert.deepStrictEqual([m.cost_bits, m.share_bits, m.renewal], [499, 349, false]);
++ assert.strictEqual((await t.balances(fan.id)).credit, 501);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 349);
++ const rev = r.json.transaction.entries.find((e) => e.account.kind === 'platform_revenue');
++ assert.strictEqual(rev.amount, 150);
++ assert.strictEqual(r.json.entitlement.active, true);
++ assert.ok(Math.abs(Date.parse(r.json.entitlement.expires_at) - (Date.now() + 31 * DAY)) < 60_000);
++ t.assertReconciled('after credit sub');
++ });
++
++ await check('an early renewal credits the share again and extends the entitlement', async () => {
++ const before = Date.parse((await t.call('GET', `/api/v1/entitlements/${fan.id}?streamer=${streamer.id}`)).json.expires_at);
++ const r = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: fan, streamer, source: 'credit' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.transaction.metadata.renewal, true);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 698);
++ const after = Date.parse(r.json.entitlement.expires_at);
++ assert.strictEqual(after - before, 31 * DAY);
++ t.assertReconciled('after renewal');
++ });
++
++ await check('insufficient credit refuses a subscription and changes nothing', async () => {
++ const r = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: fan, streamer, source: 'credit' } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'billing.insufficient_funds');
++ });
++
++ await check('a receipt-paid subscription through the API needs billing.ledger.admin', async () => {
++ const r = await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: t.user(33), streamer, source: 'receipt', receipt: { provider: 'powerchat', provider_ref: 'x1', amount_cents: 549, fee_cents: 50, route: 'site' } } });
++ assert.strictEqual(r.status, 403);
++ const ok = await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage', 'billing.ledger.admin'], body: { subscriber: t.user(33), streamer, source: 'receipt', receipt: { provider: 'powerchat', provider_ref: 'x1', amount_cents: 549, fee_cents: 50, route: 'site' } } });
++ assert.strictEqual(ok.status, 201, ok.text);
++ assert.strictEqual(ok.json.transaction.metadata.share_bits, 349);
++ });
++
++ await check('the renewal sweep renews from credit (share credited) and ends unaffordable subs', async () => {
++ await fund(t, fan, 499 - 2); // exactly one more period (2 bits left + 497)
++ t.clock.offset = 63 * DAY; // both paid periods are over
++ const s1 = await t.call('POST', '/api/v1/admin/sweep', { key: null });
++ assert.strictEqual(s1.status, 200, s1.text);
++ assert.deepStrictEqual(s1.json.renewed, [sub.id]);
++ assert.strictEqual((await t.balances(streamer.id)).payable, 698 + 349 + 349);
++ assert.strictEqual((await t.balances(fan.id)).credit, 0);
++ t.clock.offset = 95 * DAY;
++ const s2 = await t.call('POST', '/api/v1/admin/sweep', { key: null });
++ assert.deepStrictEqual(s2.json.expired, [sub.id]);
++ const e = await t.call('GET', `/api/v1/entitlements/${fan.id}?streamer=${streamer.id}`);
++ assert.strictEqual(e.json.active, false);
++ assert.strictEqual(e.json.subscription.status, 'expired');
++ t.clock.offset = 0;
++ t.assertReconciled('after sweeps');
++ });
++
++ await check('cancel keeps access until the period ends, then the sweep ends it', async () => {
++ const f2 = t.user(34);
++ await fund(t, f2, 499);
++ const s = await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: f2, streamer, source: 'credit' } });
++ const c = await t.call('POST', `/api/v1/subscriptions/${s.json.subscription.id}/cancel`, { cap: ['billing.subscription.manage'], body: {} });
++ assert.strictEqual(c.status, 200, c.text);
++ assert.strictEqual(c.json.subscription.cancel_at_period_end, true);
++ assert.strictEqual(c.json.subscription.auto_renew, false);
++ assert.strictEqual(c.json.provider_sync, 'not_needed');
++ assert.strictEqual((await t.call('GET', `/api/v1/entitlements/${f2.id}?streamer=${streamer.id}`)).json.active, true);
++ t.clock.offset = 32 * DAY;
++ const sw = await t.call('POST', '/api/v1/admin/sweep', { key: null });
++ assert.ok(sw.json.canceled.includes(s.json.subscription.id));
++ assert.strictEqual((await t.call('GET', `/api/v1/entitlements/${f2.id}?streamer=${streamer.id}`)).json.active, false);
++ t.clock.offset = 0;
++ });
++
++ await check('entitlements are answered by Billing alone, with Live absent and the Network down', async () => {
++ t.network.state.down = true;
++ const f3 = t.user(35);
++ await fund(t, f3, 499);
++ await t.call('POST', '/api/v1/subscriptions', { body: { subscriber: f3, streamer, source: 'credit' } });
++ const one = await t.call('GET', `/api/v1/entitlements/${f3.id}?streamer=${streamer.id}`, { cap: ['billing.entitlement.check'] });
++ assert.strictEqual(one.status, 200);
++ assert.strictEqual(one.json.active, true);
++ const all = await t.call('GET', `/api/v1/entitlements/${f3.id}`, { cap: ['billing.entitlement.check'] });
++ assert.strictEqual(all.json.entitlements.length, 1);
++ assert.strictEqual(all.json.entitlements[0].streamer.id, streamer.id);
++ const list = await t.call('GET', `/api/v1/subscriptions?streamer=${streamer.id}&status=active`, { cap: ['billing.entitlement.check'] });
++ assert.ok(list.json.subscriptions.some((s) => s.subscriber.id === f3.id));
++ t.network.state.down = false;
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
nnew file mode 100644
iindex 0000000..1764359
---- /dev/null
++++ b/test/webhooks.test.js
@@@ -0,0 +1,204 @@
++'use strict';
++/**
++ * PowerChat receipts: verification, exactly-once effects, every checkout route, EXTERNAL tips,
++ * refunds/chargebacks tracing to their original, and the freeze switch queueing deliveries.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const buyer = t.user(21);
++ const streamer = t.user(22);
++ const pcdonStreamerLiveId = 23;
++ const pcdonStreamer = t.network.addUser(pcdonStreamerLiveId);
++ console.log('webhooks (PowerChat)');
++
++ const intent = async (body) => {
++ const r = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', ...body } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json.intent;
++ };
++ const donation = (data, streamerName = 'openvibe') => ({ type: 'donation.completed', streamer: { id: 'pc1', username: streamerName }, data: { eventId: `don-${Math.random().toString(36).slice(2)}`, ...data } });
++ const eventCount = () => t.db.prepare('SELECT COUNT(*) AS n FROM provider_events').get().n;
++
++ await check('an unsigned or stale delivery is refused and not stored', async () => {
++ const bad = await t.powerchat(donation({ amountUsdCents: 100 }), { secret: 'wrong' });
++ assert.strictEqual(bad.status, 401);
++ const stale = await t.powerchat(donation({ amountUsdCents: 100 }), { ts: Date.now() - 16 * 60 * 1000 });
++ assert.strictEqual(stale.status, 401);
++ assert.strictEqual(eventCount(), 0);
++ });
++
++ let buyIntent;
++ await check('pcorder: a confirmed tip settles the purchase intent into credit', async () => {
++ buyIntent = await intent({ kind: 'purchase', subject: buyer, bits: 1000 });
++ assert.strictEqual(buyIntent.amount_cents, 1300);
++ assert.strictEqual(buyIntent.checkout_ref, `pcorder:${buyIntent.id}`);
++ const r = await t.powerchat(donation({ eventId: 'don-A', amountUsdCents: 1300, appExternalRef: buyIntent.checkout_ref }), { deliveryId: 'dlv-A' });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json.result.effect, 'settled');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ const i = await t.call('GET', `/api/v1/intents/${buyIntent.id}`);
++ assert.strictEqual(i.json.intent.status, 'settled');
++ t.assertReconciled('after pcorder');
++ });
++
++ await check('a duplicated delivery produces exactly one accounting effect', async () => {
++ const again = await t.powerchat(donation({ eventId: 'don-A', amountUsdCents: 1300, appExternalRef: buyIntent.checkout_ref }), { deliveryId: 'dlv-A' });
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json.duplicate, true);
++ const redelivered = await t.powerchat(donation({ eventId: 'don-A', amountUsdCents: 1300, appExternalRef: buyIntent.checkout_ref }), { deliveryId: 'dlv-A-retry' });
++ assert.strictEqual(redelivered.json.result.effect, 'duplicate_receipt');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM transactions WHERE receipt_ref = 'powerchat:don-A'").get().n, 1);
++ t.assertReconciled('after duplicates');
++ });
++
++ await check('test deliveries and app echoes move no money', async () => {
++ const test = await t.powerchat(donation({ amountUsdCents: 500, isTest: true, appExternalRef: 'pcorder:whatever' }));
++ assert.strictEqual(test.json.result.effect, 'none');
++ const manual = await t.powerchat(donation({ amountUsdCents: 500, source: 'manual_test', appExternalRef: 'pcorder:whatever' }));
++ assert.strictEqual(manual.json.result.effect, 'none');
++ const echo = await t.powerchat(donation({ amountUsdCents: 500, source: 'developer_app' }));
++ assert.match(echo.json.result.reason, /echo/);
++ });
++
++ await check('pcsub via the site account: the streamer share is credited, the fee is platform revenue', async () => {
++ const i = await intent({ kind: 'subscription', subject: buyer, streamer, route: 'site' });
++ assert.deepStrictEqual([i.amount_cents, i.fee_cents, i.route], [549, 50, 'site']);
++ const r = await t.powerchat(donation({ amountUsdCents: 549, appExternalRef: i.checkout_ref }));
++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
++ assert.strictEqual((await t.balances(streamer.id)).payable, 349);
++ const e = await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`, { cap: ['billing.entitlement.check'] });
++ assert.strictEqual(e.json.active, true);
++ const tx = t.db.prepare('SELECT metadata FROM transactions WHERE id = ?').get(r.json.result.txn_id);
++ const m = JSON.parse(tx.metadata);
++ assert.deepStrictEqual([m.paid_cents, m.fee_cents, m.share_bits], [549, 50, 349]);
++ t.assertReconciled('after site sub');
++ });
++
++ await check('pcsub direct is EXTERNAL: the entitlement is granted, no money is booked', async () => {
++ const other = t.user(24);
++ const i = await intent({ kind: 'subscription', subject: other, streamer, route: 'direct' });
++ assert.strictEqual(i.amount_cents, 499);
++ const before = (await t.balances(streamer.id)).payable;
++ const r = await t.powerchat(donation({ amountUsdCents: 499, appExternalRef: i.checkout_ref }, 'streamerpc'));
++ assert.strictEqual(r.json.result.effect, 'settled');
++ const txn = await t.call('GET', `/api/v1/transactions/${r.json.result.txn_id}`);
++ assert.deepStrictEqual(txn.json.transaction.entries, []);
++ assert.strictEqual(txn.json.transaction.metadata.external, true);
++ assert.strictEqual((await t.balances(streamer.id)).payable, before);
++ const e = await t.call('GET', `/api/v1/entitlements/${other.id}?streamer=${streamer.id}`);
++ assert.strictEqual(e.json.active, true);
++ });
++
++ await check('an underpaid site subscription becomes a plain site-routed tip', async () => {
++ const i = await intent({ kind: 'subscription', subject: t.user(25), streamer, route: 'site' });
++ const before = (await t.balances(streamer.id)).payable;
++ const r = await t.powerchat(donation({ amountUsdCents: 300, appExternalRef: i.checkout_ref }));
++ assert.strictEqual(r.json.result.effect, 'settled');
++ assert.strictEqual((await t.balances(streamer.id)).payable, before + 300);
++ });
++
++ await check('pcdon (legacy Live ids) resolves the streamer and credits their payable (MONEY)', async () => {
++ const r = await t.powerchat(donation({ amountUsdCents: 250, appExternalRef: `pcdon:${pcdonStreamerLiveId}:21` }));
++ assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
++ assert.strictEqual((await t.balances(pcdonStreamer)).payable, 250);
++ assert.ok(t.network.resolveCalls.some((c) => c.system === 'live' && c.ids.includes(String(pcdonStreamerLiveId))));
++ const unknown = await t.powerchat(donation({ amountUsdCents: 250, appExternalRef: 'pcdon:999:0' }));
++ assert.strictEqual(unknown.json.result.effect, 'none');
++ assert.strictEqual(unknown.json.result.review, true);
++ });
++
++ await check("a tip on a streamer's own PowerChat is EXTERNAL; an unattributed site tip is flagged", async () => {
++ const ext = await t.powerchat(donation({ amountUsdCents: 1000, appExternalRef: 'goal:12' }, 'somestreamer'));
++ assert.match(ext.json.result.reason, /EXTERNAL/);
++ const site = await t.powerchat(donation({ amountUsdCents: 1000 }, 'openvibe'));
++ assert.strictEqual(site.json.result.review, true);
++ const rec = t.assertReconciled('after none-effects');
++ assert.ok(rec.warnings.events_for_review.length >= 2);
++ });
++
++ await check('a refund traces to the original purchase and claws the credit back', async () => {
++ const i = await intent({ kind: 'purchase', subject: buyer, bits: 500 });
++ const pay = await t.powerchat(donation({ eventId: 'don-R', amountUsdCents: 700, appExternalRef: i.checkout_ref }));
++ assert.strictEqual(pay.json.result.effect, 'settled');
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1500);
++ const ref = await t.powerchat({ type: 'donation.refunded', streamer: { username: 'openvibe' }, data: { eventId: 'rf-1', originalEventId: 'don-R', refundedUsdCents: 700 } });
++ assert.strictEqual(ref.json.result.effect, 'settled', JSON.stringify(ref.json));
++ const tx = await t.call('GET', `/api/v1/transactions/${ref.json.result.txn_id}`);
++ assert.strictEqual(tx.json.transaction.type, 'refund');
++ assert.strictEqual(tx.json.transaction.reverses_txn, pay.json.result.txn_id);
++ assert.strictEqual(tx.json.transaction.metadata.bits_clawed_back, 500);
++ assert.strictEqual((await t.balances(buyer.id)).credit, 1000);
++ const orig = await t.call('GET', `/api/v1/transactions/${pay.json.result.txn_id}`);
++ assert.deepStrictEqual(orig.json.reversed_by, [tx.json.transaction.id]);
++ assert.strictEqual(orig.json.transaction.entries.length, 5, 'the original stays as it was');
++ t.assertReconciled('after refund');
++ });
++
++ await check('a chargeback on credit already donated leaves the payable intact and books the loss for review', async () => {
++ const payer = t.user(26);
++ const i = await intent({ kind: 'purchase', subject: payer, bits: 1000 });
++ const pay = await t.powerchat(donation({ eventId: 'don-C', amountUsdCents: 1300, appExternalRef: i.checkout_ref }));
++ assert.strictEqual(pay.json.result.effect, 'settled');
++ const tip = await t.call('POST', '/api/v1/transfers', { body: { from: payer, to: streamer, amount: 800 } });
++ assert.strictEqual(tip.status, 201);
++ const payableBefore = (await t.balances(streamer.id)).payable;
++ const cb = await t.powerchat({ type: 'donation.chargeback', streamer: { username: 'openvibe' }, data: { eventId: 'cb-1', originalEventId: 'don-C' } });
++ assert.strictEqual(cb.json.result.effect, 'settled', JSON.stringify(cb.json));
++ assert.strictEqual(cb.json.result.review, true);
++ assert.strictEqual((await t.balances(streamer.id)).payable, payableBefore, 'recipient payable untouched');
++ assert.strictEqual((await t.balances(payer.id)).credit, 0);
++ const tx = (await t.call('GET', `/api/v1/transactions/${cb.json.result.txn_id}`)).json.transaction;
++ assert.strictEqual(tx.type, 'chargeback');
++ assert.strictEqual(tx.reverses_txn, pay.json.result.txn_id);
++ assert.deepStrictEqual([tx.metadata.bits_clawed_back, tx.metadata.unrecovered_bits, tx.metadata.review], [200, 800, 'required']);
++ const loss = tx.entries.find((e) => e.account.kind === 'chargeback_loss');
++ assert.strictEqual(loss.amount, -1100, 'unrecovered 800 + spread 300');
++ const rec = t.assertReconciled('after chargeback');
++ assert.ok(rec.warnings.transactions_for_review.some((x) => x.id === tx.id));
++ assert.strictEqual(rec.totals.chargeback_loss_cents, -1100);
++ const again = await t.powerchat({ type: 'donation.chargeback', streamer: { username: 'openvibe' }, data: { eventId: 'cb-2', originalEventId: 'don-C' } });
++ assert.strictEqual(again.json.result.effect, 'none');
++ assert.strictEqual(again.json.result.reason, 'already_reversed');
++ });
++
++ await check('a refund for an unknown payment is recorded as rejected', async () => {
++ const r = await t.powerchat({ type: 'donation.refunded', streamer: { username: 'openvibe' }, data: { eventId: 'rf-x', originalEventId: 'nope' } });
++ assert.strictEqual(r.json.result.effect, 'rejected');
++ assert.strictEqual(r.json.result.code, 'billing.original_not_found');
++ });
++
++ await check('freeze: writes answer 503 billing.frozen, reads work, webhooks are stored and processed after unfreeze', async () => {
++ const pending = await intent({ kind: 'purchase', subject: buyer, bits: 100 });
++ const f = await t.call('POST', '/api/v1/admin/freeze', { cap: ['billing.ledger.admin'], key: null, body: { on: true, reason: 'cutover' } });
++ assert.strictEqual(f.status, 200, f.text);
++ assert.strictEqual(f.json.frozen, true);
++ const w = await t.call('POST', '/api/v1/transfers', { body: { from: buyer, to: streamer, amount: 1 } });
++ assert.strictEqual(w.status, 503);
++ assert.strictEqual(w.json.code, 'billing.frozen');
++ const c = await t.call('POST', '/api/v1/cashouts', { body: { subject: streamer, amount: 600, payout_method: { type: 'paypal', address: '[email protected]' } } });
++ assert.strictEqual(c.json.code, 'billing.frozen');
++ const i = await t.call('POST', '/api/v1/intents', { body: { provider: 'powerchat', kind: 'purchase', subject: buyer, bits: 100 } });
++ assert.strictEqual(i.json.code, 'billing.frozen');
++ assert.strictEqual((await t.call('GET', `/api/v1/balances/${buyer.id}`)).status, 200);
++ assert.strictEqual((await t.call('GET', `/api/v1/entitlements/${buyer.id}?streamer=${streamer.id}`)).json.active, true);
++ assert.strictEqual((await t.call('GET', '/api/health', { token: null })).json.frozen, true);
++ const credit = (await t.balances(buyer.id)).credit;
++ const q = await t.powerchat(donation({ eventId: 'don-F', amountUsdCents: 150, appExternalRef: pending.checkout_ref }));
++ assert.strictEqual(q.status, 202);
++ assert.strictEqual(q.json.queued, true);
++ assert.strictEqual((await t.balances(buyer.id)).credit, credit);
++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM provider_events WHERE processed_at IS NULL").get().n, 1);
++ const off = await t.call('POST', '/api/v1/admin/freeze', { key: null, body: { on: false } });
++ assert.strictEqual(off.json.frozen, false);
++ assert.strictEqual(off.json.processed_after_unfreeze.processed, 1);
++ assert.strictEqual((await t.balances(buyer.id)).credit, credit + 100);
++ t.assertReconciled('after unfreeze');
++ });
++
++ await t.close();
++ done();
++})();