openvibers/openvibe.billing Commits
Cutover runbook checked against production, and Live's money tables mapped to Billing
Changes
@@@ -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"}@@@ -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, Billingcharges 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 exaLive 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 redirectURI `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 thedashboard; 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 lastsign-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 importholds** for users with a non-zero balance (a held user's Vibes sit on `hold:live:<id>` and would readas 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 a0. **Check who is live**: `curl -s https://openvibe.live/api/streams` — the deploy in step 6 restartsLive (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 PowerChatcheckout 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 andsettles 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 a5. **Final snapshot and import** (after step 4, so every delivery either reached Live before the snapshotor 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=billingOV_BILLING_INTERNAL_URL=http://127.0.0.1:4600OV_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 theheld deliveries in arrival order: a checkout Live never credited settles once; a delivery for an orderLive 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 Billingbalance 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 a9. **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 pendingorders 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 importbefore 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 forPowerChat-paid subscriptions (Vibes-paid ones still get it), no celebration for legacy site-routednnew file mode 100644@@@ -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. |
@@ -7,6 +7,6 @@
@@ -7,6 +7,6 @@
"adr": "ADR-012",
"adr": "ADR-012",
"authoritative": false,
"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.",
"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"
"updated": "2026-09-23"
}
}
@@ -4,9 +4,17 @@
@@ -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 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
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
## What the patch does
@@ -55,17 +63,22 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
@@ -55,17 +63,22 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
## Before the day
## 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` = 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_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
- `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`,
(`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
`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).
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 |
| client | capability | audience |
|---|---|---|
|---|---|---|
@@ -79,8 +92,9 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
@@ -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`.
Live is deliberately **not** granted `billing.cashout.manage` or `billing.ledger.admin`.
Those are held only by people, through Billing's staff console (below).
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
- **Network:** OAuth client `billing` (today a service client with no redirect URI) gets the redirect
URI `https://billing.openvibe.network/auth/callback` — add
URI `https://billing.openvibe.network/auth/callback` — add
`{ client_id: 'billing', name: 'OpenVibe.Billing', redirect_uris: ['https://billing.openvibe.network/auth/callback'] }`
`{ 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
@@ -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.
(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
- **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.
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
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 …`
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
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
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.
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):
Shell helpers used below (on the host):
@@ -107,7 +130,10 @@ 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)
# 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.
# 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"; }
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:
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
@@ -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
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.
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.
`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
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' })`.
checkout links live one hour), then run the backfill once: `api('/powerchat/reconcile', { method: 'POST' })`.
Look at what is still open:
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
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).
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:
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'"`
`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`.
(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):
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`**,
`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.
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
@@ -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
5. **Final snapshot and import** (after step 4, so every delivery either reached Live before the snapshot
or is held by Billing):
or is held by Billing):
```bash
```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 --dry-run"
billing "node scripts/import-live.js --live-db $SNAP"
billing "node scripts/import-live.js --live-db $SNAP"
billing 'node scripts/reconcile.js'
billing 'node scripts/reconcile.js'
```
```
Read the report: `holds` (must be empty for anyone with money), `adjustments`, `anomalies`,
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`
6. **Switch Live**: add to `/etc/openvibe/live.env`
```
```
BILLING_AUTHORITY=billing
BILLING_AUTHORITY=billing
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
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: []`.
`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
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
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
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
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.
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
`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
@@ -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 } })`.
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).
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,
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 →
**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
re-point → unfreeze): the verification purchase cannot work before the re-point (its webhook would reach
@@ -181,15 +226,19 @@ which money moved where.
@@ -181,15 +226,19 @@ which money moved where.
- **Before step 6** (Live still on its own columns): re-point the PowerChat webhook back to
- **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
`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
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
`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).
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
- **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.
`-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,
Otherwise a later attempt would count those payments twice (Live's credit arrives through the import,
Billing's own settlement stays).
Billing's own settlement stays).
@@ -203,13 +252,17 @@ which money moved where.
@@ -203,13 +252,17 @@ which money moved where.
Said plainly, for whoever runs this and for Wave 9 (Tips) / Wave 10 (VIP):
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!"
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
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
PowerChat-paid subscriptions (Vibes-paid ones still get it), no celebration for legacy site-routed
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -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. |