openvibers/openvibe.billing Commits

The SQLite era is gone (on PostgreSQL since 2026-09-28; no compatibility kept): the one-time migrate-to-postgres script, the *_DB_PATH settings (config, .env.example, systemd unit, test helpers), the unused better-sqlite3 dependency and its lockfile entries, and docs, docstrings and STATUS lines about SQLite or its rollback window. Reviewed independently (VERDICT: SHIP). npm test 19/19 test files passed.

OP OpenVibers committed 29 Sept 2026 · fe4ff088ef · 9 files, +14 −68


Changes

ddiff --git a/.env.example b/.env.example
iindex e169d17..0f250d8 100644
---- a/.env.example
++++ b/.env.example
@@@ -18,9 +18,6 @@ DATABASE_URL=
DATABASE_DIRECT_URL=
# Valkey: the per-person limit counters, kept across restarts (optional; without it they count in the process).
VALKEY_URL=
--# The SQLite ledger the one-time move reads (scripts/migrate-to-postgres.js). The systemd unit sets
--# /var/lib/openvibe-billing/billing.db.
--BILLING_DB_PATH=./data/billing.db
# Who keeps the money books: live (default — OpenVibe.Live is the ledger and receives the PowerChat
# webhook; Billing runs in shadow) or billing (after the cutover, docs/live-cutover.md). Only EXTERNAL
ddiff --git a/README.md b/README.md
iindex 299e096..9ff819d 100644
---- a/README.md
++++ b/README.md
@@@ -78,8 +78,7 @@ node scripts/import-live.js --live-db <snapshot> --accounts-only # only PowerC
```
Production: `/opt/openvibe.billing`, env `/etc/openvibe/billing.env`, unit
--[deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service) (state in
--`/var/lib/openvibe-billing`), vhost [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf).
++[deploy/systemd/openvibe-billing.service](deploy/systemd/openvibe-billing.service), vhost [deploy/nginx/billing.openvibe.network.conf](deploy/nginx/billing.openvibe.network.conf).
## Design
@@@ -285,7 +284,7 @@ Reversing a subscription payment revokes the periods it granted.
- **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.
--- **Backups**: `sqlite3 /var/lib/openvibe-billing/billing.db ".backup billing-$(date +%F).db"`.
++- **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).
@@@ -328,10 +327,8 @@ Production deploys with `sudo ovhost deploy billing` on the host (strategy `git-
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 migrates it at boot. The one-time move from SQLite is `scripts/migrate-to-postgres.js` (openvibe-sdk
--`runSqliteMigration`, with a `--pglite` rehearsal mode; it refuses to empty a PostgreSQL journal that already has
--transactions), run while the service is stopped and frozen; the old `/var/lib/openvibe-billing/billing.db` stays
--read-only for 7 days as the rollback. nginx serves `billing.openvibe.network` from
++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
ddiff --git a/deploy/systemd/openvibe-billing.service b/deploy/systemd/openvibe-billing.service
iindex 302f03c..81907fb 100644
---- a/deploy/systemd/openvibe-billing.service
++++ b/deploy/systemd/openvibe-billing.service
@@@ -19,11 +19,10 @@ Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/bin/env node /opt/openvibe.billing/server/index.js
Restart=always
RestartSec=5
--# /var/lib/openvibe-billing keeps the SQLite ledger the service moved from (read-only after the switch: the rollback
--# for 7 days; scripts/migrate-to-postgres.js reads it); the code tree stays read-only.
++# /var/lib/openvibe-billing holds the Live snapshot the cutover runbook copies in for the import
++# (docs/live-cutover.md); the code tree stays read-only.
StateDirectory=openvibe-billing
StateDirectoryMode=0700
--Environment=BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
ddiff --git a/docs/economic-inventory.md b/docs/economic-inventory.md
iindex 051dac7..b4b5ef6 100644
---- a/docs/economic-inventory.md
++++ b/docs/economic-inventory.md
@@@ -47,7 +47,7 @@ are non-zero.
1. **Non-atomic donate.** `donate()` deducts, credits and records in three separate statements with no
transaction (`vibes.js:111-128`); `deductVibes` is read-then-write (`database.js:5393-5399`), so two
concurrent donations can both pass the check. A crash between the deduct and the credit loses money.
-- *Billing:* every operation is one SQLite transaction with the funds check inside it.
++ *Billing:* every operation is one PostgreSQL transaction with the funds check inside it.
2. **Best-effort, mutable ledger.** The ledger row is written after the balances move and failures are
swallowed (`payments.js:262-268`, `powerchat-checkout.js:337-343`); rows are later UPDATEd
(`vibes.js:204`, `:218`); subscription payments and streamer shares write no ledger row at all
ddiff --git a/docs/live-cutover.md b/docs/live-cutover.md
iindex e9bed63..fedc2f0 100644
---- a/docs/live-cutover.md
++++ b/docs/live-cutover.md
@@@ -128,8 +128,8 @@ Shell helpers used below (on the host):
```bash
# billing '<command>' runs one command with Billing's environment, as Billing's service user (ubuntu)
--# so the database files keep their owner — the same way the shadow imports were run.
--billing() { sudo bash -c "set -a; . /etc/openvibe/billing.env; set +a; export NODE_ENV=production BILLING_DB_PATH=/var/lib/openvibe-billing/billing.db; cd /opt/openvibe.billing && sudo -E -u ubuntu $1"; }
++# so the Live snapshot files keep their owner — the same way the shadow imports were run.
++billing() { sudo bash -c "set -a; . /etc/openvibe/billing.env; set +a; export NODE_ENV=production; cd /opt/openvibe.billing && sudo -E -u ubuntu $1"; }
LIVE_DB=/opt/openvibe.live/data/live.db # production runs Live's legacy layout (a git checkout); there is no shared/
# /var/backups/openvibe is root-only (0700), so Billing's user cannot read a snapshot there: the import reads
# its copy from Billing's own state directory, and a second copy stays in /var/backups/openvibe for rollback.
@@@ -152,8 +152,8 @@ Live owner calls are made from the browser console on openvibe.live, signed in a
A `paid` row is an anomaly to fix first; a `pending` one is carried into Billing as an intent and
settles there if it is ever paid (canonical, un-minted links do not expire).
3. **Back up and freeze Billing** so it holds PowerChat deliveries until the final import is in:
-- `billing "sqlite3 /var/lib/openvibe-billing/billing.db '.backup /var/lib/openvibe-billing/billing-pre-cutover.db'"`
-- (kept for rollback), then `billing "node scripts/freeze.js on 'Live cutover'"` → `frozen: true`.
++ take a PostgreSQL snapshot of `ov_billing` (`pg_dump`, kept in `/var/lib/openvibe-billing` for rollback),
++ then `billing "node scripts/freeze.js on 'Live cutover'"` → `frozen: true`.
Then make Billing the authority for EXTERNAL tips: add `BILLING_AUTHORITY=billing` to
`/etc/openvibe/billing.env` and `sudo systemctl restart openvibe-billing`. The freeze is kept in the
database, so it survives the restart. `curl -s http://127.0.0.1:4600/api/health` →
@@@ -238,8 +238,8 @@ which money moved where.
`TIPS_CHAT_ADAPTER=none` in tips.env and restart Tips; re-point PowerChat to Live; unfreeze Live.
The billing.env `BILLING_AUTHORITY` goes too, with the Billing restore below. The stragglers Billing
settled are still pending orders on Live and are backfilled by Live's reconciler from PowerChat's
-- paid-messages feed — so Billing must forget them: stop `openvibe-billing`, restore `billing-pre-cutover.db` from step 3 over `billing.db` (remove the
-- `-wal`/`-shm` files), freeze it (`billing "node scripts/freeze.js on 'rollback'"`), start it again.
++ paid-messages feed — so Billing must forget them: stop `openvibe-billing`, restore the step-3 PostgreSQL
++ snapshot over `ov_billing`, freeze it (`billing "node scripts/freeze.js on 'rollback'"`), start it again.
Otherwise a later attempt would count those payments twice (Live's credit arrives through the import,
Billing's own settlement stays).
- **After step 9** (people have moved money in Billing): ADR-012's rollback — freeze Billing and Live,
ddiff --git a/scripts/import-live.js b/scripts/import-live.js
iindex 06093b3..96dd6c8 100644
---- a/scripts/import-live.js
++++ b/scripts/import-live.js
@@@ -15,7 +15,6 @@
* identity.subject.resolve). The report is printed and stored in import_runs; a reconciliation run
* follows every real import. Safe to re-run: a second run over the same snapshot changes nothing.
*/
--const path = require('path');
const Database = require('better-sqlite3');
const { loadConfig } = require('../server/config');
const { openDb } = require('../server/db');
@@@ -32,7 +31,6 @@ async function main() {
const livePath = opt('live-db');
if (!livePath) { console.error('usage: import-live.js --live-db <snapshot path> [--dry-run] [--json]'); process.exit(2); }
const config = loadConfig();
-- if (path.resolve(livePath) === path.resolve(config.dbPath)) throw new Error('--live-db points at the Billing database');
const live = new Database(livePath, { readonly: true, fileMustExist: true });
const db = await openDb(config);
const ctx = { db, config, rates: createRates(config.rates), now: () => Date.now(), log: console };
ddiff --git a/scripts/migrate-to-postgres.js b/scripts/migrate-to-postgres.js
ddeleted file mode 100755
iindex cbaedc3..0000000
---- a/scripts/migrate-to-postgres.js
++++ /dev/null
@@@ -1,42 +0,0 @@
--#!/usr/bin/env node
--'use strict';
--/**
-- * The one-time move of Billing's SQLite ledger (BILLING_DB_PATH) into its PostgreSQL schema (ADR-035; the procedure is
-- * openvibe-sdk docs/migrating-to-postgresql.md, section 6, carried out by openvibe-sdk/db runSqliteMigration).
-- *
-- * node scripts/migrate-to-postgres.js [--sqlite <file>] [--pglite] [--json] [--replace-ledger]
-- *
-- * Applies migrations/ as the owner (DATABASE_DIRECT_URL), copies every table into emptied tables, verifies row counts
-- * and checksums, and exits 1 unless everything verified. The SQLite file is opened read-only. Every table keeps its
-- * name and columns; the append-only triggers guard UPDATE and DELETE, so the TRUNCATE that empties a table and the
-- * copy's INSERTs pass them.
-- */
--const { runSqliteMigration } = require('openvibe-sdk/db');
--const { loadConfig } = require('../server/config');
--const { MIGRATIONS } = require('../server/db');
--
--const TABLES = {};
--
--/**
-- * The copy empties its target tables first (a rehearsal can be repeated). Once PostgreSQL holds a journal of its own,
-- * that would erase money records: it refuses unless --replace-ledger says the rows there are to go.
-- */
--async function refuseOverLiveLedger(directUrl) {
-- if (!directUrl || process.argv.includes('--pglite') || process.argv.includes('--replace-ledger')) return;
-- const { createDb } = require('openvibe-sdk/db');
-- const db = createDb({ url: directUrl, service: 'billing-migrate-check', max: 1 });
-- try {
-- const t = await db.prepare("SELECT to_regclass('transactions') IS NOT NULL AS present").get();
-- const n = t.present ? Number((await db.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n) : 0;
-- if (n > 0) throw new Error(`PostgreSQL already holds ${n} ledger transaction(s): refusing to replace them (--replace-ledger to do it anyway)`);
-- } finally { await db.close(); }
--}
--
--if (require.main === module) {
-- const config = loadConfig();
-- refuseOverLiveLedger(config.db.directUrl)
-- .then(() => runSqliteMigration({ service: 'billing', sqlite: config.dbPath, directUrl: config.db.directUrl, migrations: MIGRATIONS, tables: TABLES }))
-- .then((code) => process.exit(code), (err) => { console.error(`migrate-to-postgres failed: ${err.message}`); process.exit(1); });
--}
--
--module.exports = { TABLES };
ddiff --git a/server/config.js b/server/config.js
iindex ad683f4..5717fbd 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -69,11 +69,9 @@ function loadConfig(env = process.env) {
host: env.HOST || '127.0.0.1',
baseUrl,
trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
-- // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner). dbPath: the SQLite
-- // file of releases before PostgreSQL, read once by scripts/migrate-to-postgres.js.
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner).
db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '' },
valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:billing:' },
-- dbPath: env.BILLING_DB_PATH || './data/billing.db',
// 'live' (default, shadow): EXTERNAL PowerChat tips are recorded but not announced, because
// Live announces them from its own webhook. 'billing': they are announced as
// billing.receipt.external (OpenVibe.Tips turns that into the chat line, alert and goal).
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex fe48a48..031d91b 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -18,7 +18,6 @@ async function boot(opts = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'billing-test-'));
const env = {
NODE_ENV: 'test',
-- BILLING_DB_PATH: path.join(dir, 'billing.db'),
OV_NETWORK_INTERNAL_URL: network.url,
OV_NETWORK_ISSUER: network.url,
OV_OAUTH_CLIENT_ID: 'billing',