openvibers/openvibe.billing README.md

README.md

main 47.2 KB · 523 lines · markdown

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
10are read-only. Deployed on the host since 2026-09-23 (`/opt/openvibe.billing`, API on `127.0.0.1:4600`); it ran in
11shadow, holding a reconciled import of Live's money data, until the cutover. Every OpenVibe site's account menu shows
12a 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) —
16economic 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
22One append-only, balanced, double-entry journal for every MONEY and CREDIT flow on the network,
23kept apart from loyalty points (OpenCoins, channel points) and from every product UI. Provider
24webhooks 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)
36and free promo allowance (`promo_credit`) are separate account kinds that never mix. Credit becomes money only
37through 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
42promo is refused with `billing.insufficient_funds`; an admin adjustment that pairs a promo account with any other
43kind is refused (`billing.promo_isolated`), and reconciliation's `promo.isolated` check fails on any transaction
44that mixes them. `promo_allowances` is the policy and window record (`granted_bits`, `used_bits`, `expired_bits`
45per subject, service, period and window); the `promo_credit` balance is the authoritative remainder, and every
46promo 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
78Implemented 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
82Contracts v0.85.0), and the public `billing.vibes.charge` and `billing.vibes.read` for developer apps (D12).
83Callers today are Live (after the cutover),
84Tips (`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
87Called 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
93fnm exec --using=22.22.1 npm install
94cp .env.example .env # set OV_OAUTH_CLIENT_SECRET, POWERCHAT_WEBHOOK_SECRET, …
95npm run dev # http://localhost:4600
96npm test # 13 test files: stub Network/Events/providers, temp DBs, random ports
97npm run reconcile # reconciliation report (exit 1 on failure)
98npm run freeze -- on "reason" # economy freeze from the host (status | on "<reason>" | off)
99LIVE_DATABASE_URL=… node scripts/import-live.js [--dry-run] [--json] # reads Live's PostgreSQL (or --live-env <Live's env file>)
100LIVE_DATABASE_URL=… node scripts/import-live.js --accounts-only # only PowerChat account → creator mappings
101```
102
103Production: `/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
171Billing 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
173synchronous 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
208All `/api/v1` calls need a service token from OpenVibe.Network (audience `openvibe.billing`) and are
209made on loopback (`http://127.0.0.1:4600`); nginx refuses `/api/v1` on the public host. Every
210POST 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
212are 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
215One public route takes a **person's** token instead: `GET https://billing.openvibe.network/api/me/vibes` with the
216person's Network user JWT (`Authorization: Bearer`) answers their own `{ credit, payable, currency, bits_per_usd }`
217for the shared navbar on every OpenVibe site (`server/api/me.js`). It is CORS-open to the exact https origins the
218Contracts 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
261The 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 } }`,
265201 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
268holds it yet: the first producer gets the grant in the same change that wires its calls. Grants are matched
269with contracts' own `capabilities.grants()` (exact id or a `.*` family such as `billing.*`).
270
271### Per-person limits
272
273The money-creating routes also limit how often one person can use them: `server/api/actor-limits.js`,
274openvibe-sdk/limits, roadmap WS-R task 4. Every caller is a first-party service acting for many people,
275so nothing is counted per service: a request counts against the person its body names (the buyer, the
276payer, the creator, the subscriber), whichever service sends it; a refund counts against the payer of the
277transfer it gives back. The limit runs after the capability and freeze checks (a request refused while
278frozen is not counted) and before the idempotency store and the ledger, so a refusal stores nothing and
279the 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
292Never limited: the provider webhooks (`/webhooks/*`), capturing an approved order, cashout approve and
293deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement
294reads, `/api/health`, `/api/ready`, `/release.json` and `/metrics`. `test/actor-limits.test.js`.
295
296## Staff console
297
298Server-rendered pages at the root of `https://billing.openvibe.network` for the people who decide
299payouts — 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
301resources, `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
306client). The code is exchanged server to server on `OV_NETWORK_INTERNAL_URL`; the access token is
307verified offline with the Network key Billing already loads (issuer, audience `openvibe.network`,
308expiry). 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
312recorded in the audit log. The list is re-checked on every request: removing someone ends their session
313on their next click.
314
315**Session:** cookie `__Host-ovb_staff` — random 256-bit id (the database keeps only its SHA-256),
316host-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
319carries the session's CSRF token (constant-time compare) and cross-site `Origin` / `Sec-Fetch-Site`
320POSTs 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
324capability 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,
340reprocess — and every refused attempt (non-staff sign-in, CSRF failure, a refused approval) is one row
341in the append-only `staff_audit` table (actor subject and username, action, target, reason, outcome,
342a small detail such as the payout reference or refusal code, request id, and an HMAC of the client IP
343keyed 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
345transaction 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
358Only PowerChat is expected to be enabled in production. A reversal of a purchase claws back the
359buyer's remaining credit; credit already given to someone stays with them — their payable is never
360touched — and the unrecovered value goes to `chargeback_loss`, flagged `review: required`.
361Reversing a subscription payment revokes the periods it granted. A period paid from credit has no receipt:
362staff refund it whole (`POST /api/v1/subscriptions/:id/refund`, one `refund` transaction reversing the period's
363payment): the subscriber gets the credit back (never the promo allowance, which pays no period), platform
364revenue gives back its part, and the creator share comes back out of `creator_payable` as far as it is still
365there — 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
418Billing answers a person's account export and deletion since 2026-10-17 ([server/account-data.js](server/account-data.js),
419openvibe-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
422about them: transactions sent and received, checkouts, cash-outs, subscriptions (as a member, and as a creator without
423the members' identities), entitlements, tips on their own PowerChat, usage, promo allowances, tier prices and connected
424payment accounts. A deletion keeps the money records (the ledger must still balance; payments are records the law
425requires) and removes settings: tier prices, connected payment accounts and staff sessions. Each deletion is confirmed
426once 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
431random 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
433can 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
435grace and then ended, a promo allowance never pays it (`renewal-grace.test.js`); a credit-paid period is refunded
436whole 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`
441off 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
446Reporting 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
466Production deploys with `sudo ovhost deploy billing` on the host (strategy `git-checkout`: fetch,
467fast-forward `/opt/openvibe.billing`, install on a lockfile change, restart, wait for `/api/ready`).
468The 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);
470the release applies `migrations/` at boot as the owner (`DATABASE_DIRECT_URL`), and every read and write is async
471through `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).
474Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
475restart; 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
480The 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
484switch unset. **Run on 2026-10-17 04:50–04:56 UTC** (docs/live-cutover.md has the record): final import 0 new rows,
485reconciliation 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
4881. **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.
4902. **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).
4923. **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.
4944. **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).
4965. **Verify** the reads, **unfreeze Live**, verify with a small real PowerChat purchase.
497
498Import runs are safe after cutover too: an order Live credited is imported as settled-in-Live and never
499settles again here, and opening balances only ever correct the imported part of an account, never what
500Billing 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
505person's own Vibes (`/api/me/vibes`). Billing is not a public product of its own; it became the money authority with
506the cutover above (2026-10-17). Plan §12.12, as it stood: owning runtime with health/readiness and `/metrics` ✔; canonical identity and
507scoped service principals ✔ (principal `billing`); server-rendered public routes useful without
508JavaScript (partly — the public billing policy at `/policy`; the other pages are the staff-only console); real persistence and end-to-end
509workflows ✔ in tests, and in production since the cutover; capability/event registration against
510OpenVibe.Contracts ✔ (v0.8.0); migration strategy ✔ (shadow import reconciles; restore drill passed
5112026-09-23) with a security review, sitemap/robots still to do; acceptance tests ✔. Still due: a small real
512PowerChat purchase settling end to end after the cutover.
513
514---
515
516Part 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