openvibers/openvibe.billing README.md
README.md
| 1 | # OpenVibe.Billing |
| 2 | |
| 3 | > The isolated money ledger: providers, receipts, subscriptions, entitlements, refunds and payouts. |
| 4 | |
| 5 | **Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt |
| 6 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 7 | |
| 8 | **Status:** alpha — **the money ledger of the OpenVibe network since 2026-10-17** (the cutover below, 04:50–04:56 UTC): |
| 9 | `BILLING_AUTHORITY=billing` in Billing and OpenVibe.Live, PowerChat deliveries settle here, and Live's own money tables |
| 10 | are read-only. Deployed on the host since 2026-09-23 (`/opt/openvibe.billing`, API on `127.0.0.1:4600`); it ran in |
| 11 | shadow, holding a reconciled import of Live's money data, until the cutover. Every OpenVibe site's account menu shows |
| 12 | a person's Vibes from `GET /api/me/vibes`. |
| 13 | **Domain:** `billing.openvibe.network` — the staff console (Network SSO, staff only), `/webhooks/*` and |
| 14 | `/api/health`; the service API `/api/v1` is reachable only on loopback (`127.0.0.1:4600`) |
| 15 | **Decision:** [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md) — |
| 16 | economic classification. The survey behind it is [docs/economic-inventory.md](docs/economic-inventory.md). |
| 17 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.1, §11.4, §11.5. |
| 18 | **License:** AGPL-3.0. |
| 19 | |
| 20 | ## What it is |
| 21 | |
| 22 | One append-only, balanced, double-entry journal for every MONEY and CREDIT flow on the network, |
| 23 | kept apart from loyalty points (OpenCoins, channel points) and from every product UI. Provider |
| 24 | webhooks land here and nowhere else. |
| 25 | |
| 26 | | ADR-012 class | Here | |
| 27 | |---|---| |
| 28 | | CREDIT (Vibes bought, spendable, never withdrawable by the buyer) | `user_credit:<subject>` (vibes-bits) | |
| 29 | | MONEY (creator payable, payouts, receipts, platform revenue) | `creator_payable:<subject>`, `payouts_pending:<subject>`, `provider_clearing:<provider>`, `platform_revenue`, `refunds`, `chargeback_loss` | |
| 30 | | PROMO (free service allowance, plan T5 ledger 2: never spendable as CREDIT, never MONEY) | `promo_credit:<subject>` (vibes-bits), issued from and returned to `promo_reserve` (platform, vibes-bits); windows in `promo_allowances`. **Counterparty: a dedicated `promo_reserve`, not `platform_revenue`**: free allowance is a marketing cost, not income, so debiting revenue would understate earnings and blur the one account that reports what the platform made; a reserve of its own always equals −Σ `promo_credit` and is checked in isolation | |
| 31 | | ENTITLEMENT (channel subscriptions) | `subscriptions` + `entitlements` (one row per paid period) | |
| 32 | | EXTERNAL (tips on a streamer's own PowerChat) | stored receipt (`external_receipts`), no journal entry; announced as `billing.receipt.external` once Billing is the authority; a direct-route subscription grants its entitlement only | |
| 33 | | LOYALTY, COSMETIC, GAME-STATE, LEGACY | not here (rule 5–8) | |
| 34 | |
| 35 | **Three ledgers, one journal.** Bought Vibes (`user_credit`), creator money (`creator_payable` and its payouts) |
| 36 | and free promo allowance (`promo_credit`) are separate account kinds that never mix. Credit becomes money only |
| 37 | through a transfer (`transfers.create`: `user_credit` → `creator_payable`); money leaves only through a cashout |
| 38 | (`creator_payable` → `payouts_pending`). Promo has no such path: it is granted by an admin |
| 39 | (`POST /api/v1/admin/promo/grant`: `promo_reserve` → `promo_credit`) and only drawn down by usage |
| 40 | (`ops/promo.js` `consume()`, keyed by the caller's idempotency key) or lapses when its window ends, both back to |
| 41 | `promo_reserve`. Transfers read `user_credit` only and cashouts `creator_payable` only, so a subject holding only |
| 42 | promo is refused with `billing.insufficient_funds`; an admin adjustment that pairs a promo account with any other |
| 43 | kind is refused (`billing.promo_isolated`), and reconciliation's `promo.isolated` check fails on any transaction |
| 44 | that mixes them. `promo_allowances` is the policy and window record (`granted_bits`, `used_bits`, `expired_bits` |
| 45 | per subject, service, period and window); the `promo_credit` balance is the authoritative remainder, and every |
| 46 | promo write changes both in one transaction. |
| 47 | |
| 48 | ## Owns |
| 49 | |
| 50 | - the journal (`accounts`, `transactions`, `ledger_entries`, `account_balances`), provider receipts |
| 51 | (`provider_events`, `external_receipts`), intents, subscriptions and entitlements, cashouts, usage readings |
| 52 | (`usage_records`), promo allowances (`promo_allowances`), the freeze, reconciliation runs, import runs and the |
| 53 | staff audit log, in Billing's own PostgreSQL database (`ov_billing` on the host's data role, ADR-035; |
| 54 | schema in [migrations/](migrations/)) |
| 55 | - provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments): they land here and nowhere else |
| 56 | - the `billing.*` events and the public billing policy page (`/policy`, every number from the rates) |
| 57 | - Vibes Pay (D12): app charges, the project's `project_payable` Vibes wallet, its signed webhooks and sandbox test |
| 58 | Vibes ([docs/vibes-pay.md](docs/vibes-pay.md)) |
| 59 | |
| 60 | ## Does not own |
| 61 | |
| 62 | - loyalty points, cosmetics, game state (ADR-012 rules 5–8): OpenCoins stay in Network, channel points |
| 63 | in Live |
| 64 | - product UIs: tips (OpenVibe.Tips), memberships and perks (OpenVibe.VIP), checkout pages (each product) |
| 65 | - identity (OpenVibe.Network) |
| 66 | |
| 67 | ## Depends on |
| 68 | |
| 69 | - OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; `identity.subject.resolve` for the Live import) |
| 70 | - OpenVibe.Events (the SDK outbox relay, when `EVENTS_URL` and the OAuth client secret are set) |
| 71 | - the payment providers whose secrets are configured (none in production today) |
| 72 | - `openvibe-contracts` v0.419.0, `openvibe-sdk` v0.166.0 (service tokens, per-person limits, `openvibe-sdk/db`, `openvibe-sdk/service`, `openvibe-sdk/vibes`), `openvibe-shared` v4.241.0, pinned by release tarball |
| 73 | - **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through |
| 74 | `openvibe-sdk/db`; Valkey holds the per-person limit counters (optional: without `VALKEY_URL` they count in the process) |
| 75 | |
| 76 | ## Capabilities |
| 77 | |
| 78 | Implemented here (the service manifest's `capabilities`, audience `openvibe.billing`; routes under |
| 79 | [API](#api)): `billing.intent.create`, `billing.transfer.create`, `billing.balance.read`, |
| 80 | `billing.cashout.request`, `billing.cashout.manage`, `billing.subscription.manage`, |
| 81 | `billing.entitlement.check`, `billing.ledger.admin`, `billing.usage.record` (usage readings; released in |
| 82 | Contracts v0.85.0), and the public `billing.vibes.charge` and `billing.vibes.read` for developer apps (D12). |
| 83 | Callers today are Live (after the cutover), |
| 84 | Tips (`billing.intent.create`, `billing.transfer.create`), VIP (`billing.intent.create`, |
| 85 | `billing.subscription.manage`, `billing.entitlement.check`) and a developer app with its own app token for Vibes Pay. |
| 86 | |
| 87 | Called elsewhere, as the service principal `billing`: `events.event.publish` (Events) and |
| 88 | `identity.subject.resolve` (Network, the Live import). |
| 89 | |
| 90 | ## Run it |
| 91 | |
| 92 | ```bash |
| 93 | fnm exec --using=22.22.1 npm install |
| 94 | cp .env.example .env # set OV_OAUTH_CLIENT_SECRET, POWERCHAT_WEBHOOK_SECRET, … |
| 95 | npm run dev # http://localhost:4600 |
| 96 | npm test # 13 test files: stub Network/Events/providers, temp DBs, random ports |
| 97 | npm run reconcile # reconciliation report (exit 1 on failure) |
| 98 | npm run freeze -- on "reason" # economy freeze from the host (status | on "<reason>" | off) |
| 99 | LIVE_DATABASE_URL=… node scripts/import-live.js [--dry-run] [--json] # reads Live's PostgreSQL (or --live-env <Live's env file>) |
| 100 | LIVE_DATABASE_URL=… node scripts/import-live.js --accounts-only # only PowerChat account → creator mappings |
| 101 | ``` |
| 102 | |
| 103 | Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit |
| 104 | [deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service), vhost [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf). |
| 105 | |
| 106 | ## Design |
| 107 | |
| 108 | - **Journal** (`server/ledger.js`, `server/db.js`): `accounts(kind, owner_subject, currency)`, |
| 109 | `transactions(id txn_<ULID>, type, status, idempotency_key UNIQUE, reverses_txn, test, actor, metadata, …)`, |
| 110 | `ledger_entries(txn_id, account_id, amount)` with signed integer minor units. Every transaction sums |
| 111 | to zero **per currency** (`vibes-bits`, `usd-cents`); triggers refuse UPDATE/DELETE on entries and |
| 112 | transactions; corrections are reversing transactions. `account_balances` is a cache updated in the |
| 113 | same transaction and verified by reconciliation. Funds checks run inside the transaction that moves |
| 114 | the money, and every money transaction is SERIALIZABLE (`ledger.js` `money()`, retried on a |
| 115 | serialization failure), so two movements that each passed a check cannot both commit. |
| 116 | - **Vibes Pay** (`server/ops/vibes.js`, `server/ops/vibes-webhooks.js`, [docs/vibes-pay.md](docs/vibes-pay.md)): |
| 117 | an app creates a charge (whole Vibes, idempotent on its reference), the person pays it on the pay sheet, and the |
| 118 | Vibes move once from `user_credit` to `project_payable:project:<prj_id>`; a project owner cashes out through the |
| 119 | existing cashout path. Each paid/refunded charge publishes `billing.vibes.charge.paid@1`/`.refunded@1` to Events |
| 120 | and queues a signed HMAC-SHA256 webhook in `vibes_webhook_outbox`, retried with backoff. Sandbox apps use |
| 121 | `vibes_credit_sandbox`/`project_payable_sandbox` and test transactions only. The owner's `billing.vibes` limits |
| 122 | and the economy freeze are checked inside the money transaction. |
| 123 | - **Two currencies.** Money arrives in cents and becomes bits through the `fx_conversion` account pair |
| 124 | at the recorded value rate (`bits_per_usd`, 100 today); reconciliation checks that each transaction's |
| 125 | cents side mirrors its bits side. Anything a payment exceeds the bits' value by is `platform_revenue`. |
| 126 | - **Rates are configuration** (`BILLING_*`, defaults = Live's): price tiers, value rate, sub price, |
| 127 | 70 % streamer share, 10 % site-route fee, minimum cashout, escrow days. Each transaction records the |
| 128 | rates it used. |
| 129 | - **Receipts**: webhooks are verified, stored in `provider_events` (UNIQUE provider + event id) before |
| 130 | anything else, then processed with the effect and the "processed" mark in one transaction, with the receipt's row locked. |
| 131 | Every settling transaction has a unique `receipt_ref` (`<provider>:<payment id>`), so a payment |
| 132 | settles once whatever event, retry or API call carries it. |
| 133 | - **Freeze** (ADR-012 rule 11): `settings.freeze`; while on, every mutating endpoint answers |
| 134 | `503 billing.frozen`, reads work, webhooks are stored (202) and processed in arrival order after |
| 135 | unfreeze. Jobs pause. The owner's `money_writes_frozen` switch keeps its own audited path |
| 136 | (`/api/admin/money`); it is not configuration and no rollback may move it. |
| 137 | - **Owner settings (D11)** ([server/config-store.js](server/config-store.js)): the namespace |
| 138 | `billing.credits` holds the Balance switch, the sandbox grants, the top-up switch, its amounts and |
| 139 | fee, and the hold lease, through openvibe-shared/config. Each key keeps the environment variable it |
| 140 | had as an `x-env` pin: while that variable is set its value wins and a write is `409 |
| 141 | config.pinned_in_env`; otherwise the owner sets it from Network's AI tab and it applies live. The |
| 142 | routes are `/internal/config` (capability `billing.config.manage`, Network's service token), with the |
| 143 | owner's `usr_` id in `X-OV-Subject` and `X-OV-Fresh: 1` for the danger keys (`credits`, `topup`, |
| 144 | `topup_amounts_cents`, `topup_fee_bps`). Every activation writes a `staff_audit` row with the keys, |
| 145 | actor and revision, never a value. With `OV_CONFIG_KEKS` set the store seals secrets and reseals old |
| 146 | plaintext rows at boot; this namespace has no secret-class keys, so it reports `sealed` but exposes no |
| 147 | value. `billing.vibes` holds the Vibes Pay switch and limit, the daily app spend per person and the |
| 148 | `from_balance` switch (`vibes_pay` and `from_balance` are danger keys); `GET /api/me/balance`, the transfer |
| 149 | and the Vibes purchase are in [docs/balance.md](docs/balance.md); `GET /api/me/activity` is the person's own |
| 150 | movement list for Network's wallet. |
| 151 | - **Outbox**: openvibe-sdk/events stores pending envelopes in `service_outbox` and relays them with Billing's service token. The legacy `outbox` table stays through the N-1 rollback window. Events include `billing.transaction.settled|reversed`, `billing.entitlement.changed`, |
| 152 | `billing.subscription.canceled`, `billing.cashout.requested|paid|denied`, `billing.staff.action`, |
| 153 | `billing.receipt.external`, written in the same transaction as the effect (events.event-envelope@1, |
| 154 | source `billing`, actor `service:billing`). Relayed to OpenVibe.Events when `EVENTS_URL` and the OAuth client secret are set; otherwise rows wait. |
| 155 | - **Authority** (`BILLING_AUTHORITY`, `live` by default = shadow; `billing` after the cutover): decides |
| 156 | whether EXTERNAL tips are announced (see below). Anything else stops Billing at boot. |
| 157 | - **EXTERNAL receipts** ([server/ops/external.js](server/ops/external.js)): a PowerChat tip paid to the |
| 158 | streamer's own account is stored once per payment in `external_receipts` (never in the journal) and, |
| 159 | under `billing`, announced once as `billing.receipt.external` — subject `provider_receipt |
| 160 | powerchat:<payment id>`, payload `streamer` (SubjectRef), `amount_cents`, `currency`, `value_bits`, |
| 161 | `donor_name` (null when `anonymous`), `anonymous`, `message`, `provider`, `provider_event_id` (the |
| 162 | payment's id), `delivery_id`, `receiving_account`, `app_ref`/`app_purpose` (goal pick), `occurred_at`, |
| 163 | `test`. OpenVibe.Tips turns it into the chat line, alert and goal progress Live's webhook used to |
| 164 | produce. The streamer is whoever the receiving account is mapped to in `provider_accounts` (the importer |
| 165 | copies Live's `powerchat_connections`; an operator can add one); an unmapped account's tip is held for |
| 166 | review and announced when reprocessed after mapping. Under `live` it is recorded, not announced — |
| 167 | Live's own webhook announces it, so a tip is never celebrated twice. |
| 168 | |
| 169 | ## Rates (usage rating) |
| 170 | |
| 171 | Billing turns stored usage readings (`POST /api/v1/usage`) into charges in a **background sweep** |
| 172 | ([server/ops/rating.js](server/ops/rating.js)), never inside `POST /api/v1/usage`: a producer never drives a |
| 173 | synchronous debit. A reading without `vibes_charged` in `GET /api/v1/usage` is simply not rated yet. |
| 174 | |
| 175 | - **Two gates, both closed by default.** The sweep runs only when `BILLING_RATING_INTERVAL_MS` is set (default |
| 176 | `0` = off; `BILLING_RATING_BATCH` readings per pass, 500), and **nothing is charged without a rate card**. |
| 177 | Rate cards are loaded **only by review**: `OV_RATE_CARDS` (a JSON array of `platform.rate-card@1`, inline or |
| 178 | a file path) + `node scripts/load-rate-cards.js [--dry-run]` (`npm run load-rate-cards`). Every card is |
| 179 | validated against the contract first and one bad card loads nothing; no request and no AI writes a card. |
| 180 | - **Card match.** A reading's `provider` and `resource` are the card's `provider` and `metric`; the card's |
| 181 | region (none = every region) and effective dates (`effective_from` ≤ the reading's UTC day < |
| 182 | `effective_until`) must fit. Its `unit` must be the one the metric names (the metric itself or its leading |
| 183 | word: `GiB` for `gib-delivered`, `requests` for `request`); no unit is converted. |
| 184 | - **The charge.** `quantity / unit_size × unit_price_usd × bits_per_usd`, computed exactly and rounded up once |
| 185 | to whole vibes-bits. The subject's promo allowance (`promo_credit`, step 5) covers it first; the rest is |
| 186 | **one `usage` transaction per reading**, `user_credit:<subject>` → `platform_revenue` (vibes-bits), idempotency |
| 187 | key `usage:<reading idempotency_key>`. The allowance draw is a promo `adjustment` (`promo_credit` → |
| 188 | `promo_reserve`) in the same database transaction, so promo bits never reach `platform_revenue` or creator |
| 189 | earnings. The reading records `vibes_charged` (bits), `promo_bits`, `free_allowance_used` (in the reading's |
| 190 | own unit), `rated_at` and `txn_id` (the `usage` transaction, or the promo draw when nothing was charged). A |
| 191 | card's own `free_allowance` (the provider's free tier) is stored with the card, not applied per person. |
| 192 | - **Hard budgets** (`usage_budgets`, `POST /api/v1/admin/budgets`): per subject + service (`'*'` = every |
| 193 | service) + window (`day`, `month`, `none`). A charge that would take an open budget's `spent_bits` past |
| 194 | `budget_bits` is **refused whole** (`billing.budget_exceeded`): nothing is charged, no allowance drawn, the |
| 195 | reading stays unrated. `spent_bits` grows in the same transaction as the ledger post. A window that ends opens |
| 196 | the next with the same budget. **With no budget row only the spendable balance limits the charge** |
| 197 | (`billing.insufficient_funds`). `budget_bits: 0` stops all further charges. |
| 198 | - **Unrated, never partly charged.** The funds check, the budget check, the allowance draw, the post and the |
| 199 | "rated" mark are one serializable transaction that claims the reading's row (`FOR UPDATE SKIP LOCKED`), so |
| 200 | two sweeps, or a sweep and `POST /api/v1/admin/rate`, never charge or draw twice. `rating_error` says why a |
| 201 | reading waits: `billing.insufficient_funds` / `billing.budget_exceeded` are tried again after |
| 202 | `BILLING_RATING_RETRY_MS` (1 h); `billing.no_rate_card` / `billing.unit_mismatch` wait until a matching card |
| 203 | is loaded (the loader makes them due again); `billing.no_subject` (no `user:usr_…` subject, e.g. an app's |
| 204 | job) is never charged. Nothing is rated while the economy is frozen. |
| 205 | |
| 206 | ## API |
| 207 | |
| 208 | All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`) and are |
| 209 | made on loopback (`http://127.0.0.1:4600`); nginx refuses `/api/v1` on the public host. Every |
| 210 | POST needs an `Idempotency-Key` (8–200 of `A-Za-z0-9._:-`); a replay returns the original response |
| 211 | (`Idempotent-Replayed: true`), the same key with another body is `422 idempotency.key_reused`, refusals |
| 212 | are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs `{ "type": "user", "id": "usr_…" }` |
| 213 | (path parameters take the bare id); amounts are integer bits or cents. |
| 214 | |
| 215 | One public route takes a **person's** token instead: `GET https://billing.openvibe.network/api/me/vibes` with the |
| 216 | person's Network user JWT (`Authorization: Bearer`) answers their own `{ credit, payable, currency, bits_per_usd }` |
| 217 | for the shared navbar on every OpenVibe site (`server/api/me.js`). It is CORS-open to the exact https origins the |
| 218 | Contracts service manifests name (never a wildcard), never cached, limited to 30 a minute per person, and answers |
| 219 | `409 billing.not_authoritative` while OpenVibe.Live is still the ledger. |
| 220 | |
| 221 | | Method & path | Capability | Does | |
| 222 | |---|---|---| |
| 223 | | `GET /api/health`, `/api/ready` | — | liveness / readiness (DB + Network key) | |
| 224 | | `GET /api/v1/rates` | — | prices, packages, sub price/share/fee, cashout rules, enabled providers | |
| 225 | | `POST /api/v1/intents` | `billing.intent.create` | start a checkout `{provider, kind: purchase\|subscription, subject, bits \| streamer, tier?, route?, auto_renew?}`; amount priced here (a subscription at the creator's tier price); returns the provider checkout URL, or `checkout_ref` (`pcorder:`/`pcsub:`) for PowerChat | |
| 226 | | `GET /api/v1/intents/:id`, `POST …/:id/capture` | `billing.intent.create` | read; capture an approved PayPal order | |
| 227 | | `POST /api/v1/purchases/settle` | `billing.ledger.admin` | settle a verified provider receipt `{provider, provider_ref, amount_cents, subject \| intent_id, bits?}` | |
| 228 | | `POST /api/v1/transfers` | `billing.transfer.create` | tip/donation `{from, to, amount, kind?, target? (EntityRef), message?}`; self-dealing refused by subject | |
| 229 | | `POST /api/v1/transfers/:id/refund` | `billing.transfer.create` | give a credit-funded transfer back `{amount?, reason?}` (refused if the recipient no longer holds it) | |
| 230 | | `POST /api/v1/recycle` | `billing.cashout.request` | creator payable → own spendable credit `{subject, amount}` | |
| 231 | | `POST /api/v1/cashouts` | `billing.cashout.request` | `{subject, amount, payout_method: {type, address}}` → escrow | |
| 232 | | `GET /api/v1/cashouts[?status&subject]`, `GET …/:id` | `billing.cashout.manage` (`…request` for one) | list / read | |
| 233 | | `POST /api/v1/cashouts/:id/approve` | `billing.cashout.manage` | `{payout_reference (required), payout_provider?}`; refused before the escrow ends | |
| 234 | | `POST /api/v1/cashouts/:id/deny` | `billing.cashout.manage` | reversal back to payable `{reason?}` | |
| 235 | | `POST /api/v1/subscriptions` | `billing.subscription.manage` | start/renew a period `{subscriber, streamer, source: credit, tier?, auto_renew?}` at the tier's price (share credited every period); no switching tiers while a period runs (409 `billing.tier_change_unsupported`); `source: receipt` also needs `billing.ledger.admin` | |
| 236 | | `GET /api/v1/stats[?days=1..90]` | `billing.stats.read` | network-wide counts with no person in them (Vibes bought: all time, d/w/m/pw windows, a daily series; subscriptions: active now, started in the windows and per day; test and imported rows left out of windows and series), for a product's public stats (Live's home) | |
| 237 | | `GET /api/v1/tiers/:streamer` | `billing.tier.manage`, `billing.intent.create` or `billing.subscription.manage` | a creator's tier prices `{streamer, tiers: [{tier, price_cents, status}], default_price_cents, min_price_cents, max_price_cents, max_tiers}`; tier 1 without a row sells at the default price | |
| 238 | | `PUT /api/v1/tiers/:streamer/:tier` | `billing.tier.manage` | set a tier's monthly price `{price_cents, status?: active\|archived}` within the bounds (`BILLING_TIER_MIN_CENTS`..`BILLING_TIER_MAX_CENTS`, at most `BILLING_MAX_TIERS` tiers); the product that sells memberships (OpenVibe.VIP) calls it for the creator; archived sells no more, running subscriptions renew at the price they were bought at | |
| 239 | | `POST /api/v1/subscriptions/:id/cancel` | `billing.subscription.manage` | cancel at period end; Stripe is cancelled at Stripe first | |
| 240 | | `POST /api/v1/subscriptions/:id/refund` | `billing.ledger.admin` | staff refund of one whole period paid from credit `{transaction_id \| period_end, reason}` → 201 `{transaction, subscription, entitlement}`; the period is revoked (the subscription is shortened, or ends with its last period); a period already refunded or charged back → 200 `{transaction: null, noop: "already_reversed", reversed_by}`; a receipt-paid period → 422 `billing.not_refundable` (its provider reverses it) | |
| 241 | | `GET /api/v1/subscriptions[?subscriber&streamer&status]`, `GET …/:id` | `billing.entitlement.check` | read | |
| 242 | | `GET /api/v1/entitlements/:subject[?streamer=]` | `billing.entitlement.check` | `{active, expires_at, grace_until, subscription}` — needs nothing but Billing; `grace_until` is set only while the subscription is `past_due` (never active) | |
| 243 | | `GET /api/v1/balances/:subject` | `billing.balance.read` | `{credit, payable, pending_payouts, promo_bits, payable_value_cents}` (`promo_bits`: unused free allowance, never transferable) | |
| 244 | | `GET /api/v1/transactions?subject=&cursor=&limit=`, `GET …/:id` | `billing.balance.read` | history (cursor paging), one transaction with `reversed_by` | |
| 245 | | `POST /api/v1/usage` | `billing.usage.record` | store a `platform.usage-sample@1` reading (charges nothing here: the rating sweep does, see Rates; accepted while frozen); idempotent by the reading's `idempotency_key`: same money fields (`service`, `project`, `subject`, `resource`, `provider`, `operation`, `quantity`, `unit`, `at`; `at` as an instant, absent = null) → 200 with the first stored row, unchanged (`Idempotent-Replayed: true`), different money fields → 409 `billing.usage_key_reused`; placement and correlation fields (`node`, `cell`, `region`, `route_epoch`, `trace_id`, `source`, `cost_estimate`, `free_allowance_used`, `vibes_charged`) never cause a 409; no `Idempotency-Key` header | |
| 246 | | `GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor=` | `billing.ledger.admin` | readings newest first, `from` ≤ `at` < `to`, cursor paging (`limit` ≤ 500) | |
| 247 | | `GET\|POST /api/v1/admin/freeze` | `billing.ledger.admin` | read / set `{on, reason?}`; unfreeze drains queued webhooks | |
| 248 | | `GET /api/v1/admin/reconcile` | `billing.ledger.admin` | run + store a reconciliation | |
| 249 | | `GET /api/v1/admin/reconciliations[?limit&failed=1]`, `GET …/:id` (`latest`) | `billing.ledger.admin` | stored runs (scheduled and on demand) with their trigger; one full report | |
| 250 | | `GET\|POST /api/v1/admin/provider-accounts` | `billing.ledger.admin` | list / map `{provider, username, account_id?, subject}` a provider account to its creator (allowed while frozen) | |
| 251 | | `POST /api/v1/admin/adjustments` | `billing.ledger.admin` | `{from: account, to: account, amount, reason, relates_to?}` (same currency; a promo account only against the other promo kind) | |
| 252 | | `POST /api/v1/admin/promo/grant` | `billing.ledger.admin` | `{subject, bits, service? ('*'), period? (day \| month \| none; month)}` → 201 `{allowance, transaction}`: tops up the current window (a second grant in it adds); idempotent by `Idempotency-Key` | |
| 253 | | `POST /api/v1/admin/rate` | `billing.ledger.admin` | one rating pass now `{batch?}` → `{rated, skipped, busy, failed}` (refused while frozen; no `Idempotency-Key` needed: rating is idempotent per reading) | |
| 254 | | `GET /api/v1/admin/budgets?subject=`, `POST /api/v1/admin/budgets` | `billing.ledger.admin` | hard usage budgets; set `{subject, service? ('*'), period? (day \| month \| none; month), budget_bits}` for the window open now (keeps what it has spent) | |
| 255 | | `GET /api/v1/admin/provider-events[?pending=1]`, `POST …/:id/reprocess` | `billing.ledger.admin` | receipts; retry an unprocessed/rejected one | |
| 256 | | `POST /api/v1/admin/sweep`, `GET /api/v1/admin/import-holds` | `billing.ledger.admin` | renewal sweep now; unmapped import users | |
| 257 | | `POST /webhooks/<provider>` | provider signature | `powerchat`, `stripe`, `paypal`, `ccbill` (GET too), `nowpayments` | |
| 258 | | `GET /metrics` | direct loopback caller | Prometheus text (openvibe-shared/metrics): HTTP golden signals, process, `release_info`, and `billing_*` gauges — freeze, authority, receipts by state, oldest pending receipt, EXTERNAL receipts, outbox backlog, cashouts, the latest reconciliation. 404 to anything a proxy relayed; nginx answers 404 on the public host | |
| 259 | | `GET\|POST /internal/config[/:namespace[/history\|rollback]]`, `PUT\|DELETE …/secrets/:key` | `billing.config.manage` | owner settings (D11): namespace summaries with the schema, values, pins and `sealed`; apply a change (danger keys need `X-OV-Fresh: 1`); history and rollback. Network's AI tab is the only caller; nginx 404s `/internal/` on the public host | |
| 260 | |
| 261 | The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0; |
| 262 | `billing.staff.action` since v0.17.0). `billing.usage.record` is released in Contracts v0.85.0: input is |
| 263 | `platform.usage-sample@1`; output is `billing.usage-record-result@1` = |
| 264 | `{ record: { id, idempotency_key, project|null, subject|null, service, at, received_at, principal, reading } }`, |
| 265 | 201 when stored and 200 with `Idempotent-Replayed: true` on a replay; `GET /api/v1/usage` needs |
| 266 | `billing.ledger.admin` and returns `{ records, next_cursor }`. Network grants `billing.usage.record` |
| 267 | (audience `openvibe.billing`) to each producer service in `server/identity/principals.js`, and no service |
| 268 | holds it yet: the first producer gets the grant in the same change that wires its calls. Grants are matched |
| 269 | with contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`). |
| 270 | |
| 271 | ### Per-person limits |
| 272 | |
| 273 | The money-creating routes also limit how often one person can use them: `server/api/actor-limits.js`, |
| 274 | openvibe-sdk/limits, roadmap WS-R task 4. Every caller is a first-party service acting for many people, |
| 275 | so nothing is counted per service: a request counts against the person its body names (the buyer, the |
| 276 | payer, the creator, the subscriber), whichever service sends it; a refund counts against the payer of the |
| 277 | transfer it gives back. The limit runs after the capability and freeze checks (a request refused while |
| 278 | frozen is not counted) and before the idempotency store and the ledger, so a refusal stores nothing and |
| 279 | the same `Idempotency-Key` works after `Retry-After`. Past a limit: `429` problem+json `rate_limited`, one log line and |
| 280 | `billing_rate_limited_total{limit,window}`. `BILLING_LIMITS=off` turns them all off (a rollback lever). |
| 281 | |
| 282 | | Route | Per person, a minute / an hour | |
| 283 | |---|---| |
| 284 | | `POST /api/v1/intents` (checkout) | 5 / 30 | |
| 285 | | `POST /api/v1/transfers` (per payer) | 60 / 1200 | |
| 286 | | `POST /api/v1/transfers/:id/refund` (per payer of the transfer) | 20 / 200 | |
| 287 | | `POST /api/v1/cashouts` (payout request) | 3 / 10 | |
| 288 | | `POST /api/v1/recycle` | 10 / 60 | |
| 289 | | `POST /api/v1/subscriptions` (per subscriber) | 10 / 60 | |
| 290 | | `GET /api/v1/transactions?subject=` (history) | `BILLING_LIMITS_MINUTE` / `BILLING_LIMITS_HOUR` (120 / 3000) | |
| 291 | |
| 292 | Never limited: the provider webhooks (`/webhooks/*`), capturing an approved order, cashout approve and |
| 293 | deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement |
| 294 | reads, `/api/health`, `/api/ready`, `/release.json` and `/metrics`. `test/actor-limits.test.js`. |
| 295 | |
| 296 | ## Staff console |
| 297 | |
| 298 | Server-rendered pages at the root of `https://billing.openvibe.network` for the people who decide |
| 299 | payouts — the replacement for Live's cashout admin, which answers 409 once Live runs with |
| 300 | `BILLING_AUTHORITY=billing` ([docs/live-cutover.md](docs/live-cutover.md)). No JavaScript, no external |
| 301 | resources, `noindex`, `Cache-Control: no-store`, a strict CSP (`default-src 'none'`, `form-action 'self'`, |
| 302 | `frame-ancestors 'none'`). Code: [server/console/](server/console/); tests: `test/console.test.js`. |
| 303 | |
| 304 | **Sign-in:** OpenVibe.Network SSO, authorization code with PKCE (S256), as OAuth client `billing` |
| 305 | (redirect `https://billing.openvibe.network/auth/callback`, which the Network must list for that |
| 306 | client). The code is exchanged server to server on `OV_NETWORK_INTERNAL_URL`; the access token is |
| 307 | verified offline with the Network key Billing already loads (issuer, audience `openvibe.network`, |
| 308 | expiry). Billing keeps none of the Network's tokens — the refresh token it is handed is revoked at once. |
| 309 | |
| 310 | **Who:** a person whose Network token says `role: "admin"` **and** whose subject (`subject_id`, a |
| 311 | `usr_…`) is listed in `BILLING_STAFF_SUBJECTS`. Everyone else gets 403 and no session; the refusal is |
| 312 | recorded in the audit log. The list is re-checked on every request: removing someone ends their session |
| 313 | on their next click. |
| 314 | |
| 315 | **Session:** cookie `__Host-ovb_staff` — random 256-bit id (the database keeps only its SHA-256), |
| 316 | host-only, `Path=/`, `HttpOnly`, `Secure`, `SameSite=Strict`, absolute lifetime `BILLING_SESSION_TTL_MIN` |
| 317 | (60 min). The OAuth state + PKCE verifier travel in a 10-minute `ovb_flow` cookie (`Path=/auth`, |
| 318 | `SameSite=Lax` for the return from openvibe.network) signed with `BILLING_SESSION_SECRET`. Every form |
| 319 | carries the session's CSRF token (constant-time compare) and cross-site `Origin` / `Sec-Fetch-Site` |
| 320 | POSTs are refused. Sign-out is a POST. |
| 321 | |
| 322 | **What staff may do** is expressed in the API's own capability ids: a staff session is the principal |
| 323 | `{ sub: <usr_…>, cap: [billing.cashout.manage, billing.ledger.admin] }` and every page names the one |
| 324 | capability it needs. Every action calls the same functions the API does — `ops/cashouts.approve|deny`, |
| 325 | `ops/admin.setFreeze` (+ processing the held webhooks on unfreeze), `reconcile()`, |
| 326 | `providers.reprocess()` — behind the same freeze guard; there is no second implementation. |
| 327 | |
| 328 | | Page | Needs | Does | |
| 329 | |---|---|---| |
| 330 | | `/` | staff | freeze state, last reconciliation, outstanding credit / payable / pending payouts (test transactions excluded), queue counts | |
| 331 | | `/cashouts?tab=escrow\|ready\|paid\|denied` | `billing.cashout.manage` | the queue with escrow dates (Billing has no separate "approved" state: approving records a payout already made, so it is **paid**) | |
| 332 | | `/cashouts/:id` | `billing.cashout.manage` | detail; **approve** needs the provider's payout reference, a payout provider and a ticked confirmation, and is refused before the escrow ends; **deny** needs a reason and returns the amount to the creator's payable. A resubmitted form replays (per-form action key → the ops idempotency key), never pays twice | |
| 333 | | `/receipts` | `billing.ledger.admin` | provider receipts flagged for review (unattributed site tips, underpaid deliveries, held site-routed tips), rejected ones (incl. deliveries for orders Live already credited — `billing.intent_settled_in_live`), stored-but-unprocessed ones, reversals flagged `review: required`; reprocess an unprocessed/rejected one. Shown by id and outcome only — never the provider payload | |
| 334 | | `/import-holds` | `billing.ledger.admin` | unmapped Live users from the importer | |
| 335 | | `/reconciliation`, `/reconciliation/:id` | `billing.ledger.admin` | run + history; a run shows checks, offender counts, warning counts and totals | |
| 336 | | `/freeze` | `billing.ledger.admin` | freeze / unfreeze with a mandatory reason | |
| 337 | | `/audit` | `billing.ledger.admin` | the staff audit log | |
| 338 | |
| 339 | **Audit:** every staff action — sign-in, sign-out, approve, deny, freeze, unfreeze, reconciliation run, |
| 340 | reprocess — and every refused attempt (non-staff sign-in, CSRF failure, a refused approval) is one row |
| 341 | in the append-only `staff_audit` table (actor subject and username, action, target, reason, outcome, |
| 342 | a small detail such as the payout reference or refusal code, request id, and an HMAC of the client IP |
| 343 | keyed by `BILLING_SESSION_SECRET`). Done actions are also written to the outbox as |
| 344 | `billing.staff.action` (visibility `internal`, actor = the staff subject), in the same |
| 345 | transaction as their effect. Adjustments are not in the console; they stay on the API |
| 346 | (`POST /api/v1/admin/adjustments`). |
| 347 | |
| 348 | ## Providers |
| 349 | |
| 350 | | Adapter | Enabled when | Settles | Reverses | |
| 351 | |---|---|---|---| |
| 352 | | PowerChat | `POWERCHAT_WEBHOOK_SECRET` | `donation.completed` with `pcorder:` (purchase), `pcsub:` (site → share; direct → EXTERNAL entitlement), `pcdon:` (site-routed tip → payable); any other tip on a streamer's own account → EXTERNAL receipt (`billing.receipt.external` under `billing`) | `donation.refunded`, `donation.disputed`/`donation.chargeback` (event names to confirm with PowerChat) | |
| 353 | | Stripe | `STRIPE_SECRET_KEY` + `STRIPE_WEBHOOK_SECRET` | `checkout.session.completed` (payment), every `invoice.paid` | `charge.refunded` (cumulative), `charge.dispute.funds_withdrawn` | |
| 354 | | PayPal | client id + secret + `PAYPAL_WEBHOOK_ID` | `PAYMENT.CAPTURE.COMPLETED`, capture after return | `PAYMENT.CAPTURE.REFUNDED`, `…REVERSED` | |
| 355 | | CCBill | `CCBILL_WEBHOOK_SECRET` | `NewSaleSuccess` (reported price required) | `Refund`, `Void`, `Chargeback` | |
| 356 | | NOWPayments | `NOWPAYMENTS_IPN_SECRET` | `finished`/`confirmed`/`sending` | `refunded` | |
| 357 | |
| 358 | Only PowerChat is expected to be enabled in production. A reversal of a purchase claws back the |
| 359 | buyer's remaining credit; credit already given to someone stays with them — their payable is never |
| 360 | touched — and the unrecovered value goes to `chargeback_loss`, flagged `review: required`. |
| 361 | Reversing a subscription payment revokes the periods it granted. A period paid from credit has no receipt: |
| 362 | staff refund it whole (`POST /api/v1/subscriptions/:id/refund`, one `refund` transaction reversing the period's |
| 363 | payment): the subscriber gets the credit back (never the promo allowance, which pays no period), platform |
| 364 | revenue gives back its part, and the creator share comes back out of `creator_payable` as far as it is still |
| 365 | there — a share already recycled or cashed out goes to `chargeback_loss`, flagged `review: required`. |
| 366 | |
| 367 | ## Operations |
| 368 | |
| 369 | - **Reconciliation** runs **every hour on its own** (`BILLING_RECONCILE_INTERVAL_MS`, a first run a |
| 370 | minute after boot, also while frozen — it only reads) and on demand (`npm run reconcile`, |
| 371 | `GET /api/v1/admin/reconcile`, the console's Reconciliation page, after every import). It checks: journal |
| 372 | zero-sum per currency, per-transaction balance, cached balances = entry sums, fx mirror, every settled |
| 373 | provider event has exactly one transaction, paid cashouts have payout references, payouts_pending = |
| 374 | requested cashouts — and the ledger against the provider receipts: every transaction settled from a |
| 375 | receipt names its provider and moved exactly the receipt's cents through that provider's clearing |
| 376 | account (`receipts.ledger`), EXTERNAL receipts are never in the journal and each announced one has one |
| 377 | outbox event (`receipts.external`), and no receipt waits unprocessed longer than |
| 378 | `BILLING_RECONCILE_STALE_RECEIPT_MIN` (60) while the economy is open (`receipts.stale`) — and every paid |
| 379 | period against its charge: a granted period rests on an unreversed subscription transaction (periods imported |
| 380 | from Live are counted, not failed) and a period revoked for a refund or chargeback has its payment's reversal |
| 381 | (`subscriptions.period_charged`). It also lists |
| 382 | negative balances, unprocessed/rejected events, items for review and import holds; totals exclude test |
| 383 | transactions. Every run is stored in `reconciliation_runs` with its trigger (`scheduled`, `api`, |
| 384 | `console`, `script`, `import`); passing scheduled runs older than `BILLING_RECONCILE_KEEP_DAYS` (30) are |
| 385 | pruned, failed ones are kept. Results: the console, `GET /api/v1/admin/reconciliations`, and `/metrics` |
| 386 | (`billing_reconciliation_ok`, `…_failed_checks`, `…_last_run_timestamp_seconds`); a failed scheduled run |
| 387 | is also logged. |
| 388 | - **Renewals** run in the hourly subscription sweep (`BILLING_SWEEP_INTERVAL_MS`, not while frozen; on demand |
| 389 | `POST /api/v1/admin/sweep`): a credit subscription whose period ended is charged once from the subscriber's |
| 390 | credit (never the promo allowance) under `renew:<sub>:<period end>` — `…:<k>` after `k` charges under that |
| 391 | period's earlier keys were refunded or charged back — so a retried, replayed or concurrent sweep charges and |
| 392 | credits the creator share once. A renewal the credit cannot pay leaves the subscription `past_due` |
| 393 | for `BILLING_RENEWAL_GRACE_DAYS` days past the period end (default **3**; set `0` to end it at once |
| 394 | with `renewal_insufficient_credit`): no access meanwhile (event reason `renewal_failed`), every |
| 395 | later sweep retries the same key (a top-up is picked up; a cancel ends it), and past `grace_until` it |
| 396 | expires (`grace_ended`). Turning the grace on (or off): [docs/renewal-grace-cutover.md](docs/renewal-grace-cutover.md). |
| 397 | - **Freeze** before any risky change: the console's Freeze page, `POST /api/v1/admin/freeze {"on": true, "reason": "…"}`, or on the |
| 398 | host `node scripts/freeze.js on "<reason>"` (same switch; after `off` the running service processes the |
| 399 | held webhooks on its next retry tick). |
| 400 | - **Review queue**: chargebacks on donated credit and unattributed site tips appear under |
| 401 | `warnings` and on the console's Receipts page; resolve them with an adjustment (`relates_to` = the transaction). |
| 402 | - **Payouts** are decided in the staff console (or with `billing.cashout.manage` on the API): pay at the |
| 403 | provider, then approve with its payout reference once the escrow has ended. |
| 404 | - **Metrics**: `curl -s http://127.0.0.1:4600/metrics` on the host. |
| 405 | - **Request telemetry** (plan T1 step 10): each HTTP request is aggregated by route template, method and |
| 406 | status class, never by raw URL; `/shared` files, static assets, health, readiness and `/metrics` are not |
| 407 | counted. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and |
| 408 | event-loop-lag gauges, is written to Billing's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, |
| 409 | the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily. `server/app.js` mounts |
| 410 | openvibe-sdk's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()` at |
| 411 | production start, runs the 14-day retention and flushes on stop; the table is `migrations/0007_telemetry.sql`. |
| 412 | - **Backups**: `pg_dump ov_billing > billing-$(date +%F).sql` (the host's data role, ADR-035). |
| 413 | - **Mapping of Live's tables** to Billing's (including payouts, refunds and plans, which have no table |
| 414 | of their own on Live): [docs/live-mapping.md](docs/live-mapping.md). |
| 415 | |
| 416 | ### Account export and deletion (ADR-033) |
| 417 | |
| 418 | Billing answers a person's account export and deletion since 2026-10-17 ([server/account-data.js](server/account-data.js), |
| 419 | openvibe-sdk/account-data): OpenVibe.Events delivers `network.account.export_requested` and `network.account.deleted` to |
| 420 | `POST /internal/events` on 127.0.0.1:4600 (signed with `BILLING_EVENTS_SECRET`; loopback only, and nginx 404s |
| 421 | `/internal/`), the two subscriptions are created at boot. The export holds the person's balances and every money record |
| 422 | about them: transactions sent and received, checkouts, cash-outs, subscriptions (as a member, and as a creator without |
| 423 | the members' identities), entitlements, tips on their own PowerChat, usage, promo allowances, tier prices and connected |
| 424 | payment accounts. A deletion keeps the money records (the ledger must still balance; payments are records the law |
| 425 | requires) and removes settings: tier prices, connected payment accounts and staff sessions. Each deletion is confirmed |
| 426 | once with its counts (`account_data_events`). |
| 427 | |
| 428 | ## Acceptance |
| 429 | |
| 430 | `npm test` runs every `test/*.test.js` against stub Network, Events and providers, temp databases and |
| 431 | random ports. What they prove: the journal balances per currency and refuses edits |
| 432 | (`ledger.test.js`); a receipt settles once whatever retries carry it, and reversals claw back only what |
| 433 | can be (`webhooks.test.js`, `providers.test.js`, `stripe.test.js`); subscriptions and entitlements |
| 434 | (`subscriptions.test.js`); a renewal is one charge per period, a failed one is retried on its key in the |
| 435 | grace and then ended, a promo allowance never pays it (`renewal-grace.test.js`); a credit-paid period is refunded |
| 436 | whole and once, the creator share clawed back or booked as loss, and every paid period has its charge record |
| 437 | (`subscription-refund.test.js`); EXTERNAL receipts are announced once and only under `billing` |
| 438 | (`external.test.js`); the import reconciles (`import.test.js`); freeze, reconciliation and review |
| 439 | (`operations.test.js`); the cutover sequence (`cutover.test.js`); the staff console and its staff map |
| 440 | (`console.test.js`, `staff-map.test.js`); the public policy (`policy.test.js`); the vhost keeps `/api/v1` |
| 441 | off the public host (`nginx-vhost.test.js`, `security.test.js`); per-person limits |
| 442 | (`actor-limits.test.js`); service auth and the outbox (`auth-outbox.test.js`). |
| 443 | |
| 444 | ## Security |
| 445 | |
| 446 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps: |
| 447 | |
| 448 | - **Auth.** `/api/v1` answers only on loopback (nginx denies it publicly) and needs a Network service |
| 449 | token for audience `openvibe.billing` with the route's capability; every POST needs an |
| 450 | `Idempotency-Key`. The staff console is Network SSO with PKCE, limited to `BILLING_STAFF_SUBJECTS`, |
| 451 | and every staff action is audited (`billing.staff.action`). |
| 452 | - **Money integrity.** The journal is append-only (triggers refuse UPDATE/DELETE), every transaction |
| 453 | sums to zero per currency, funds checks run inside the transaction that moves money, and a receipt is |
| 454 | stored before it is processed. The freeze stops every mutation; hourly reconciliation checks the |
| 455 | ledger against the receipts. |
| 456 | - **Webhooks.** Each provider's signature is verified before anything is read; a disabled provider |
| 457 | answers 404. A buyer-editable reference on a tip to a creator's own account moves no money |
| 458 | (`test/security.test.js`). |
| 459 | - **Secrets.** Provider secrets (`POWERCHAT_WEBHOOK_SECRET`, `STRIPE_*`, `PAYPAL_*`, `CCBILL_*`, |
| 460 | `NOWPAYMENTS_*`), `OV_OAUTH_CLIENT_SECRET` and `BILLING_SESSION_SECRET` live in |
| 461 | `/etc/openvibe/billing.env` (0600), by name only. `/metrics` answers direct loopback callers only. |
| 462 | - **Egress.** Billing calls Network, Events and the enabled providers' APIs only. |
| 463 | |
| 464 | ## Deploy |
| 465 | |
| 466 | Production deploys with `sudo ovhost deploy billing` on the host (strategy `git-checkout`: fetch, |
| 467 | fast-forward `/opt/openvibe.billing`, install on a lockfile change, restart, wait for `/api/ready`). |
| 468 | The unit is `openvibe-billing.service` on `127.0.0.1:4600`, the env file `/etc/openvibe/billing.env`. The ledger is |
| 469 | `ov_billing` on the host's data role (`sudo /opt/openvibe.host/roles/data/add-service.sh billing` writes its settings); |
| 470 | the release applies `migrations/` at boot as the owner (`DATABASE_DIRECT_URL`), and every read and write is async |
| 471 | through `openvibe-sdk/db`. nginx serves `billing.openvibe.network` from |
| 472 | [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf) (console, `/policy`, |
| 473 | `/webhooks/*`, `/api/health`). Freeze the economy before any risky change (below). |
| 474 | Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the |
| 475 | restart; afterwards `sudo ovhost rollback billing --to <sha>`. Migrations only add tables and columns. |
| 476 | `BILLING_LIMITS=off` turns the per-person limits off without a deploy. |
| 477 | |
| 478 | ## Cutover runbook (Live → Billing) |
| 479 | |
| 480 | The exact production sequence, rollback, grants and the Live behaviour the switch cannot preserve are in |
| 481 | [docs/live-cutover.md](docs/live-cutover.md); the Live side (a `BILLING_AUTHORITY=live|billing` switch, |
| 482 | `live` by default, plus the `money_writes_frozen` freeze) was the patch |
| 483 | [docs/live-patch.diff](docs/live-patch.diff) and is deployed in OpenVibe.Live since `c384787` with the |
| 484 | switch unset. **Run on 2026-10-17 04:50–04:56 UTC** (docs/live-cutover.md has the record): final import 0 new rows, |
| 485 | reconciliation OK before and after. Step 3 was done in Live's vhost rather than the PowerChat dashboard: openvibe.live's |
| 486 | `/api/powerchat/webhook` is proxied to Billing (OpenVibe.Live#172; the signature does not cover the URL). In short: |
| 487 | |
| 488 | 1. **Shadow import** as often as needed (`billing_live 'node scripts/import-live.js --dry-run'`, docs/live-cutover.md); |
| 489 | map held users in the Network and explain every adjustment until reconciliation is clean. |
| 490 | 2. **Freeze Live money writes**, wait for in-flight PowerChat checkouts (an hour), back up and **freeze Billing**, |
| 491 | set `BILLING_AUTHORITY=billing` in billing.env (EXTERNAL tips are then announced for Tips). |
| 492 | 3. **Re-point the PowerChat webhook** to `https://billing.openvibe.network/webhooks/powerchat` (Billing holds |
| 493 | the deliveries while frozen), then run the **final import** (Live's money writes frozen); reconcile. |
| 494 | 4. **Switch Live** (`BILLING_AUTHORITY=billing`, deploy with `--wait-idle`), **unfreeze Billing** (held |
| 495 | deliveries settle; ones Live already credited are rejected for review, never credited twice). |
| 496 | 5. **Verify** the reads, **unfreeze Live**, verify with a small real PowerChat purchase. |
| 497 | |
| 498 | Import runs are safe after cutover too: an order Live credited is imported as settled-in-Live and never |
| 499 | settles again here, and opening balances only ever correct the imported part of an account, never what |
| 500 | Billing did itself. |
| 501 | |
| 502 | ## Launch rule |
| 503 | |
| 504 | `billing.openvibe.network` left OpenVibe.Sites on 2026-09-23: it serves the staff console, the billing policy and a |
| 505 | person's own Vibes (`/api/me/vibes`). Billing is not a public product of its own; it became the money authority with |
| 506 | the cutover above (2026-10-17). Plan §12.12, as it stood: owning runtime with health/readiness and `/metrics` ✔; canonical identity and |
| 507 | scoped service principals ✔ (principal `billing`); server-rendered public routes useful without |
| 508 | JavaScript (partly — the public billing policy at `/policy`; the other pages are the staff-only console); real persistence and end-to-end |
| 509 | workflows ✔ in tests, and in production since the cutover; capability/event registration against |
| 510 | OpenVibe.Contracts ✔ (v0.8.0); migration strategy ✔ (shadow import reconciles; restore drill passed |
| 511 | 2026-09-23) with a security review, sitemap/robots still to do; acceptance tests ✔. Still due: a small real |
| 512 | PowerChat purchase settling end to end after the cutover. |
| 513 | |
| 514 | --- |
| 515 | |
| 516 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 517 | |
| 518 | <!-- versions:start --> |
| 519 | - openvibe-contracts: v0.419.0 |
| 520 | - openvibe-sdk: v0.166.0 |
| 521 | - openvibe-shared: v4.241.0 |
| 522 | <!-- versions:end --> |
| 523 |