Repositories openvibers openvibe.billing official

openvibers/openvibe.billing

Open source double-entry money ledger for the OpenVibe network: providers, receipts, subscriptions, entitlements, refunds and payouts.

Get it

Get this repository

Clone over HTTPS

A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.

git clone https://git.openvibe.codes/openvibers/openvibe.billing.git
Clone to my computer

OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.

Clone to my computer
Open in Codes

A Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.

Open in Codes
Files at main
.env.exampleCard payments: the Stripe keys and the live switch are owner settings (billing.cards) (#205)6 days ago8.2 KB
.githubopenvibe-shared v4.223.0 (was v4.214.0) (#278)19 h ago
.gitignoreMove Billing's shutdown onto openvibe-sdk/service gracefulStop (#15)5 Oct 2026106 B
AGENTS.mdAGENTS.md: the context an agent starts from (#277)1 days ago10.0 KB
CITATION.cffREADME first screen and CITATION.cff (#247)3 days ago534 B
CLAUDE.mdAGENTS.md: the context an agent starts from (#277)1 days ago11 B
LICENSECharter: OpenVibe.Billing as defined in the OpenVibe realignment plan (placeholder, no code yet)21 Sept 202633.7 KB
README.mdopenvibe-shared v4.235.0 (was v4.234.0) (#292)19 min ago47.2 KB
SECURITY.mdSECURITY.md: how to report a vulnerability ([email protected], 7-day reply, scope, supported versions)24 Sept 2026986 B
STATUS.jsonopenvibe-shared v4.235.0 (was v4.234.0) (#292)19 min ago5.0 KB
deployPublish dated public offers for Compare (#263)2 days ago
docsAsk 273: Vibes gain/loss API with cursor, read state, balance.changed event (#260)2 days ago
migrationsAsk 273: Vibes gain/loss API with cursor, read state, balance.changed event (#260)2 days ago
package-lock.jsonopenvibe-shared v4.235.0 (was v4.234.0) (#292)19 min ago63.2 KB
package.jsonopenvibe-shared v4.235.0 (was v4.234.0) (#292)19 min ago1.4 KB
scriptsBilling: the Live importer reads Live's PostgreSQL; the cutover runbook follows (C-30) (#47)11 Oct 2026
serverRoute signed-in nonstaff Billing arrivals to Wallet (#272)1 days ago
testRoute signed-in nonstaff Billing arrivals to Wallet (#272)1 days ago

README.md

View file

OpenVibe.Billing

The isolated money ledger: providers, receipts, subscriptions, entitlements, refunds and payouts.

Docs: https://openvibe.services · For AI: https://openvibe.network/llms.txt
Run it: npm ci && npm run dev (Node 22 or newer) · Tests: npm test

Status: alpha — the money ledger of the OpenVibe network since 2026-10-17 (the cutover below, 04:50–04:56 UTC):
BILLING_AUTHORITY=billing in Billing and OpenVibe.Live, PowerChat deliveries settle here, and Live's own money tables
are read-only. Deployed on the host since 2026-09-23 (/opt/openvibe.billing, API on 127.0.0.1:4600); it ran in
shadow, holding a reconciled import of Live's money data, until the cutover. Every OpenVibe site's account menu shows
a person's Vibes from GET /api/me/vibes.
Domain: billing.openvibe.network — the staff console (Network SSO, staff only), /webhooks/* and
/api/health; the service API /api/v1 is reachable only on loopback (127.0.0.1:4600)
Decision: ADR-012 —
economic classification. The survey behind it is [docs/economic-inventory.md](docs/economic-inventory.md).
Plan: OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §11.1, §11.4, §11.5.
License: AGPL-3.0.

What it is

One append-only, balanced, double-entry journal for every MONEY and CREDIT flow on the network,
kept apart from loyalty points (OpenCoins, channel points) and from every product UI. Provider
webhooks land here and nowhere else.

| ADR-012 class | Here |
|---|---|
| CREDIT (Vibes bought, spendable, never withdrawable by the buyer) | user_credit:<subject> (vibes-bits) |
| MONEY (creator payable, payouts, receipts, platform revenue) | creator_payable:<subject>, payouts_pending:<subject>, provider_clearing:<provider>, platform_revenue, refunds, chargeback_loss |
| 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 |
| ENTITLEMENT (channel subscriptions) | subscriptions + entitlements (one row per paid period) |
| 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 |
| LOYALTY, COSMETIC, GAME-STATE, LEGACY | not here (rule 5–8) |

Three ledgers, one journal. Bought Vibes (user_credit), creator money (creator_payable and its payouts)
and free promo allowance (promo_credit) are separate account kinds that never mix. Credit becomes money only
through a transfer (transfers.create: user_credit → creator_payable); money leaves only through a cashout
(creator_payable → payouts_pending). Promo has no such path: it is granted by an admin
(POST /api/v1/admin/promo/grant: promo_reserve → promo_credit) and only drawn down by usage
(ops/promo.js consume(), keyed by the caller's idempotency key) or lapses when its window ends, both back to
promo_reserve. Transfers read user_credit only and cashouts creator_payable only, so a subject holding only
promo is refused with billing.insufficient_funds; an admin adjustment that pairs a promo account with any other
kind is refused (billing.promo_isolated), and reconciliation's promo.isolated check fails on any transaction
that mixes them. promo_allowances is the policy and window record (granted_bits, used_bits, expired_bits
per subject, service, period and window); the promo_credit balance is the authoritative remainder, and every
promo write changes both in one transaction.

Owns

  • the journal (accounts, transactions, ledger_entries, account_balances), provider receipts
    (provider_events, external_receipts), intents, subscriptions and entitlements, cashouts, usage readings
    (usage_records), promo allowances (promo_allowances), the freeze, reconciliation runs, import runs and the
    staff audit log, in Billing's own PostgreSQL database (ov_billing on the host's data role, ADR-035;
    schema in [migrations/](migrations/))
  • provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments): they land here and nowhere else
  • the billing.* events and the public billing policy page (/policy, every number from the rates)
  • Vibes Pay (D12): app charges, the project's project_payable Vibes wallet, its signed webhooks and sandbox test
    Vibes ([docs/vibes-pay.md](docs/vibes-pay.md))

Does not own

  • loyalty points, cosmetics, game state (ADR-012 rules 5–8): OpenCoins stay in Network, channel points
    in Live
  • product UIs: tips (OpenVibe.Tips), memberships and perks (OpenVibe.VIP), checkout pages (each product)
  • identity (OpenVibe.Network)

Depends on

  • OpenVibe.Network (JWKS for service tokens; SSO with PKCE for the staff console; identity.subject.resolve for the Live import)
  • OpenVibe.Events (the SDK outbox relay, when EVENTS_URL and the OAuth client secret are set)
  • the payment providers whose secrets are configured (none in production today)
  • openvibe-contracts v0.419.0, openvibe-sdk v0.161.0 (service tokens, per-person limits, openvibe-sdk/db, openvibe-sdk/service, openvibe-sdk/vibes), openvibe-shared v4.235.0, pinned by release tarball
  • PostgreSQL 18 and Valkey 9 (OpenVibe.Host roles/data/, ADR-035): every read and write is async through
    openvibe-sdk/db; Valkey holds the per-person limit counters (optional: without VALKEY_URL they count in the process)

Capabilities

Implemented here (the service manifest's capabilities, audience openvibe.billing; routes under
API): billing.intent.create, billing.transfer.create, billing.balance.read,
billing.cashout.request, billing.cashout.manage, billing.subscription.manage,
billing.entitlement.check, billing.ledger.admin, billing.usage.record (usage readings; released in
Contracts v0.85.0), and the public billing.vibes.charge and billing.vibes.read for developer apps (D12).
Callers today are Live (after the cutover),
Tips (billing.intent.create, billing.transfer.create), VIP (billing.intent.create,
billing.subscription.manage, billing.entitlement.check) and a developer app with its own app token for Vibes Pay.

Called elsewhere, as the service principal billing: events.event.publish (Events) and
identity.subject.resolve (Network, the Live import).

Run it

fnm exec --using=22.22.1 npm install
cp .env.example .env          # set OV_OAUTH_CLIENT_SECRET, POWERCHAT_WEBHOOK_SECRET, …
npm run dev                   # http://localhost:4600
npm test                      # 13 test files: stub Network/Events/providers, temp DBs, random ports
npm run reconcile             # reconciliation report (exit 1 on failure)
npm run freeze -- on "reason" # economy freeze from the host (status | on "<reason>" | off)
LIVE_DATABASE_URL=… node scripts/import-live.js [--dry-run] [--json]   # reads Live's PostgreSQL (or --live-env <Live's env file>)
LIVE_DATABASE_URL=… node scripts/import-live.js --accounts-only   # only PowerChat account → creator mappings

Production: /opt/openvibe.billing, env /etc/openvibe/billing.env, unit
[deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service), vhost [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf).

Design

  • Journal (server/ledger.js, server/db.js): accounts(kind, owner_subject, currency),
    transactions(id txn_<ULID>, type, status, idempotency_key UNIQUE, reverses_txn, test, actor, metadata, …),
    ledger_entries(txn_id, account_id, amount) with signed integer minor units. Every transaction sums
    to zero per currency (vibes-bits, usd-cents); triggers refuse UPDATE/DELETE on entries and
    transactions; corrections are reversing transactions. account_balances is a cache updated in the
    same transaction and verified by reconciliation. Funds checks run inside the transaction that moves
    the money, and every money transaction is SERIALIZABLE (ledger.js money(), retried on a
    serialization failure), so two movements that each passed a check cannot both commit.
  • Vibes Pay (server/ops/vibes.js, server/ops/vibes-webhooks.js, [docs/vibes-pay.md](docs/vibes-pay.md)):
    an app creates a charge (whole Vibes, idempotent on its reference), the person pays it on the pay sheet, and the
    Vibes move once from user_credit to project_payable:project:<prj_id>; a project owner cashes out through the
    existing cashout path. Each paid/refunded charge publishes billing.vibes.charge.paid@1/.refunded@1 to Events
    and queues a signed HMAC-SHA256 webhook in vibes_webhook_outbox, retried with backoff. Sandbox apps use
    vibes_credit_sandbox/project_payable_sandbox and test transactions only. The owner's billing.vibes limits
    and the economy freeze are checked inside the money transaction.
  • Two currencies. Money arrives in cents and becomes bits through the fx_conversion account pair
    at the recorded value rate (bits_per_usd, 100 today); reconciliation checks that each transaction's
    cents side mirrors its bits side. Anything a payment exceeds the bits' value by is platform_revenue.
  • Rates are configuration (BILLING_*, defaults = Live's): price tiers, value rate, sub price,
    70 % streamer share, 10 % site-route fee, minimum cashout, escrow days. Each transaction records the
    rates it used.
  • Receipts: webhooks are verified, stored in provider_events (UNIQUE provider + event id) before
    anything else, then processed with the effect and the "processed" mark in one transaction, with the receipt's row locked.
    Every settling transaction has a unique receipt_ref (<provider>:<payment id>), so a payment
    settles once whatever event, retry or API call carries it.
  • Freeze (ADR-012 rule 11): settings.freeze; while on, every mutating endpoint answers
    503 billing.frozen, reads work, webhooks are stored (202) and processed in arrival order after
    unfreeze. Jobs pause. The owner's money_writes_frozen switch keeps its own audited path
    (/api/admin/money); it is not configuration and no rollback may move it.
  • Owner settings (D11) ([server/config-store.js](server/config-store.js)): the namespace
    billing.credits holds the Balance switch, the sandbox grants, the top-up switch, its amounts and
    fee, and the hold lease, through openvibe-shared/config. Each key keeps the environment variable it
    had as an x-env pin: while that variable is set its value wins and a write is 409 config.pinned_in_env; otherwise the owner sets it from Network's AI tab and it applies live. The
    routes are /internal/config (capability billing.config.manage, Network's service token), with the
    owner's usr_ id in X-OV-Subject and X-OV-Fresh: 1 for the danger keys (credits, topup,
    topup_amounts_cents, topup_fee_bps). Every activation writes a staff_audit row with the keys,
    actor and revision, never a value. With OV_CONFIG_KEKS set the store seals secrets and reseals old
    plaintext rows at boot; this namespace has no secret-class keys, so it reports sealed but exposes no
    value. billing.vibes holds the Vibes Pay switch and limit, the daily app spend per person and the
    from_balance switch (vibes_pay and from_balance are danger keys); GET /api/me/balance, the transfer
    and the Vibes purchase are in [docs/balance.md](docs/balance.md); GET /api/me/activity is the person's own
    movement list for Network's wallet.
  • 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,
    billing.subscription.canceled, billing.cashout.requested|paid|denied, billing.staff.action,
    billing.receipt.external, written in the same transaction as the effect (events.event-envelope@1,
    source billing, actor service:billing). Relayed to OpenVibe.Events when EVENTS_URL and the OAuth client secret are set; otherwise rows wait.
  • Authority (BILLING_AUTHORITY, live by default = shadow; billing after the cutover): decides
    whether EXTERNAL tips are announced (see below). Anything else stops Billing at boot.
  • EXTERNAL receipts ([server/ops/external.js](server/ops/external.js)): a PowerChat tip paid to the
    streamer's own account is stored once per payment in external_receipts (never in the journal) and,
    under billing, announced once as billing.receipt.external — subject provider_receipt powerchat:<payment id>, payload streamer (SubjectRef), amount_cents, currency, value_bits,
    donor_name (null when anonymous), anonymous, message, provider, provider_event_id (the
    payment's id), delivery_id, receiving_account, app_ref/app_purpose (goal pick), occurred_at,
    test. OpenVibe.Tips turns it into the chat line, alert and goal progress Live's webhook used to
    produce. The streamer is whoever the receiving account is mapped to in provider_accounts (the importer
    copies Live's powerchat_connections; an operator can add one); an unmapped account's tip is held for
    review and announced when reprocessed after mapping. Under live it is recorded, not announced —
    Live's own webhook announces it, so a tip is never celebrated twice.

Rates (usage rating)

Billing turns stored usage readings (POST /api/v1/usage) into charges in a background sweep
([server/ops/rating.js](server/ops/rating.js)), never inside POST /api/v1/usage: a producer never drives a
synchronous debit. A reading without vibes_charged in GET /api/v1/usage is simply not rated yet.

  • Two gates, both closed by default. The sweep runs only when BILLING_RATING_INTERVAL_MS is set (default
    0 = off; BILLING_RATING_BATCH readings per pass, 500), and nothing is charged without a rate card.
    Rate cards are loaded only by review: OV_RATE_CARDS (a JSON array of platform.rate-card@1, inline or
    a file path) + node scripts/load-rate-cards.js [--dry-run] (npm run load-rate-cards). Every card is
    validated against the contract first and one bad card loads nothing; no request and no AI writes a card.
  • Card match. A reading's provider and resource are the card's provider and metric; the card's
    region (none = every region) and effective dates (effective_from ≤ the reading's UTC day <
    effective_until) must fit. Its unit must be the one the metric names (the metric itself or its leading
    word: GiB for gib-delivered, requests for request); no unit is converted.
  • The charge. quantity / unit_size × unit_price_usd × bits_per_usd, computed exactly and rounded up once
    to whole vibes-bits. The subject's promo allowance (promo_credit, step 5) covers it first; the rest is
    one usage transaction per reading, user_credit:<subject> → platform_revenue (vibes-bits), idempotency
    key usage:<reading idempotency_key>. The allowance draw is a promo adjustment (promo_credit →
    promo_reserve) in the same database transaction, so promo bits never reach platform_revenue or creator
    earnings. The reading records vibes_charged (bits), promo_bits, free_allowance_used (in the reading's
    own unit), rated_at and txn_id (the usage transaction, or the promo draw when nothing was charged). A
    card's own free_allowance (the provider's free tier) is stored with the card, not applied per person.
  • Hard budgets (usage_budgets, POST /api/v1/admin/budgets): per subject + service ('*' = every
    service) + window (day, month, none). A charge that would take an open budget's spent_bits past
    budget_bits is refused whole (billing.budget_exceeded): nothing is charged, no allowance drawn, the
    reading stays unrated. spent_bits grows in the same transaction as the ledger post. A window that ends opens
    the next with the same budget. With no budget row only the spendable balance limits the charge
    (billing.insufficient_funds). budget_bits: 0 stops all further charges.
  • Unrated, never partly charged. The funds check, the budget check, the allowance draw, the post and the
    "rated" mark are one serializable transaction that claims the reading's row (FOR UPDATE SKIP LOCKED), so
    two sweeps, or a sweep and POST /api/v1/admin/rate, never charge or draw twice. rating_error says why a
    reading waits: billing.insufficient_funds / billing.budget_exceeded are tried again after
    BILLING_RATING_RETRY_MS (1 h); billing.no_rate_card / billing.unit_mismatch wait until a matching card
    is loaded (the loader makes them due again); billing.no_subject (no user:usr_… subject, e.g. an app's
    job) is never charged. Nothing is rated while the economy is frozen.

API

All /api/v1 calls need a service token from OpenVibe.Network (audience openvibe.billing) and are
made on loopback (http://127.0.0.1:4600); nginx refuses /api/v1 on the public host. Every
POST needs an Idempotency-Key (8–200 of A-Za-z0-9._:-); a replay returns the original response
(Idempotent-Replayed: true), the same key with another body is 422 idempotency.key_reused, refusals
are not stored. Errors are RFC 9457 problem+json. People are SubjectRefs { "type": "user", "id": "usr_…" }
(path parameters take the bare id); amounts are integer bits or cents.

One public route takes a person's token instead: GET https://billing.openvibe.network/api/me/vibes with the
person's Network user JWT (Authorization: Bearer) answers their own { credit, payable, currency, bits_per_usd }
for the shared navbar on every OpenVibe site (server/api/me.js). It is CORS-open to the exact https origins the
Contracts service manifests name (never a wildcard), never cached, limited to 30 a minute per person, and answers
409 billing.not_authoritative while OpenVibe.Live is still the ledger.

| Method & path | Capability | Does |
|---|---|---|
| GET /api/health, /api/ready | — | liveness / readiness (DB + Network key) |
| GET /api/v1/rates | — | prices, packages, sub price/share/fee, cashout rules, enabled providers |
| POST /api/v1/intents | billing.intent.create | start a checkout {provider, kind: purchase\|subscription, subject, bits \| streamer, 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 |
| GET /api/v1/intents/:id, POST …/:id/capture | billing.intent.create | read; capture an approved PayPal order |
| POST /api/v1/purchases/settle | billing.ledger.admin | settle a verified provider receipt {provider, provider_ref, amount_cents, subject \| intent_id, bits?} |
| POST /api/v1/transfers | billing.transfer.create | tip/donation {from, to, amount, kind?, target? (EntityRef), message?}; self-dealing refused by subject |
| POST /api/v1/transfers/:id/refund | billing.transfer.create | give a credit-funded transfer back {amount?, reason?} (refused if the recipient no longer holds it) |
| POST /api/v1/recycle | billing.cashout.request | creator payable → own spendable credit {subject, amount} |
| POST /api/v1/cashouts | billing.cashout.request | {subject, amount, payout_method: {type, address}} → escrow |
| GET /api/v1/cashouts[?status&subject], GET …/:id | billing.cashout.manage (…request for one) | list / read |
| POST /api/v1/cashouts/:id/approve | billing.cashout.manage | {payout_reference (required), payout_provider?}; refused before the escrow ends |
| POST /api/v1/cashouts/:id/deny | billing.cashout.manage | reversal back to payable {reason?} |
| POST /api/v1/subscriptions | billing.subscription.manage | start/renew a period {subscriber, streamer, source: credit, 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 |
| 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) |
| 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 |
| 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 |
| POST /api/v1/subscriptions/:id/cancel | billing.subscription.manage | cancel at period end; Stripe is cancelled at Stripe first |
| 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) |
| GET /api/v1/subscriptions[?subscriber&streamer&status], GET …/:id | billing.entitlement.check | read |
| GET /api/v1/entitlements/:subject[?streamer=] | billing.entitlement.check | {active, expires_at, grace_until, subscription} — needs nothing but Billing; grace_until is set only while the subscription is past_due (never active) |
| GET /api/v1/balances/:subject | billing.balance.read | {credit, payable, pending_payouts, promo_bits, payable_value_cents} (promo_bits: unused free allowance, never transferable) |
| GET /api/v1/transactions?subject=&cursor=&limit=, GET …/:id | billing.balance.read | history (cursor paging), one transaction with reversed_by |
| 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 |
| GET /api/v1/usage?project=&subject=&service=&from=&to=&limit=&cursor= | billing.ledger.admin | readings newest first, from ≤ at < to, cursor paging (limit ≤ 500) |
| GET\|POST /api/v1/admin/freeze | billing.ledger.admin | read / set {on, reason?}; unfreeze drains queued webhooks |
| GET /api/v1/admin/reconcile | billing.ledger.admin | run + store a reconciliation |
| 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 |
| 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) |
| 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) |
| 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 |
| 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) |
| 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) |
| GET /api/v1/admin/provider-events[?pending=1], POST …/:id/reprocess | billing.ledger.admin | receipts; retry an unprocessed/rejected one |
| POST /api/v1/admin/sweep, GET /api/v1/admin/import-holds | billing.ledger.admin | renewal sweep now; unmapped import users |
| POST /webhooks/<provider> | provider signature | powerchat, stripe, paypal, ccbill (GET too), nowpayments |
| 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 |
| 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 |

The existing capabilities and service manifest are released in openvibe-contracts (v0.8.0;
billing.staff.action since v0.17.0). billing.usage.record is released in Contracts v0.85.0: input is
platform.usage-sample@1; output is billing.usage-record-result@1 =
{ record: { id, idempotency_key, project|null, subject|null, service, at, received_at, principal, reading } },
201 when stored and 200 with Idempotent-Replayed: true on a replay; GET /api/v1/usage needs
billing.ledger.admin and returns { records, next_cursor }. Network grants billing.usage.record
(audience openvibe.billing) to each producer service in server/identity/principals.js, and no service
holds it yet: the first producer gets the grant in the same change that wires its calls. Grants are matched
with contracts' own capabilities.grants() (exact id or a .* family such as billing.*).

Per-person limits

The money-creating routes also limit how often one person can use them: server/api/actor-limits.js,
openvibe-sdk/limits, roadmap WS-R task 4. Every caller is a first-party service acting for many people,
so nothing is counted per service: a request counts against the person its body names (the buyer, the
payer, the creator, the subscriber), whichever service sends it; a refund counts against the payer of the
transfer it gives back. The limit runs after the capability and freeze checks (a request refused while
frozen is not counted) and before the idempotency store and the ledger, so a refusal stores nothing and
the same Idempotency-Key works after Retry-After. Past a limit: 429 problem+json rate_limited, one log line and
billing_rate_limited_total{limit,window}. BILLING_LIMITS=off turns them all off (a rollback lever).

| Route | Per person, a minute / an hour |
|---|---|
| POST /api/v1/intents (checkout) | 5 / 30 |
| POST /api/v1/transfers (per payer) | 60 / 1200 |
| POST /api/v1/transfers/:id/refund (per payer of the transfer) | 20 / 200 |
| POST /api/v1/cashouts (payout request) | 3 / 10 |
| POST /api/v1/recycle | 10 / 60 |
| POST /api/v1/subscriptions (per subscriber) | 10 / 60 |
| GET /api/v1/transactions?subject= (history) | BILLING_LIMITS_MINUTE / BILLING_LIMITS_HOUR (120 / 3000) |

Never limited: the provider webhooks (/webhooks/*), capturing an approved order, cashout approve and
deny, subscription cancel, the freeze, every admin route, the staff console, balance and entitlement
reads, /api/health, /api/ready, /release.json and /metrics. test/actor-limits.test.js.

Staff console

Server-rendered pages at the root of https://billing.openvibe.network for the people who decide
payouts — the replacement for Live's cashout admin, which answers 409 once Live runs with
BILLING_AUTHORITY=billing ([docs/live-cutover.md](docs/live-cutover.md)). No JavaScript, no external
resources, noindex, Cache-Control: no-store, a strict CSP (default-src 'none', form-action 'self',
frame-ancestors 'none'). Code: [server/console/](server/console/); tests: test/console.test.js.

Sign-in: OpenVibe.Network SSO, authorization code with PKCE (S256), as OAuth client billing
(redirect https://billing.openvibe.network/auth/callback, which the Network must list for that
client). The code is exchanged server to server on OV_NETWORK_INTERNAL_URL; the access token is
verified offline with the Network key Billing already loads (issuer, audience openvibe.network,
expiry). Billing keeps none of the Network's tokens — the refresh token it is handed is revoked at once.

Who: a person whose Network token says role: "admin" and whose subject (subject_id, a
usr_…) is listed in BILLING_STAFF_SUBJECTS. Everyone else gets 403 and no session; the refusal is
recorded in the audit log. The list is re-checked on every request: removing someone ends their session
on their next click.

Session: cookie __Host-ovb_staff — random 256-bit id (the database keeps only its SHA-256),
host-only, Path=/, HttpOnly, Secure, SameSite=Strict, absolute lifetime BILLING_SESSION_TTL_MIN
(60 min). The OAuth state + PKCE verifier travel in a 10-minute ovb_flow cookie (Path=/auth,
SameSite=Lax for the return from openvibe.network) signed with BILLING_SESSION_SECRET. Every form
carries the session's CSRF token (constant-time compare) and cross-site Origin / Sec-Fetch-Site
POSTs are refused. Sign-out is a POST.

What staff may do is expressed in the API's own capability ids: a staff session is the principal
{ sub: <usr_…>, cap: [billing.cashout.manage, billing.ledger.admin] } and every page names the one
capability it needs. Every action calls the same functions the API does — ops/cashouts.approve|deny,
ops/admin.setFreeze (+ processing the held webhooks on unfreeze), reconcile(),
providers.reprocess() — behind the same freeze guard; there is no second implementation.

| Page | Needs | Does |
|---|---|---|
| / | staff | freeze state, last reconciliation, outstanding credit / payable / pending payouts (test transactions excluded), queue counts |
| /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) |
| /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 |
| /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 |
| /import-holds | billing.ledger.admin | unmapped Live users from the importer |
| /reconciliation, /reconciliation/:id | billing.ledger.admin | run + history; a run shows checks, offender counts, warning counts and totals |
| /freeze | billing.ledger.admin | freeze / unfreeze with a mandatory reason |
| /audit | billing.ledger.admin | the staff audit log |

Audit: every staff action — sign-in, sign-out, approve, deny, freeze, unfreeze, reconciliation run,
reprocess — and every refused attempt (non-staff sign-in, CSRF failure, a refused approval) is one row
in the append-only staff_audit table (actor subject and username, action, target, reason, outcome,
a small detail such as the payout reference or refusal code, request id, and an HMAC of the client IP
keyed by BILLING_SESSION_SECRET). Done actions are also written to the outbox as
billing.staff.action (visibility internal, actor = the staff subject), in the same
transaction as their effect. Adjustments are not in the console; they stay on the API
(POST /api/v1/admin/adjustments).

Providers

| Adapter | Enabled when | Settles | Reverses |
|---|---|---|---|
| PowerChat | POWERCHAT_WEBHOOK_SECRET | donation.completed with pcorder: (purchase), pcsub: (site → share; direct → EXTERNAL entitlement), pcdon: (site-routed tip → payable); 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) |
| Stripe | STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET | checkout.session.completed (payment), every invoice.paid | charge.refunded (cumulative), charge.dispute.funds_withdrawn |
| PayPal | client id + secret + PAYPAL_WEBHOOK_ID | PAYMENT.CAPTURE.COMPLETED, capture after return | PAYMENT.CAPTURE.REFUNDED, …REVERSED |
| CCBill | CCBILL_WEBHOOK_SECRET | NewSaleSuccess (reported price required) | Refund, Void, Chargeback |
| NOWPayments | NOWPAYMENTS_IPN_SECRET | finished/confirmed/sending | refunded |

Only PowerChat is expected to be enabled in production. A reversal of a purchase claws back the
buyer's remaining credit; credit already given to someone stays with them — their payable is never
touched — and the unrecovered value goes to chargeback_loss, flagged review: required.
Reversing a subscription payment revokes the periods it granted. A period paid from credit has no receipt:
staff refund it whole (POST /api/v1/subscriptions/:id/refund, one refund transaction reversing the period's
payment): the subscriber gets the credit back (never the promo allowance, which pays no period), platform
revenue gives back its part, and the creator share comes back out of creator_payable as far as it is still
there — a share already recycled or cashed out goes to chargeback_loss, flagged review: required.

Operations

  • Reconciliation runs every hour on its own (BILLING_RECONCILE_INTERVAL_MS, a first run a
    minute after boot, also while frozen — it only reads) and on demand (npm run reconcile,
    GET /api/v1/admin/reconcile, the console's Reconciliation page, after every import). It checks: journal
    zero-sum per currency, per-transaction balance, cached balances = entry sums, fx mirror, every settled
    provider event has exactly one transaction, paid cashouts have payout references, payouts_pending =
    requested cashouts — and the ledger against the provider receipts: every transaction settled from a
    receipt names its provider and moved exactly the receipt's cents through that provider's clearing
    account (receipts.ledger), EXTERNAL receipts are never in the journal and each announced one has one
    outbox event (receipts.external), and no receipt waits unprocessed longer than
    BILLING_RECONCILE_STALE_RECEIPT_MIN (60) while the economy is open (receipts.stale) — and every paid
    period against its charge: a granted period rests on an unreversed subscription transaction (periods imported
    from Live are counted, not failed) and a period revoked for a refund or chargeback has its payment's reversal
    (subscriptions.period_charged). It also lists
    negative balances, unprocessed/rejected events, items for review and import holds; totals exclude test
    transactions. Every run is stored in reconciliation_runs with its trigger (scheduled, api,
    console, script, import); passing scheduled runs older than BILLING_RECONCILE_KEEP_DAYS (30) are
    pruned, failed ones are kept. Results: the console, GET /api/v1/admin/reconciliations, and /metrics
    (billing_reconciliation_ok, …_failed_checks, …_last_run_timestamp_seconds); a failed scheduled run
    is also logged.
  • Renewals run in the hourly subscription sweep (BILLING_SWEEP_INTERVAL_MS, not while frozen; on demand
    POST /api/v1/admin/sweep): a credit subscription whose period ended is charged once from the subscriber's
    credit (never the promo allowance) under renew:<sub>:<period end> — …:<k> after k charges under that
    period's earlier keys were refunded or charged back — so a retried, replayed or concurrent sweep charges and
    credits the creator share once. A renewal the credit cannot pay leaves the subscription past_due
    for BILLING_RENEWAL_GRACE_DAYS days past the period end (default 3; set 0 to end it at once
    with renewal_insufficient_credit): no access meanwhile (event reason renewal_failed), every
    later sweep retries the same key (a top-up is picked up; a cancel ends it), and past grace_until it
    expires (grace_ended). Turning the grace on (or off): [docs/renewal-grace-cutover.md](docs/renewal-grace-cutover.md).
  • Freeze before any risky change: the console's Freeze page, POST /api/v1/admin/freeze {"on": true, "reason": "…"}, or on the
    host node scripts/freeze.js on "<reason>" (same switch; after off the running service processes the
    held webhooks on its next retry tick).
  • Review queue: chargebacks on donated credit and unattributed site tips appear under
    warnings and on the console's Receipts page; resolve them with an adjustment (relates_to = the transaction).
  • Payouts are decided in the staff console (or with billing.cashout.manage on the API): pay at the
    provider, then approve with its payout reference once the escrow has ended.
  • Metrics: curl -s http://127.0.0.1:4600/metrics on the host.
  • Request telemetry (plan T1 step 10): each HTTP request is aggregated by route template, method and
    status class, never by raw URL; /shared files, static assets, health, readiness and /metrics are not
    counted. Once a minute one http.request sample per key, plus the active-requests, p95 latency and
    event-loop-lag gauges, is written to Billing's own telemetry_samples table (openvibe-sdk 0.46 telemetry,
    the platform.telemetry-sample@1 shape); rows older than 14 days are deleted daily. server/app.js mounts
    openvibe-sdk's telemetryMiddleware (a no-op until init); server/index.js calls telemetry.init() at
    production start, runs the 14-day retention and flushes on stop; the table is migrations/0007_telemetry.sql.
  • Backups: pg_dump ov_billing > billing-$(date +%F).sql (the host's data role, ADR-035).
  • Mapping of Live's tables to Billing's (including payouts, refunds and plans, which have no table
    of their own on Live): [docs/live-mapping.md](docs/live-mapping.md).
Account export and deletion (ADR-033)

Billing answers a person's account export and deletion since 2026-10-17 ([server/account-data.js](server/account-data.js),
openvibe-sdk/account-data): OpenVibe.Events delivers network.account.export_requested and network.account.deleted to
POST /internal/events on 127.0.0.1:4600 (signed with BILLING_EVENTS_SECRET; loopback only, and nginx 404s
/internal/), the two subscriptions are created at boot. The export holds the person's balances and every money record
about them: transactions sent and received, checkouts, cash-outs, subscriptions (as a member, and as a creator without
the members' identities), entitlements, tips on their own PowerChat, usage, promo allowances, tier prices and connected
payment accounts. A deletion keeps the money records (the ledger must still balance; payments are records the law
requires) and removes settings: tier prices, connected payment accounts and staff sessions. Each deletion is confirmed
once with its counts (account_data_events).

Acceptance

npm test runs every test/*.test.js against stub Network, Events and providers, temp databases and
random ports. What they prove: the journal balances per currency and refuses edits
(ledger.test.js); a receipt settles once whatever retries carry it, and reversals claw back only what
can be (webhooks.test.js, providers.test.js, stripe.test.js); subscriptions and entitlements
(subscriptions.test.js); a renewal is one charge per period, a failed one is retried on its key in the
grace and then ended, a promo allowance never pays it (renewal-grace.test.js); a credit-paid period is refunded
whole and once, the creator share clawed back or booked as loss, and every paid period has its charge record
(subscription-refund.test.js); EXTERNAL receipts are announced once and only under billing
(external.test.js); the import reconciles (import.test.js); freeze, reconciliation and review
(operations.test.js); the cutover sequence (cutover.test.js); the staff console and its staff map
(console.test.js, staff-map.test.js); the public policy (policy.test.js); the vhost keeps /api/v1
off the public host (nginx-vhost.test.js, security.test.js); per-person limits
(actor-limits.test.js); service auth and the outbox (auth-outbox.test.js).

Security

Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:

  • Auth. /api/v1 answers only on loopback (nginx denies it publicly) and needs a Network service
    token for audience openvibe.billing with the route's capability; every POST needs an
    Idempotency-Key. The staff console is Network SSO with PKCE, limited to BILLING_STAFF_SUBJECTS,
    and every staff action is audited (billing.staff.action).
  • Money integrity. The journal is append-only (triggers refuse UPDATE/DELETE), every transaction
    sums to zero per currency, funds checks run inside the transaction that moves money, and a receipt is
    stored before it is processed. The freeze stops every mutation; hourly reconciliation checks the
    ledger against the receipts.
  • Webhooks. Each provider's signature is verified before anything is read; a disabled provider
    answers 404. A buyer-editable reference on a tip to a creator's own account moves no money
    (test/security.test.js).
  • Secrets. Provider secrets (POWERCHAT_WEBHOOK_SECRET, STRIPE_*, PAYPAL_*, CCBILL_*,
    NOWPAYMENTS_*), OV_OAUTH_CLIENT_SECRET and BILLING_SESSION_SECRET live in
    /etc/openvibe/billing.env (0600), by name only. /metrics answers direct loopback callers only.
  • Egress. Billing calls Network, Events and the enabled providers' APIs only.

Deploy

Production deploys with sudo ovhost deploy billing on the host (strategy git-checkout: fetch,
fast-forward /opt/openvibe.billing, install on a lockfile change, restart, wait for /api/ready).
The unit is openvibe-billing.service on 127.0.0.1:4600, the env file /etc/openvibe/billing.env. The ledger is
ov_billing on the host's data role (sudo /opt/openvibe.host/roles/data/add-service.sh billing writes its settings);
the release applies migrations/ at boot as the owner (DATABASE_DIRECT_URL), and every read and write is async
through openvibe-sdk/db. nginx serves billing.openvibe.network from
[deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf) (console, /policy,
/webhooks/*, /api/health). Freeze the economy before any risky change (below).
Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer 2xx after the
restart; afterwards sudo ovhost rollback billing --to <sha>. Migrations only add tables and columns.
BILLING_LIMITS=off turns the per-person limits off without a deploy.

Cutover runbook (Live → Billing)

The exact production sequence, rollback, grants and the Live behaviour the switch cannot preserve are in
[docs/live-cutover.md](docs/live-cutover.md); the Live side (a BILLING_AUTHORITY=live|billing switch,
live by default, plus the money_writes_frozen freeze) was the patch
[docs/live-patch.diff](docs/live-patch.diff) and is deployed in OpenVibe.Live since c384787 with the
switch unset. Run on 2026-10-17 04:50–04:56 UTC (docs/live-cutover.md has the record): final import 0 new rows,
reconciliation OK before and after. Step 3 was done in Live's vhost rather than the PowerChat dashboard: openvibe.live's
/api/powerchat/webhook is proxied to Billing (OpenVibe.Live#172; the signature does not cover the URL). In short:

  1. Shadow import as often as needed (billing_live 'node scripts/import-live.js --dry-run', docs/live-cutover.md);
    map held users in the Network and explain every adjustment until reconciliation is clean.
  2. Freeze Live money writes, wait for in-flight PowerChat checkouts (an hour), back up and freeze Billing,
    set BILLING_AUTHORITY=billing in billing.env (EXTERNAL tips are then announced for Tips).
  3. Re-point the PowerChat webhook to https://billing.openvibe.network/webhooks/powerchat (Billing holds
    the deliveries while frozen), then run the final import (Live's money writes frozen); reconcile.
  4. Switch Live (BILLING_AUTHORITY=billing, deploy with --wait-idle), unfreeze Billing (held
    deliveries settle; ones Live already credited are rejected for review, never credited twice).
  5. Verify the reads, unfreeze Live, verify with a small real PowerChat purchase.

Import runs are safe after cutover too: an order Live credited is imported as settled-in-Live and never
settles again here, and opening balances only ever correct the imported part of an account, never what
Billing did itself.

Launch rule

billing.openvibe.network left OpenVibe.Sites on 2026-09-23: it serves the staff console, the billing policy and a
person's own Vibes (/api/me/vibes). Billing is not a public product of its own; it became the money authority with
the cutover above (2026-10-17). Plan §12.12, as it stood: owning runtime with health/readiness and /metrics ✔; canonical identity and
scoped service principals ✔ (principal billing); server-rendered public routes useful without
JavaScript (partly — the public billing policy at /policy; the other pages are the staff-only console); real persistence and end-to-end
workflows ✔ in tests, and in production since the cutover; capability/event registration against
OpenVibe.Contracts ✔ (v0.8.0); migration strategy ✔ (shadow import reconciles; restore drill passed
2026-09-23) with a security review, sitemap/robots still to do; acceptance tests ✔. Still due: a small real
PowerChat purchase settling end to end after the cutover.


Part of the OpenVibe network. Built in the open by OpenVibers.

<!-- versions:start -->

  • openvibe-contracts: v0.419.0
  • openvibe-sdk: v0.161.0
  • openvibe-shared: v4.235.0

<!-- versions:end -->