openvibers/openvibe.billing Commits

Cutover runbook checked against production, and Live's money tables mapped to Billing

OP OpenVibers committed 23 Sept 2026 · 2169593a4f · 3 files, +239 −36

- docs/live-cutover.md: LIVE_DB is /opt/openvibe.live/data/live.db (Live runs the legacy layout; there is no shared/). The final snapshot is read from /var/lib/openvibe-billing, because /var/backups/openvibe is root-only (0700) and the import runs as ubuntu; a copy is kept in /var/backups/openvibe for rollback. Live's env switch needs deploy.sh --restart --wait-idle (without --restart an unchanged commit is not restarted, so the env is never read), in the sequence and in the rollback. Step 2 reads Live's database with sudo sqlite3 -readonly. Step 3 also sets BILLING_AUTHORITY=billing in billing.env, so EXTERNAL tips held from step 4 are announced at step 7; step 6 switches Tips to TIPS_CHAT_ADAPTER=live-chat; before the day, Tips subscribes to billing.receipt.* and the import maps every PowerChat connection. The verification includes an EXTERNAL tip. The rollback resets both switches. "What the switch does not preserve" 1 now says what the Billing → Tips → Live path carries and what it does not (Live's own donation_goals, PowerChat follow/host/points/sub notices). Items already done on the host are marked done.
- docs/live-mapping.md: every Live money table and setting and where it goes in Billing (or Tips, VIP, or nowhere, with the reason), including payouts (transactions type cashout → cashouts), refunds (refund rows, denied cashouts, media-request refunds with no row) and plans (site-wide price settings and subscriptions.tier → BILLING_* rates, Billing subscriptions, VIP plans), with production counts.
- STATUS.json: what is built since b59725e and not deployed.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 07ecb2a..b70b0ba 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,6 +7,6 @@
"adr": "ADR-012",
"authoritative": false,
"deployment": "Deployed in shadow on openvibe-ovh: /opt/openvibe.billing (release b59725e), unit openvibe-billing, API on 127.0.0.1:4600 (loopback only; /api/v1 is denied on the public host). Public at https://billing.openvibe.network: the staff console (Network SSO + PKCE, BILLING_STAFF_SUBJECTS = the owner only), /api/health and /webhooks/*. No provider secret is configured, so every provider is off and /webhooks/* answers 404 billing.provider_disabled. Pins openvibe-contracts v0.19.0.",
-- "notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built and tested. Capabilities (8 billing.*) and events are registered in OpenVibe.Contracts (v0.8.0; billing.staff.action in v0.17.0). Production holds a shadow import of a Live snapshot (322 transactions: 304 imported plus 18 opening adjustments; 3 reconciliation runs, the last 2026-09-23 01:21 UTC, all checks ok), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). The restore drill passed on the host 2026-09-23 05:22 UTC. The Live side (Billing client behind BILLING_AUTHORITY, money freeze) is deployed in Live since c384787, but BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) has not been executed: it waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat. Not done: /metrics (404), scheduled reconciliation, EXTERNAL PowerChat tips produce no event for Tips, payouts/refunds/plans are represented by cashouts, refund transactions and VIP plans rather than their own tables.",
++ "notes": "Runtime, journal, adapters, import, reconciliation and the staff console (cashout approve/deny, review queue, reconciliation, freeze, audit log) are built and tested. Capabilities (8 billing.*) and events are registered in OpenVibe.Contracts (v0.8.0; billing.staff.action in v0.17.0). Production holds a shadow import of a Live snapshot (322 transactions: 304 imported plus 18 opening adjustments; 3 reconciliation runs, the last 2026-09-23 01:21 UTC, all checks ok), 0 subscriptions and 0 entitlements; the console has never been used (staff_audit 0). The restore drill passed on the host 2026-09-23 05:22 UTC. The Live side (Billing client behind BILLING_AUTHORITY, money freeze) is deployed in Live since c384787, but BILLING_AUTHORITY is not set in live.env, so Live stays the money authority. The cutover (README runbook, docs/live-cutover.md) has not been executed: it waits on the owner putting the PowerChat secret in billing.env and re-pointing the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat. Built, not yet deployed (after b59725e): /metrics (openvibe-shared v1.3.0, loopback only); hourly scheduled reconciliation stored with its trigger and exposed at GET /api/v1/admin/reconciliations, in the console and on /metrics, with ledger-vs-receipt checks (receipts.ledger, receipts.external, receipts.stale); EXTERNAL PowerChat tips stored in external_receipts and announced as billing.receipt.external under BILLING_AUTHORITY=billing (default live: recorded only), attributed through provider_accounts (imported from Live's powerchat_connections), consumed by OpenVibe.Tips 6d14574; overflow guards (safe integers in the journal, $1M per receipt); settlement under an IMMEDIATE transaction (multi-process races settle once without lock errors). billing.receipt.external is not yet in an openvibe-contracts release. Live's payouts, refunds and plans have no tables of their own; where they map is docs/live-mapping.md.",
"updated": "2026-09-23"
}
ddiff --git a/docs/live-cutover.md b/docs/live-cutover.md
iindex ebfa0f9..e9bed63 100644
---- a/docs/live-cutover.md
++++ b/docs/live-cutover.md
@@@ -4,9 +4,17 @@
the sequence below. Binding decision: [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md).
The Live side is the patch [`live-patch.diff`](live-patch.diff), made against OpenVibe.Live branch
--`seadragon` at `e032d78` and `git apply --check` clean on `9f4f1da` (that commit only touches the nginx
--config). It adds a switch that is **off by
--default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exactly as before.
++`seadragon` at `e032d78`. It has been merged into Live and deployed there since `c384787`. It adds a
++switch that is **off by default**. `BILLING_AUTHORITY` is not set in `/etc/openvibe/live.env`, so Live
++behaves exactly as before.
++
++Billing has a switch of the same name (`BILLING_AUTHORITY` in `/etc/openvibe/billing.env`, `live` by
++default). It decides one thing: whether an EXTERNAL tip (a tip paid on a streamer's own PowerChat)
++that reaches Billing's webhook is **announced** as `billing.receipt.external`. OpenVibe.Tips turns that
++event into the Live chat line, the alert and goal progress. Under `live` the tip is only recorded,
++because Live's own webhook announces it. The two switches are flipped in the same maintenance window
++(steps 3 and 6). Only one of Live and Billing ever receives the PowerChat webhook, so a tip is
++celebrated exactly once.
## What the patch does
@@@ -55,17 +63,22 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
## Before the day
--1. **Billing** runs this repository at the commit that carries this document (it includes the
-- cutover guards: an order Live already credited is never settled again, a later import never
-- adjusts away Billing's own movements, `scripts/freeze.js`). `/etc/openvibe/billing.env`:
++1. **Billing** runs this repository at the commit that carries this document. That commit includes the
++ cutover guards (an order Live already credited is never settled again, and a later import never
++ adjusts away Billing's own movements), `scripts/freeze.js`, the EXTERNAL-receipt announcement, the
++ hourly reconciliation and `/metrics`. This release adds the `openvibe-shared` dependency, so the deploy
++ runs `npm ci --omit=dev` before the restart. Afterwards, `curl -s http://127.0.0.1:4600/metrics | head`
++ answers on the host and `/api/health` shows `"authority":"live"`. `/etc/openvibe/billing.env`:
- `POWERCHAT_WEBHOOK_SECRET` = the PowerChat app's webhook signing secret (the value Live has in the
`powerchat_webhook_secret` setting — the webhook is only re-pointed, not re-created);
-- - `POWERCHAT_SITE_USERNAME` = Live's `powerchat_site_tip_username`;
++ - `POWERCHAT_SITE_USERNAME` = Live's `powerchat_site_tip_username` (already set since 2026-09-23);
++ - `BILLING_AUTHORITY` **unset** (= `live`) until step 3;
- `POWERCHAT_ALLOW_TEST_FULFILLMENT` off; `BILLING_*` rates equal to Live's settings
(`bucks_per_usd`, `bucks_min_purchase_bucks`, `sub_price_usd`, `sub_streamer_share_pct`,
`sub_site_route_fee_pct`, `MIN_CASHOUT_BUCKS`, `ESCROW_HOLD_DAYS`) — Live shows its own prices, Billing
charges its own, so they must match (`GET /api/admin/money` lists any drift once the switch is on).
--2. **Network grants** for Live's client (`live`):
++2. **Network grants** for Live's client (`live`). These have been in place since 2026-09-23 (Network
++ `server/identity/principals.js`):
| client | capability | audience |
|---|---|---|
@@@ -79,8 +92,9 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
Live is deliberately **not** granted `billing.cashout.manage` or `billing.ledger.admin`.
Those are held only by people, through Billing's staff console (below).
--3. **Staff console** (it replaces Live's cashout admin — without it nobody can approve a payout after
-- the switch):
++3. **Staff console**. It replaces Live's cashout admin: without it, nobody can approve a payout after
++ the switch. It has been live at `https://billing.openvibe.network` since 2026-09-23, with the owner as
++ the only staff member; the items below are how it was set up:
- **Network:** OAuth client `billing` (today a service client with no redirect URI) gets the redirect
URI `https://billing.openvibe.network/auth/callback` — add
`{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] }`
@@@ -93,13 +107,22 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
(console proxied, `/api/v1` denied on the public host, `/webhooks/*` public) and reload.
- **Check:** sign in at `https://billing.openvibe.network/`, open Cashouts, run a reconciliation from the
dashboard; the Audit log shows the sign-in and the run. Someone not listed gets 403.
--4. **Live** has the patch applied and deployed with `BILLING_AUTHORITY` unset (no behaviour change;
-- `GET /api/admin/money` answers `authority: "live"`).
++4. **Live** has the patch applied and deployed with `BILLING_AUTHORITY` unset (done since `c384787`;
++ no behaviour change; `GET /api/admin/money` answers `authority: "live"`).
5. **Identity:** Live's daily legacy-map sync (`identity-legacy-sync`) has run since the last
sign-ups (restart Live or wait a day), and Live's log has no `[Identity] live user N is mapped to …`
conflict for a user who holds money. A shadow import against a fresh snapshot shows **no import
holds** for users with a non-zero balance (a held user's Vibes sit on `hold:live:<id>` and would read
as 0 under their subject until a later import releases them) and every adjustment is explained.
++ The same report's `PowerChat accounts: … unmapped` line should list nobody. Every Live PowerChat
++ connection maps to a creator (`provider_accounts`); an EXTERNAL tip on an unmapped account is held for
++ review instead of announced.
++6. **Tips** (for EXTERNAL tips): OpenVibe.Tips runs a release that consumes `billing.receipt.external`
++ (Tips `6d14574` or later). Its Events subscriptions include `billing.receipt.*`. To add it, run
++ `sudo bash -c "set -a; . /etc/openvibe/tips.env; set +a; cd /opt/openvibe.tips && sudo -E -u ubuntu node scripts/subscribe.js"`.
++ It creates the missing `billing.receipt.*` subscription and reports the existing
++ `billing.transaction.*` one. Tips already holds `live.tips_delivery.write`. `TIPS_CHAT_ADAPTER` stays
++ `none` until step 6.
Shell helpers used below (on the host):
@@@ -107,7 +130,10 @@ Shell helpers used below (on the host):
# billing '<command>' runs one command with Billing's environment, as Billing's service user (ubuntu)
# so the database files keep their owner — the same way the shadow imports were run.
billing() { sudo bash -c "set -a; . /etc/openvibe/billing.env; set +a; export NODE_ENV=production BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db; cd /opt/openvibe.billing && sudo -E -u ubuntu $1"; }
--LIVE_DB=/opt/openvibe.live/data/live.db
++LIVE_DB=/opt/openvibe.live/data/live.db # production runs Live's legacy layout (a git checkout); there is no shared/
++# /var/backups/openvibe is root-only (0700), so Billing's user cannot read a snapshot there: the import reads
++# its copy from Billing's own state directory, and a second copy stays in /var/backups/openvibe for rollback.
++SNAPDIR=/var/lib/openvibe-billing
```
Live owner calls are made from the browser console on openvibe.live, signed in as the owner:
@@@ -117,17 +143,23 @@ Live owner calls are made from the browser console on openvibe.live, signed in a
0. **Check who is live**: `curl -s https://openvibe.live/api/streams` — the deploy in step 6 restarts
Live (RTMP/WHIP sessions reconnect); pick a quiet moment.
--1. **Freeze Live money writes** (runbook step 3): `api('/admin/money/freeze', { method: 'POST', body: { on: true, reason: 'Billing cutover' } })`;
++1. **Freeze Live money writes**: `api('/admin/money/freeze', { method: 'POST', body: { on: true, reason: 'Billing cutover' } })`;
`api('/admin/money')` → `frozen: true`. New checkouts, donations, cashouts, subscriptions stop.
2. **Let in-flight checkouts land** on Live: wait at least **60 minutes** after step 1 (minted PowerChat
checkout links live one hour), then run the backfill once: `api('/powerchat/reconcile', { method: 'POST' })`.
Look at what is still open:
-- `sqlite3 "$LIVE_DB" "SELECT id, provider, kind, amount_cents, status, created_at FROM payment_orders WHERE status IN ('pending','paid') AND created_at >= datetime('now','-3 days')"`.
++ `sudo sqlite3 -readonly "$LIVE_DB" "SELECT id, provider, kind, amount_cents, status, created_at FROM payment_orders WHERE status IN ('pending','paid') AND created_at >= datetime('now','-3 days')"`.
A `paid` row is an anomaly to fix first; a `pending` one is carried into Billing as an intent and
settles there if it is ever paid (canonical, un-minted links do not expire).
3. **Back up and freeze Billing** so it holds PowerChat deliveries until the final import is in:
`billing "sqlite3 /var/lib/openvibe-billing/billing.db '.backup /var/lib/openvibe-billing/billing-pre-cutover.db'"`
(kept for rollback), then `billing "node scripts/freeze.js on 'Live cutover'"` → `frozen: true`.
++ Then make Billing the authority for EXTERNAL tips: add `BILLING_AUTHORITY=billing` to
++ `/etc/openvibe/billing.env` and `sudo systemctl restart openvibe-billing`. The freeze is kept in the
++ database, so it survives the restart. `curl -s http://127.0.0.1:4600/api/health` →
++ `"frozen":true,"authority":"billing"`. From step 4 on, an EXTERNAL tip that reaches Billing is held
++ with the other deliveries and announced when step 7 processes it. Live receives none of them, so it
++ announces none.
4. **Re-point the PowerChat webhook** (manual, PowerChat developer dashboard for OpenVibe.Live's app):
`https://openvibe.live/api/powerchat/webhook` → **`https://billing.openvibe.network/webhooks/powerchat`**,
same signing secret. From this moment every delivery is stored (202) and held by Billing; Live gets none.
@@@ -135,27 +167,37 @@ Live owner calls are made from the browser console on openvibe.live, signed in a
5. **Final snapshot and import** (after step 4, so every delivery either reached Live before the snapshot
or is held by Billing):
```bash
-- SNAP=/var/backups/openvibe/live-cutover-$(date +%F-%H%M).db # keep this file (rollback)
-- sudo mkdir -p /var/backups/openvibe && sudo sqlite3 "$LIVE_DB" ".backup $SNAP" && sudo chown ubuntu "$SNAP" && sudo chmod 600 "$SNAP"
++ SNAP=$SNAPDIR/live-cutover-$(date +%F-%H%M).db
++ sudo sqlite3 "$LIVE_DB" ".backup $SNAP" && sudo chown ubuntu:ubuntu "$SNAP" && sudo chmod 600 "$SNAP"
++ sudo cp -p "$SNAP" /var/backups/openvibe/ # keep this copy (rollback)
billing "node scripts/import-live.js --live-db $SNAP --dry-run"
billing "node scripts/import-live.js --live-db $SNAP"
billing 'node scripts/reconcile.js'
```
Read the report: `holds` (must be empty for anyone with money), `adjustments`, `anomalies`,
-- `intents_updated` (orders Live credited since the shadow import). Reconciliation must print **OK**.
++ `intents_updated` (orders Live credited since the shadow import), and `PowerChat accounts` (nobody
++ `unmapped`). Reconciliation must print **OK**. The `receipts.stale` check does not count while Billing
++ is frozen, so the held deliveries are listed only as `unprocessed events`. What each Live table becomes is in
++ [live-mapping.md](live-mapping.md).
6. **Switch Live**: add to `/etc/openvibe/live.env`
```
BILLING_AUTHORITY=billing
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
```
-- then `sudo /opt/openvibe.live/deploy/scripts/deploy.sh --wait-idle` (an env change needs the
-- restart; the systemd socket keeps accepting while it boots). Wait for `GET /api/ready`.
++ then `sudo /opt/openvibe.live/deploy/scripts/deploy.sh --restart --wait-idle`. `--restart` is
++ required: with no new commit, deploy.sh changes nothing unless told to restart, and only a restart
++ reads the new env. The systemd socket keeps accepting while Live boots. Wait for `GET /api/ready`.
`api('/admin/money')` → `authority: "billing"`, `billing.reachable: true`, `billing.rates.drift: []`.
++ Then let Tips post chat lines: set `TIPS_CHAT_ADAPTER=live-chat` in `/etc/openvibe/tips.env` and run
++ `sudo systemctl restart openvibe-tips`. `curl -s http://127.0.0.1:4610/api/health` →
++ `"chat_adapter":"live-chat"`.
7. **Unfreeze Billing**: `billing 'node scripts/freeze.js off'`. Within a minute the service processes the
held deliveries in arrival order: a checkout Live never credited settles once; a delivery for an order
Live already credited is **rejected** (`billing.intent_settled_in_live`) and listed for review — never
-- credited twice. `billing 'node scripts/reconcile.js'` → OK; read its `rejected events` line.
++ credited twice. An EXTERNAL tip that arrived since step 4 is announced now: Tips posts its chat
++ line and advances the goal. `billing 'node scripts/reconcile.js'` → OK; read its `rejected events`
++ and `EXTERNAL receipts` lines.
8. **Verify the reads** (Live still frozen): as the owner, `api('/funds/balance')` equals your Billing
balance and your frozen Live column; spot-check a streamer's `cashout_balance` the same way.
`api('/admin/money')` → `billing.actions.attention: []`. In the staff console, Cashouts lists the
@@@ -164,8 +206,11 @@ Live owner calls are made from the browser console on openvibe.live, signed in a
9. **Unfreeze Live**: `api('/admin/money/freeze', { method: 'POST', body: { on: false } })`.
10. **Verify with a small real PowerChat purchase**: Buy Vibes on openvibe.live with PowerChat (100 Vibes).
After the tip: one new settled `powerchat` provider event in Billing, the balance rises by 100 once,
-- `billing 'node scripts/reconcile.js'` is OK, and Live's legacy columns did not move. If anything is off,
-- freeze Live and Billing again and roll back.
++ `billing 'node scripts/reconcile.js'` is OK, and Live's legacy columns did not move. Then a small tip on
++ a connected streamer's **own** PowerChat while they are live: Billing records one `external` receipt,
++ announced (`EXTERNAL receipts: 1, announced 1`). No transaction is booked for it. Tips records one
++ `billing-external` interaction, and Live chat shows one "… tipped N Vibes … (PowerChat)" line with the
++ alert sound. If anything is off, freeze Live and Billing again and roll back.
**Why this order differs from the brief** (freeze → final import → reconcile → switch → deploy → verify →
re-point → unfreeze): the verification purchase cannot work before the re-point (its webhook would reach
@@@ -181,15 +226,19 @@ which money moved where.
- **Before step 6** (Live still on its own columns): re-point the PowerChat webhook back to
`https://openvibe.live/api/powerchat/webhook`; leave Billing **frozen** (its held deliveries are
-- not processed, and must not be until a fresh final import); unfreeze Live. Payments whose webhook went to Billing in the window are still pending
++ not processed, and must not be until a fresh final import); unfreeze Live. Remove
++ `BILLING_AUTHORITY` from billing.env and restart Billing (it stays frozen). When the held deliveries
++ are eventually processed, the EXTERNAL tips among them are then recorded, not announced hours late.
++ Payments whose webhook went to Billing in the window are still pending
orders on Live and are backfilled by Live's paid-messages reconciler (every 15 min, or
`api('/powerchat/reconcile', { method: 'POST' })`). Before a later attempt, run a fresh final import
before unfreezing Billing (the guards then reject the held deliveries Live has since credited).
- **After step 6, before step 9** (Live frozen; only held stragglers settled in Billing): remove
-- `BILLING_AUTHORITY` from `/etc/openvibe/live.env`, deploy with `--wait-idle`, re-point PowerChat to
-- Live, unfreeze Live. The stragglers Billing settled are still pending orders on Live and are
-- backfilled by Live's reconciler from PowerChat's paid-messages feed — so Billing must forget them:
-- stop `openvibe-billing`, restore `billing-pre-cutover.db` from step 3 over `billing.db` (remove the
++ `BILLING_AUTHORITY` from `/etc/openvibe/live.env` and deploy with `--restart --wait-idle`; set
++ `TIPS_CHAT_ADAPTER=none` in tips.env and restart Tips; re-point PowerChat to Live; unfreeze Live.
++ The billing.env `BILLING_AUTHORITY` goes too, with the Billing restore below. The stragglers Billing
++ settled are still pending orders on Live and are backfilled by Live's reconciler from PowerChat's
++ paid-messages feed — so Billing must forget them: stop `openvibe-billing`, restore `billing-pre-cutover.db` from step 3 over `billing.db` (remove the
`-wal`/`-shm` files), freeze it (`billing "node scripts/freeze.js on 'rollback'"`), start it again.
Otherwise a later attempt would count those payments twice (Live's credit arrives through the import,
Billing's own settlement stays).
@@@ -203,13 +252,17 @@ which money moved where.
Said plainly, for whoever runs this and for Wave 9 (Tips) / Wave 10 (VIP):
--1. **Everything else that arrived on Live's PowerChat webhook stops once it points at Billing.** Tips
-- on a streamer's own PowerChat (EXTERNAL money) no longer post Live's donation chat line and global
-- mirror, play the donation sound, or advance the streamer's Live donation goal; PowerChat
-- follow/host/channel-points/subscription notices no longer appear in Live chat. PowerChat has one
-- webhook URL per app. Billing stores these deliveries (EXTERNAL, no liability) but nothing forwards
-- them to Live; that needs an Events consumer (Billing's outbox emits `billing.*` when `EVENTS_URL` is
-- set) or the Tips service.
++1. **Tips on a streamer's own PowerChat (EXTERNAL) move to the Billing → Tips → Live path, and lose
++ Live's own goals.** Billing records each one once (`external_receipts`, no journal entry). It
++ announces it as `billing.receipt.external`, attributed through `provider_accounts`. Tips posts the
++ chat line through Live's `/internal/tips/deliveries`: channel room, global mirror, saved `donation`
++ message, donation sound. Tips also advances its own goal and overlay. **Not carried:** Live's own
++ `donation_goals` (`applyDonationToGoal`) and their `goal-update` / `goal-reached` frames and "Goal
++ reached" chat line. Live's `/internal/tips/deliveries` does not credit Live goals; that is a Live-side
++ change. Carrying them also needs a durable idempotency key there, because the route dedupes in
++ memory only. A tip on a PowerChat account no creator has connected is held for review, not announced.
++ PowerChat follow/host/channel-points/subscription notices no longer appear in Live chat: they carry no
++ money, and nobody forwards them. PowerChat has one webhook URL per app.
2. **Live no longer learns about PowerChat settlements:** no "Vibes credited" / "Subscribed!"
notification to the buyer, no sub alert forwarded to the streamer's PowerChat overlay for
PowerChat-paid subscriptions (Vibes-paid ones still get it), no celebration for legacy site-routed
ddiff --git a/docs/live-mapping.md b/docs/live-mapping.md
nnew file mode 100644
iindex 0000000..b03df87
---- /dev/null
++++ b/docs/live-mapping.md
@@@ -0,0 +1,150 @@
++# OpenVibe.Live → OpenVibe.Billing: what maps where
++
++Where every piece of Live's money state goes in Billing, as the importer
++([server/importer/live.js](../server/importer/live.js), `node scripts/import-live.js --live-db <snapshot>`) and
++the cutover ([live-cutover.md](live-cutover.md)) implement it. The audit found this was never written down,
++in particular for **payouts, refunds and plans**. **Live has no table for any of those three.** They live
++inside other rows, and this page says which ones.
++
++Counts are from production `/opt/openvibe.live/data/live.db`, read on 2026-09-23. The importer opens a
++**snapshot copy** read-only and never writes Live. Every write it makes is keyed, so a re-run over the same
++snapshot changes nothing.
++
++## Balances: `users`
++
++| Live | Billing | How |
++|---|---|---|
++| `users.openvibe_bucks_balance` (spendable Vibes) | `user_credit:<subject>` (vibes-bits, CREDIT) | Step 4 (opening): the imported part of the account is set equal to the column. The difference from the replayed history goes to `import_adjustment` and is listed in the report. |
++| `users.openvibe_bucks_cashout_balance` (creator earnings) | `creator_payable:<subject>` (vibes-bits, MONEY) | Same as above. |
++| the Live user id | the Network subject `usr_…` | Step 1: `resolve-batch` (system `live`). A user with no subject is **held**: their accounts sit under `hold:live:<id>` and appear in `import_holds`. A later run moves them once the Network knows the user. |
++| `users.openvibe_coins_balance`, `channel_points`, `coin_transactions` | not Billing | OpenCoins and channel points are LOYALTY (ADR-012 rules 5–8). |
++
++Fractional column values are rounded and reported as `fractional_balance` anomalies.
++
++## Vibes history: `transactions`
++
++Every row becomes one journal transaction with type `import`, status `imported` and key
++`import:live:txn:<id>`. Rows dated before `site_settings.stats_vibes_reset_at` are flagged `test`. A row
++that moved no balance still gets a transaction, with no entries and the reason in `metadata.note`.
++
++| Live `type` / `status` | Billing entries | Notes |
++|---|---|---|
++| `purchase` / `completed` | `provider_clearing:live-legacy` → fx → `user_credit:<to>` | The provider receipt, if there was one, comes from `payment_orders` (below). |
++| `donation` / `completed`, from a user | `user_credit:<from>` → `creator_payable:<to>` | Includes Vibes-paid media requests (`message` "Media request: …"). |
++| `donation` / `completed`, no sender | `provider_clearing:live-legacy` → fx → `creator_payable:<to>` | Site-routed PowerChat tips ("PowerChat tip via site account"). |
++| `subscription` / `completed` | not replayable: recorded with no entries (`unreplayable`) | The balance effect reaches Billing through the opening balances. The subscription itself comes from `subscriptions`. |
++| `cashout` / `escrow` | `creator_payable` → `payouts_pending` + a `cashouts` row `requested` | See **Payouts**. |
++| `cashout` / `completed` | `creator_payable` → fx → `provider_clearing:paypal_payouts` + a `cashouts` row `paid` | See **Payouts**. |
++| `cashout` / `refunded` | no entries (requested and returned) + a `cashouts` row `denied` | See **Payouts**. |
++| `refund` / `completed` | `creator_payable:<from>` → `user_credit:<to>` | See **Refunds**. |
++| `bonus` / `completed` | `platform_revenue` (bits) → `user_credit:<to>` | |
++| any `pending` / `failed` | no entries (`status …: no balance moved`) | |
++
++Production on 2026-09-23 has 304 rows: 176 `purchase` and 128 `donation`, all `completed`. There are no
++`cashout`, `refund`, `bonus` or `subscription` rows.
++
++## Payouts: no table on Live
++
++On Live, a payout is a `transactions` row with `type = 'cashout'` plus the move from
++`openvibe_bucks_cashout_balance`. In Billing it is a `cashouts` row plus its journal entries. Billing
++decides new payouts in the staff console.
++
++| Live | Billing `cashouts` |
++|---|---|
++| the row id | `legacy_live_txn` (UNIQUE: imported once) |
++| `from_user_id` | `subject` (or `hold:live:<id>` until mapped) |
++| `amount` | `amount_bits`; `value_cents` = the value at the recorded rate |
++| `status` `escrow` / `completed` / `refunded` | `requested` / `paid` / `denied` |
++| `created_at` | `created_at`; `escrow_until` = `created_at` + `BILLING_ESCROW_DAYS` (Live's `ESCROW_HOLD_DAYS`, 14) |
++| `message` ("Cashout to PayPal: …") | `payout_method` `{ type: 'paypal', legacy_message }` |
++| — | `request_txn` / `settle_txn` = the imported transaction |
++| `paypal_transaction_id` | **not carried.** A `paid` import gets `payout_reference = live-import:unrecorded:<id>` and a `paid_cashout_without_provider_reference` anomaly. Only Live's unmounted `community-funds.js` ever wrote the column, and 0 of the 304 production rows have it. |
++
++`MIN_CASHOUT_BUCKS` (500) maps to `BILLING_MIN_CASHOUT_BITS`. Live's `min_cashout_amount` site setting
++(also 500) is not read by Live's cashout code.
++
++## Refunds: no table on Live
++
++| Live | Billing |
++|---|---|
++| `transactions` `type = 'refund'` (a creator gives Vibes back) | an imported transaction `creator_payable` → `user_credit` |
++| `transactions` `cashout` / `refunded` (a denied cashout) | a `cashouts` row `denied`, nets to nothing |
++| a Vibes media-request refund (`media-queue.js`: balances move, **no row**) | nothing replayable. The effect shows up in the opening-balance adjustment. After the cutover Live calls `POST /api/v1/transfers/:id/refund`. A request charged before the cutover and refunded after it is refunded by an operator adjustment (live-cutover.md, "What the switch does not preserve" 11). |
++| provider refunds and chargebacks | none: Live handled none. Billing books them as `refund` / `chargeback` transactions with `reverses_txn` (server/ops/reversals.js). |
++| `payment_orders.status` | has no refunded state. An intent becomes `refunded` in Billing only after a Billing reversal. |
++
++## Plans: no table on Live
++
++Live sells one channel subscription per (subscriber, streamer) at a site-wide price. There is no plan row.
++The "plan" is made of settings plus the `subscriptions.tier` column:
++
++| Live | Billing / elsewhere |
++|---|---|
++| `site_settings.sub_price_usd` (4.99) | `BILLING_SUB_PRICE_CENTS` (499) |
++| `site_settings.sub_streamer_share_pct` (70) | `BILLING_SUB_SHARE_PCT` |
++| `site_settings.sub_site_route_fee_pct` (10) | `BILLING_SITE_ROUTE_FEE_PCT` |
++| the period (a month) | `BILLING_SUB_PERIOD_DAYS` (31) |
++| `subscriptions.tier` (1–3, always 1 in practice) | `subscriptions.tier` |
++| what a subscriber gets (badge, perks) and the plan as a product | **OpenVibe.VIP** `vip_plans` / `vip_plan_versions` (VIP's own Live import creates plan `channel-subscription` per creator). VIP never prices anything; the price is Billing's. |
++
++Each transaction records the rates it used (`metadata.rates`), so a later change of these settings does not
++rewrite history.
++
++## Subscriptions: `subscriptions`
++
++Step 5, keyed by `legacy_live_id`: each row becomes a Billing `subscriptions` row, plus one `entitlements`
++row for the current paid period.
++
++| Live | Billing |
++|---|---|
++| `subscriber_id`, `streamer_id` | `subscriber`, `streamer` (subjects). The row is skipped (reported) while either is unmapped. |
++| `status`, `is_active`, `current_period_end` / `expires_at` | `status` `active` / `canceled` / `expired`; `current_period_end` |
++| `provider` (`bucks` → `credit`), `provider_ref`, `price_cents`, `auto_renew`, `cancel_at_period_end` | the same fields |
++| the paid period | an `entitlements` row (`channel_subscription`, scope = streamer). A later snapshot that extends it adds one. |
++
++Production has 0 rows.
++
++## Checkouts: `payment_orders`
++
++Step 2, keyed by `legacy_order_id`: every order becomes a `payment_intents` row. A `credited` or `paid`
++order also becomes an immutable `provider_events` receipt (`live-order:<id>`, effect `imported`).
++
++| Live | Billing |
++|---|---|
++| `status` `credited` / `failed` / `pending` / `paid` | intent `settled` (settled **in Live**: no `settled_txn`, so a later delivery is refused, never credited twice) / `failed` / `created` or `expired` if older than 3 days / `created` + an `order_paid_not_credited` anomaly |
++| `kind` `bucks` / `subscription` | `kind` `purchase` / `subscription` |
++| `provider_ref` `direct[:renew]`, `site[:fee=N][:renew]` (PowerChat route markers) | `route`, `fee_cents`, `auto_renew`. `provider_ref` becomes NULL. For `direct`, `metadata.receiving_account` is taken from `powerchat_connections`. |
++| any other duplicate `provider_ref` | kept on the first order, reported in `duplicate_provider_refs` |
++| `amount_cents`, `bucks`, `user_id`, `streamer_id` | `amount_cents`, `bits`, `subject`, `streamer_subject` |
++
++A later run follows orders Live credited or failed after the earlier run. Production has 5 orders.
++
++## PowerChat: `powerchat_connections` and the webhook
++
++| Live | Billing |
++|---|---|
++| `powerchat_connections` (`user_id`, `powerchat_username`, `powerchat_user_id`) | Step 6: `provider_accounts` (provider `powerchat`, lower-case username, account id → subject, source `live-import`). An EXTERNAL tip on that account is announced for that creator (server/ops/external.js). An operator mapping (source `admin`) is never overwritten. `--accounts-only` runs this step alone. Production has 2 connections, both with an account id. |
++| the same table, for a direct subscription | the intent's `receiving_account` (see above) |
++| `powerchat_webhook_deliveries` (delivery-id dedupe) | not imported. Billing dedupes on `provider_events (provider, provider_event_id)`. |
++| `site_settings.powerchat_webhook_secret`, `powerchat_site_tip_username`, `powerchat_allow_test_fulfillment` | `POWERCHAT_WEBHOOK_SECRET`, `POWERCHAT_SITE_USERNAME`, `POWERCHAT_ALLOW_TEST_FULFILLMENT` |
++| Live's webhook celebrating a tip on the streamer's own PowerChat (chat line, sound, goal) | `external_receipts` plus `billing.receipt.external` under `BILLING_AUTHORITY=billing`. OpenVibe.Tips celebrates it. |
++
++## Prices and rates
++
++| Live | Billing |
++|---|---|
++| `site_settings.bucks_per_usd` (100) | `BILLING_BITS_PER_USD` |
++| `site_settings.bucks_min_purchase_bucks` (100) | `BILLING_MIN_PURCHASE_BITS` |
++| `BUCKS_PRICE_TIERS` (constant in `server/monetization/vibes.js`) | `BILLING_PRICE_TIERS` (the same values by default) |
++| `site_settings.stats_vibes_reset_at` | the importer's `test` cut-off |
++| `site_settings.payments_enabled` (false) | nothing. Live keeps it as an extra gate for card rails. Billing enables a provider only when its secrets are set. |
++
++## Not imported, on purpose
++
++| Live | Why / where it goes |
++|---|---|
++| `donation_goals` | OpenVibe.Tips (`tip_goals`, via Tips' own Live import; `current_amount` carried over as `opening_amount`) |
++| `media_requests` (queue, `cost`) | Live's queue. The Vibes charge is a `donation` above. Tips holds paid media requests after the cutover. |
++| `billing_actions` | Live's own journal of calls it made to Billing (created on first use; it does not exist on production yet) |
++| `coin_transactions`, `channel_points`, `arena_tier_paid` | LOYALTY. `arena_tier_paid` records which arena tier-up OpenCoins rewards were paid. It is no longer used by Live's code. |
++| chat `donation` messages | history only. Tips imports the PowerChat ones as EXTERNAL interactions. |