openvibers/openvibe.billing AGENTS.md
AGENTS.md
AGENTS.md — OpenVibe.Billing
Project overview
The isolated money ledger of the OpenVibe network: one append-only, balanced, double-entry journal
for every MONEY and CREDIT flow, provider receipts, subscriptions and entitlements, refunds,
cashouts, usage rating, promo allowances and Vibes Pay. Node.js/Express, PostgreSQL (ov_billing),
CommonJS, no build step.
- Service id
billing, port4600, staff consolehttps://billing.openvibe.network; the service API
/api/v1is reachable only on loopback (127.0.0.1:4600), never through the public vhost. - Authority since 2026-10-17:
BILLING_AUTHORITY=billingin Billing and Live, PowerChat deliveries
settle here, and Live's old money tables are read-only. Every site's account menu reads a person's
Vibes fromGET /api/me/vibes. - Economic classification is ADR-012.
What it owns
- The journal:
accounts,transactions,ledger_entries,account_balancesinov_billing
([migrations/](migrations/)); append-only — triggers refuse UPDATE/DELETE, and every
transaction sums to zero per currency. - Three ledgers that never mix: bought Vibes (
user_credit), creator money (creator_payable,
payouts_pending,platform_revenue,refunds,chargeback_loss), promo allowance
(promo_creditagainst a dedicatedpromo_reserve, neverplatform_revenue). Credit becomes
money only by transfer; money leaves only by cashout; promo has no such path. - Provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments) — they land here and nowhere
else; a receipt is stored before it is processed. - Subscriptions and entitlements, intents, cashouts, usage readings (
usage_records), rating
(server/ops/rating.js), promo windows, the freeze, reconciliation/import runs and the staff audit. - Vibes Pay (D12): app charges, the project's
project_payablewallet, its signed webhooks and
sandbox Vibes ([docs/vibes-pay.md](docs/vibes-pay.md)). - The
billing.*events and the public billing policy page.
Not owned: loyalty points (OpenCoins, channel points), cosmetics (Inventory), game state, legacy
balances, or any product UI.
Who calls it, and whom it calls
- Callers: every product that moves money or grants an entitlement — Live (checkouts, donations,
subscriptions, cashouts), Tips, VIP, Inventory, Exchange and the account menus of every site. They
use a Network service token audienceopenvibe.billingwith the route's capability. - Depends on: Network (JWKS, service tokens, SSO+PKCE for the console,
identity.subject.resolvefor
the Live import); Events (SDK outbox, thebilling.*events); the configured payment providers;
PostgreSQL 18 and Valkey 9 (per-person limit counters; withoutVALKEY_URLthey count in process).
Repository map
- Entry: [server/index.js](server/index.js) — config, app, background jobs (provider event retry,
hourly renewal sweep, hourly reconciliation, outbox relay; rating only when its interval is set). - App: [server/app.js](server/app.js) —
/webhooks,/internal/credits,/api/v1/me,/api/v1,
/api/me/vibes, the staff console and machine endpoints. - Config: [server/config.js](server/config.js), [server/config-store.js](server/config-store.js)
(owner settings, D11), [server/internal-config.js](server/internal-config.js). - Database: [server/db.js](server/db.js); schema in [migrations/](migrations/) (
0001…0018). - Ledger: [server/ledger.js](server/ledger.js), [server/money/](server/money/) (reasons),
[server/ops/](server/ops/) —transfers.js,cashouts.js,subscriptions.js,promo.js,
rating.js,usage.js,intents.js,wallet.js,admin.js. - API: [server/api/](server/api/) —
v1.js,internal.js,me.js,webhooks.js,
subject-assertion.js,idempotency.js,auth.js,actor-limits.js. - Providers: [server/providers/](server/providers/) —
powerchat.js,stripe.js,paypal.js,
nowpayments.js,index.js. - Console: [server/console/](server/console/) — server-rendered, Network SSO, staff only.
- Scripts: [scripts/reconcile.js](scripts/reconcile.js),
freeze.js,import-live.js,
load-rate-cards.js. - Deploy: [deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service),
[deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf).
Commands
npm ci
cp .env.example .env
npm run dev # http://localhost:4600
npm test # test/run.js, PGlite, stub Network/Events/providers
npm run test:pg # same suite on PostgreSQL
npm run reconcile # reconciliation report; exit 1 on failure
npm run freeze -- status|on "<reason>"|off
npm run import-live # LIVE_DATABASE_URL=… (see README); --dry-run, --json
ov test # the harness's sharded run of what the change reaches
Tests need no network, provider key or running Network: in-process stubs, temp databases, random
ports. npm test -- import webhooks filters; -- --strict makes a skip fail.
Database and migrations
PostgreSQL database ov_billing on the host's data role (ADR-035); development/tests use PGlite.
Async reads/writes through openvibe-sdk/db; an INSERT that needs its id uses RETURNING id. Schema
is migrations/NNNN_*.sql, applied at boot; never edit a migration that has run (its checksum is
recorded) and never create a table at runtime. Money integrity is enforced in SQL: the append-only
triggers, the zero-sum rule per currency and the balance checks. Do not weaken them for convenience.
Auth, idempotency and the freeze
/api/v1is loopback-only (nginx denies it publicly), needs a Network service token audience
openvibe.billingwith the route's capability, and every POST needs anIdempotency-Key.
A replay with the same money fields returns the first row; different money fields are
409 billing.usage_key_reused/ the matching reason.- The staff console is Network SSO with PKCE, limited to
BILLING_STAFF_SUBJECTS; every staff action
is audited asbilling.staff.action. - The freeze (
money_writes_frozen/npm run freeze) stops every mutation: writes answer
503 billing.frozen, reads still work, provider webhooks are stored (202) and processed in arrival
order after unfreeze, and jobs pause. Rating and renewals never run while frozen.
Frontend loading model
The only UI is the staff console, server-rendered HTML (no SPA, no build step, no bundler); there
is no public/ app. /shared/* is served from the pinned openvibe-shared package where used.
Provider pages carry no product UI; a person's balance is read by each site from /api/me/vibes.
Deploy
sudo ovhost deploy billing (strategy git-checkout): fetch, fast-forward /opt/openvibe.billing,
install on a lockfile change, restart, wait for /api/ready, roll back on failure. Unit
openvibe-billing.service, env /etc/openvibe/billing.env (0600), vhost
[deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf). The release
migrates ov_billing at boot; sudo ovhost rollback billing --to <sha>. Cutover, freeze and import
runbooks are in the README and [docs/](docs/); follow them, do not improvise a money migration.
Tests
npm test runs each test/*.test.js in its own process through openvibe-shared/test-runner
(60 s/file), with stub Network/Events/providers. The suite pins the ledger invariants, the three
separate ledgers and promo isolation, promo grant/consume/lapse, idempotency, webhook storage and
ordering, the freeze, renewals/grace, reconciliation, capabilities, the public words and the
nginx/vhost rules. test/public-words.test.js and test/nginx-vhost.test.js pin files, so a copy
change may require the matching test update. ov test runs the same.
Conventions
- CommonJS, 4-space indent, single quotes, semicolons; factories with injectable
now,fetchImpl,
log,keys,identity,adaptersandlimitsNowso tests are deterministic. - Amounts are integer vibes-bits; never use floats for money. Reasons are enum values from
openvibe-contractsmoney reasons, not free text. - Errors are RFC 9457 problem+json with stable codes (
billing.insufficient_funds,
billing.promo_isolated,billing.frozen, …). - Secrets (provider keys, webhook secrets, OAuth) come from the environment and are never returned,
logged or written to a file.
Pitfalls
- Never write money outside the ledger. A product must call an operation, not
UPDATEa balance. - Promo is not credit. A transfer/cashout from
promo_creditis refused; an admin adjustment
mixing promo with another kind isbilling.promo_isolated; reconciliation checks it. - Every POST needs an Idempotency-Key; without it the route refuses, and a reused key with
different money fields is a 409, not a second charge. - Webhooks are stored before processing; never process inline and lose the receipt on a crash.
- The freeze is global. New mutations must check it; the relay and reconciliation are the allowed
exceptions. /api/v1is not public. Do not add it to the vhost; only the console,/webhooks/*and
/api/healthare exposed.- Imports read Live's data, never write it.
scripts/import-live.jsis the only bridge and needs
LIVE_DATABASE_URL. - Tests pin copy and nginx. Update the test with an intended change; never skip the assertion.
Documentation
- [README.md](README.md) — the journal, routes, providers, operations and deploy runbook.
- [docs/economic-inventory.md](docs/economic-inventory.md) — every value flow and its class.
- [docs/wallet.md](docs/wallet.md), [docs/balance.md](docs/balance.md), [docs/vibes-pay.md](docs/vibes-pay.md).
- [docs/live-cutover.md](docs/live-cutover.md), [docs/cutover-usage-rating.md](docs/cutover-usage-rating.md),
[docs/renewal-grace-cutover.md](docs/renewal-grace-cutover.md), [docs/live-mapping.md](docs/live-mapping.md). - [docs/sdk-outbox.md](docs/sdk-outbox.md) — the event outbox.