openvibers/openvibe.billing AGENTS.md
AGENTS.md
| 1 | # AGENTS.md — OpenVibe.Billing |
| 2 | |
| 3 | ## Project overview |
| 4 | |
| 5 | The isolated money ledger of the OpenVibe network: one append-only, balanced, double-entry journal |
| 6 | for every MONEY and CREDIT flow, provider receipts, subscriptions and entitlements, refunds, |
| 7 | cashouts, usage rating, promo allowances and Vibes Pay. Node.js/Express, PostgreSQL (`ov_billing`), |
| 8 | CommonJS, **no build step**. |
| 9 | |
| 10 | - Service id `billing`, port `4600`, staff console `https://billing.openvibe.network`; the service API |
| 11 | `/api/v1` is reachable **only on loopback** (`127.0.0.1:4600`), never through the public vhost. |
| 12 | - Authority since 2026-10-17: `BILLING_AUTHORITY=billing` in Billing and Live, PowerChat deliveries |
| 13 | settle here, and Live's old money tables are read-only. Every site's account menu reads a person's |
| 14 | Vibes from `GET /api/me/vibes`. |
| 15 | - Economic classification is [ADR-012](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-012-economic-classification.md). |
| 16 | |
| 17 | ## What it owns |
| 18 | |
| 19 | - The journal: `accounts`, `transactions`, `ledger_entries`, `account_balances` in `ov_billing` |
| 20 | ([migrations/](migrations/)); **append-only** — triggers refuse UPDATE/DELETE, and every |
| 21 | transaction sums to zero per currency. |
| 22 | - Three ledgers that never mix: bought Vibes (`user_credit`), creator money (`creator_payable`, |
| 23 | `payouts_pending`, `platform_revenue`, `refunds`, `chargeback_loss`), promo allowance |
| 24 | (`promo_credit` against a dedicated `promo_reserve`, never `platform_revenue`). Credit becomes |
| 25 | money only by transfer; money leaves only by cashout; promo has no such path. |
| 26 | - Provider webhooks (PowerChat, Stripe, PayPal, CCBill, NOWPayments) — they land here and nowhere |
| 27 | else; a receipt is stored before it is processed. |
| 28 | - Subscriptions and entitlements, intents, cashouts, usage readings (`usage_records`), rating |
| 29 | (`server/ops/rating.js`), promo windows, the freeze, reconciliation/import runs and the staff audit. |
| 30 | - Vibes Pay (D12): app charges, the project's `project_payable` wallet, its signed webhooks and |
| 31 | sandbox Vibes ([docs/vibes-pay.md](docs/vibes-pay.md)). |
| 32 | - The `billing.*` events and the public billing policy page. |
| 33 | |
| 34 | Not owned: loyalty points (OpenCoins, channel points), cosmetics (Inventory), game state, legacy |
| 35 | balances, or any product UI. |
| 36 | |
| 37 | ## Who calls it, and whom it calls |
| 38 | |
| 39 | - Callers: every product that moves money or grants an entitlement — Live (checkouts, donations, |
| 40 | subscriptions, cashouts), Tips, VIP, Inventory, Exchange and the account menus of every site. They |
| 41 | use a Network **service token** audience `openvibe.billing` with the route's capability. |
| 42 | - Depends on: Network (JWKS, service tokens, SSO+PKCE for the console, `identity.subject.resolve` for |
| 43 | the Live import); Events (SDK outbox, the `billing.*` events); the configured payment providers; |
| 44 | PostgreSQL 18 and Valkey 9 (per-person limit counters; without `VALKEY_URL` they count in process). |
| 45 | |
| 46 | ## Repository map |
| 47 | |
| 48 | - **Entry:** [server/index.js](server/index.js) — config, app, background jobs (provider event retry, |
| 49 | hourly renewal sweep, hourly reconciliation, outbox relay; rating only when its interval is set). |
| 50 | - **App:** [server/app.js](server/app.js) — `/webhooks`, `/internal/credits`, `/api/v1/me`, `/api/v1`, |
| 51 | `/api/me/vibes`, the staff console and machine endpoints. |
| 52 | - **Config:** [server/config.js](server/config.js), [server/config-store.js](server/config-store.js) |
| 53 | (owner settings, D11), [server/internal-config.js](server/internal-config.js). |
| 54 | - **Database:** [server/db.js](server/db.js); schema in [migrations/](migrations/) (`0001…0018`). |
| 55 | - **Ledger:** [server/ledger.js](server/ledger.js), [server/money/](server/money/) (reasons), |
| 56 | [server/ops/](server/ops/) — `transfers.js`, `cashouts.js`, `subscriptions.js`, `promo.js`, |
| 57 | `rating.js`, `usage.js`, `intents.js`, `wallet.js`, `admin.js`. |
| 58 | - **API:** [server/api/](server/api/) — `v1.js`, `internal.js`, `me.js`, `webhooks.js`, |
| 59 | `subject-assertion.js`, `idempotency.js`, `auth.js`, `actor-limits.js`. |
| 60 | - **Providers:** [server/providers/](server/providers/) — `powerchat.js`, `stripe.js`, `paypal.js`, |
| 61 | `nowpayments.js`, `index.js`. |
| 62 | - **Console:** [server/console/](server/console/) — server-rendered, Network SSO, staff only. |
| 63 | - **Scripts:** [scripts/reconcile.js](scripts/reconcile.js), `freeze.js`, `import-live.js`, |
| 64 | `load-rate-cards.js`. |
| 65 | - **Deploy:** [deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service), |
| 66 | [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf). |
| 67 | |
| 68 | ## Commands |
| 69 | |
| 70 | ```bash |
| 71 | npm ci |
| 72 | cp .env.example .env |
| 73 | npm run dev # http://localhost:4600 |
| 74 | npm test # test/run.js, PGlite, stub Network/Events/providers |
| 75 | npm run test:pg # same suite on PostgreSQL |
| 76 | npm run reconcile # reconciliation report; exit 1 on failure |
| 77 | npm run freeze -- status|on "<reason>"|off |
| 78 | npm run import-live # LIVE_DATABASE_URL=… (see README); --dry-run, --json |
| 79 | ov test # the harness's sharded run of what the change reaches |
| 80 | ``` |
| 81 | |
| 82 | Tests need no network, provider key or running Network: in-process stubs, temp databases, random |
| 83 | ports. `npm test -- import webhooks` filters; `-- --strict` makes a skip fail. |
| 84 | |
| 85 | ## Database and migrations |
| 86 | |
| 87 | PostgreSQL database `ov_billing` on the host's data role (ADR-035); development/tests use PGlite. |
| 88 | Async reads/writes through `openvibe-sdk/db`; an INSERT that needs its id uses `RETURNING id`. Schema |
| 89 | is `migrations/NNNN_*.sql`, applied at boot; **never edit a migration that has run** (its checksum is |
| 90 | recorded) and never create a table at runtime. Money integrity is enforced in SQL: the append-only |
| 91 | triggers, the zero-sum rule per currency and the balance checks. Do not weaken them for convenience. |
| 92 | |
| 93 | ## Auth, idempotency and the freeze |
| 94 | |
| 95 | - `/api/v1` is loopback-only (nginx denies it publicly), needs a Network service token audience |
| 96 | `openvibe.billing` with the route's capability, and **every POST needs an `Idempotency-Key`**. |
| 97 | A replay with the same money fields returns the first row; different money fields are |
| 98 | `409 billing.usage_key_reused` / the matching reason. |
| 99 | - The staff console is Network SSO with PKCE, limited to `BILLING_STAFF_SUBJECTS`; every staff action |
| 100 | is audited as `billing.staff.action`. |
| 101 | - **The freeze** (`money_writes_frozen` / `npm run freeze`) stops every mutation: writes answer |
| 102 | `503 billing.frozen`, reads still work, provider webhooks are stored (202) and processed in arrival |
| 103 | order after unfreeze, and jobs pause. Rating and renewals never run while frozen. |
| 104 | |
| 105 | ## Frontend loading model |
| 106 | |
| 107 | The only UI is the **staff console**, server-rendered HTML (no SPA, no build step, no bundler); there |
| 108 | is no `public/` app. `/shared/*` is served from the pinned `openvibe-shared` package where used. |
| 109 | Provider pages carry no product UI; a person's balance is read by each site from `/api/me/vibes`. |
| 110 | |
| 111 | ## Deploy |
| 112 | |
| 113 | `sudo ovhost deploy billing` (strategy `git-checkout`): fetch, fast-forward `/opt/openvibe.billing`, |
| 114 | install on a lockfile change, restart, wait for `/api/ready`, roll back on failure. Unit |
| 115 | `openvibe-billing.service`, env `/etc/openvibe/billing.env` (0600), vhost |
| 116 | [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf). The release |
| 117 | migrates `ov_billing` at boot; `sudo ovhost rollback billing --to <sha>`. Cutover, freeze and import |
| 118 | runbooks are in the README and [docs/](docs/); follow them, do not improvise a money migration. |
| 119 | |
| 120 | ## Tests |
| 121 | |
| 122 | `npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner` |
| 123 | (60 s/file), with stub Network/Events/providers. The suite pins the ledger invariants, the three |
| 124 | separate ledgers and promo isolation, promo grant/consume/lapse, idempotency, webhook storage and |
| 125 | ordering, the freeze, renewals/grace, reconciliation, capabilities, the public words and the |
| 126 | nginx/vhost rules. `test/public-words.test.js` and `test/nginx-vhost.test.js` pin files, so a copy |
| 127 | change may require the matching test update. `ov test` runs the same. |
| 128 | |
| 129 | ## Conventions |
| 130 | |
| 131 | - CommonJS, 4-space indent, single quotes, semicolons; factories with injectable `now`, `fetchImpl`, |
| 132 | `log`, `keys`, `identity`, `adapters` and `limitsNow` so tests are deterministic. |
| 133 | - Amounts are integer **vibes-bits**; never use floats for money. Reasons are enum values from |
| 134 | `openvibe-contracts` money reasons, not free text. |
| 135 | - Errors are RFC 9457 problem+json with stable codes (`billing.insufficient_funds`, |
| 136 | `billing.promo_isolated`, `billing.frozen`, …). |
| 137 | - Secrets (provider keys, webhook secrets, OAuth) come from the environment and are never returned, |
| 138 | logged or written to a file. |
| 139 | |
| 140 | ## Pitfalls |
| 141 | |
| 142 | - **Never write money outside the ledger.** A product must call an operation, not `UPDATE` a balance. |
| 143 | - **Promo is not credit.** A transfer/cashout from `promo_credit` is refused; an admin adjustment |
| 144 | mixing promo with another kind is `billing.promo_isolated`; reconciliation checks it. |
| 145 | - **Every POST needs an Idempotency-Key**; without it the route refuses, and a reused key with |
| 146 | different money fields is a 409, not a second charge. |
| 147 | - **Webhooks are stored before processing**; never process inline and lose the receipt on a crash. |
| 148 | - **The freeze is global.** New mutations must check it; the relay and reconciliation are the allowed |
| 149 | exceptions. |
| 150 | - **`/api/v1` is not public.** Do not add it to the vhost; only the console, `/webhooks/*` and |
| 151 | `/api/health` are exposed. |
| 152 | - **Imports read Live's data, never write it.** `scripts/import-live.js` is the only bridge and needs |
| 153 | `LIVE_DATABASE_URL`. |
| 154 | - **Tests pin copy and nginx.** Update the test with an intended change; never skip the assertion. |
| 155 | |
| 156 | ## Documentation |
| 157 | |
| 158 | - [README.md](README.md) — the journal, routes, providers, operations and deploy runbook. |
| 159 | - [docs/economic-inventory.md](docs/economic-inventory.md) — every value flow and its class. |
| 160 | - [docs/wallet.md](docs/wallet.md), [docs/balance.md](docs/balance.md), [docs/vibes-pay.md](docs/vibes-pay.md). |
| 161 | - [docs/live-cutover.md](docs/live-cutover.md), [docs/cutover-usage-rating.md](docs/cutover-usage-rating.md), |
| 162 | [docs/renewal-grace-cutover.md](docs/renewal-grace-cutover.md), [docs/live-mapping.md](docs/live-mapping.md). |
| 163 | - [docs/sdk-outbox.md](docs/sdk-outbox.md) — the event outbox. |
| 164 |