openvibers/openvibe.billing Commits

Billing answers account export and deletion (ADR-033): the person's balances and money records exported, money records kept on deletion, settings removed; /internal/events on loopback, subscriptions at boot (#77)

OP OpenVibers committed 27 days ago · 2c50eb4edd · 10 files, +207 −1

Changes

ddiff --git a/.env.example b/.env.example
iindex 12a0202..7779044 100644
---- a/.env.example
++++ b/.env.example
@@@ -133,3 +133,7 @@ POWERCHAT_SITE_USERNAME=
#BILLING_RATING_BATCH=500
#BILLING_RATING_RETRY_MS=3600000
#OV_RATE_CARDS=/etc/openvibe/rate-cards.json
++
++# Account export and deletion (ADR-033): the signing secret of Billing's own Events subscriptions (32+ characters;
++# comma-separated during a rotation). Without it, the account subscriptions are not created and /internal/events answers 503.
++#BILLING_EVENTS_SECRET=
ddiff --git a/README.md b/README.md
iindex e7be8be..ed0c26f 100644
---- a/README.md
++++ b/README.md
@@@ -383,6 +383,18 @@ there — a share already recycled or cashed out goes to `chargeback_loss`, flag
- **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
ddiff --git a/deploy/nginx/billing.openvibe.network.conf b/deploy/nginx/billing.openvibe.network.conf
iindex 4ceec8b..49094aa 100644
---- a/deploy/nginx/billing.openvibe.network.conf
++++ b/deploy/nginx/billing.openvibe.network.conf
@@@ -68,6 +68,8 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# answers 404 to any request a proxy relayed.
location ~* ^/metrics(/|$) { return 404; }
++ # Service-to-service (OpenVibe.Events → 127.0.0.1:4600): never public.
++ location ^~ /internal/ { return 404; }
# The service API: loopback only. Never proxied from the public host.
location ^~ /api/v1 { deny all; }
ddiff --git a/migrations/0009_account_data.sql b/migrations/0009_account_data.sql
nnew file mode 100644
iindex 0000000..070e895
---- /dev/null
++++ b/migrations/0009_account_data.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- Account export and deletion (ADR-033, server/account-data.js): one receipt per export and deletion id, so a
++-- redelivered deletion never erases twice and a confirmation Network did not take is sent again (openvibe-sdk/account-data).
++CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome JSONB,
++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
++ sent_at TIMESTAMPTZ
++);
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..4a239de
---- /dev/null
++++ b/server/account-data.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/**
++ * Account export and deletion → Billing (ADR-033; openvibe-sdk/account-data). Billing is the money ledger since the
++ * cutover of 2026-10-17, so a person's money history is answered here (Live keeps none of it).
++ *
++ * export the person's accounts with their balances, every transaction they sent or received, their checkouts,
++ * cash-outs, subscriptions (as a member, and as a creator without the members' identities), entitlements,
++ * tips on their own PowerChat, usage readings, promo allowances, tier prices and connected payment accounts
++ * erase money records stay (the ledger, receipts, checkouts, cash-outs, subscriptions, entitlements, usage and
++ * promo rows are records the law and the reconciliation need): counted as retained. What is only settings
++ * goes: tier prices, connected payment accounts (the attribution of a provider username to the person),
++ * staff sessions.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const ledger = 'money records: the ledger must still balance and reconcile, and payments are records the law requires us to keep';
++
++const TABLES = [
++ { table: 'transactions', subject: 'from_subject', file: 'transactions-sent.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger } },
++ { table: 'transactions', subject: 'to_subject', file: 'transactions-received.json', columns: ['id', 'type', 'status', 'from_subject', 'to_subject', 'provider', 'reverses_txn', 'test', 'metadata', 'created_at'], erase: { keep: ledger }, kind: 'transactions_received' },
++ { table: 'payment_intents', subject: 'subject', file: 'checkouts.json', columns: ['id', 'provider', 'kind', 'streamer_subject', 'amount_cents', 'fee_cents', 'bits', 'route', 'auto_renew', 'status', 'settled_txn', 'created_at', 'updated_at'], erase: { keep: ledger } },
++ { table: 'cashouts', subject: 'subject', file: 'cashouts.json', columns: ['id', 'amount_bits', 'value_cents', 'status', 'payout_method', 'escrow_until', 'payout_provider', 'payout_reference', 'reason', 'created_at', 'updated_at'], erase: { keep: ledger } },
++ { table: 'subscriptions', subject: 'subscriber', file: 'subscriptions.json', columns: ['id', 'streamer', 'tier', 'provider', 'status', 'auto_renew', 'cancel_at_period_end', 'price_cents', 'current_period_end', 'created_at', 'updated_at'], erase: { keep: ledger } },
++ // As a creator: their subscriptions without who the members are (that is the members' data).
++ { table: 'subscriptions', subject: 'streamer', file: 'subscriptions-to-you.json', columns: ['id', 'tier', 'status', 'price_cents', 'current_period_end', 'created_at'], erase: { keep: ledger }, kind: 'subscriptions_to_you' },
++ { table: 'entitlements', subject: 'subject', file: 'entitlements.json', columns: ['id', 'kind', 'scope', 'subscription_id', 'starts_at', 'ends_at', 'revoked_at', 'created_at'], erase: { keep: ledger } },
++ { table: 'external_receipts', subject: 'streamer_subject', file: 'tips-on-your-powerchat.json', columns: ['receipt_ref', 'provider', 'receiving_account', 'amount_cents', 'test', 'status', 'created_at'], erase: { keep: ledger } },
++ { table: 'usage_records', subject: 'subject', file: 'usage.json', erase: { keep: ledger } },
++ { table: 'promo_allowances', subject: 'subject', file: 'promo-allowances.json', erase: { keep: ledger } },
++ { table: 'subscription_tiers', subject: 'streamer', file: 'tier-prices.json', columns: ['tier', 'price_cents', 'status', 'created_at', 'updated_at'] },
++ { table: 'provider_accounts', subject: 'subject', file: 'payment-accounts.json', columns: ['provider', 'username', 'source', 'created_at', 'updated_at'] },
++ { table: 'staff_sessions', subject: 'subject', file: null },
++];
++
++/** Accounts with their balances (a balance is the account's cached sum of ledger entries). */
++async function balances(db, subject) {
++ return await db.prepare(`SELECT a.kind, a.currency, COALESCE(b.balance, 0) AS balance, a.created_at FROM accounts a
++ LEFT JOIN account_balances b ON b.account_id = a.id WHERE a.owner_subject = ? ORDER BY a.id`).all(subject);
++}
++
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db, service: 'billing', tables: TABLES, log,
++ note: 'OpenVibe.Billing keeps Vibes and payments. Money records are kept after an account is deleted, as the law and the books require; they no longer lead to a person once the account is gone.',
++ extraExport: async (d, subject) => [{ name: 'balances.json', content: await balances(d, subject) }],
++ // Accounts stay (the ledger points at them): counted as retained with the other money records.
++ extraErase: async (t, subjects, counts) => {
++ for (const subject of subjects) {
++ counts.add(counts.retained, 'accounts', Number(await t.value('SELECT COUNT(*)::int FROM accounts WHERE owner_subject = $1', [subject])) || 0);
++ }
++ },
++ });
++}
++
++module.exports = { create, TABLES, TOPICS, balances };
ddiff --git a/server/app.js b/server/app.js
iindex 375e6c2..ef2683f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -100,6 +100,16 @@ async function createApp(opts = {}) {
app.get('/api/ready', readiness.handler);
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
++ // Account export and deletion (ADR-033, server/account-data.js): OpenVibe.Events delivers network.account.* to
++ // 127.0.0.1:4600 directly (the consumer refuses anything that came through a proxy; nginx 404s /internal/ too), and
++ // the parts and confirmations go to Network with Billing's own token. A test injects a stand-in through opts.accountSend.
++ const accountData = require('./account-data').create({ db, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? require('openvibe-sdk/account-data').createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, ...(opts.fetchImpl ? { fetch: fetchImpl } : {}) })
++ : null);
++ app.post('/internal/events', accountData.consumer({ secrets: config.events.webhookSecrets, send: accountSend }));
++ app.locals.accountData = accountData;
++ app.locals.accountSend = accountSend;
// Per-person limits on the money-creating routes (api/actor-limits.js); the webhooks above never are.
const limits = createActorLimits({ config, db, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
ddiff --git a/server/config.js b/server/config.js
iindex fe42f7d..d980f40 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -159,6 +159,9 @@ function loadConfig(env = process.env) {
events: {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ // Signing secret of Billing's own subscriptions (account export and deletion, server/account-data.js);
++ // comma-separated so a rotation can accept the old and the new value.
++ webhookSecrets: String(env.BILLING_EVENTS_SECRET || '').split(',').map((v) => v.trim()).filter(Boolean),
},
jobs: {
enabled: env.BILLING_JOBS !== 'off',
ddiff --git a/server/index.js b/server/index.js
iindex 7148bea..8847414 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -12,6 +12,7 @@
* BILLING_RATING_INTERVAL_MS is set (default 0 = off). While the economy is frozen only the relay and the
* reconciliation (it only reads the books) run.
*/
++const { startSubscriptions } = require('openvibe-sdk/account-data');
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const providers = require('./providers');
@@@ -55,6 +56,13 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
relay.start();
}
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // BILLING_EVENTS_SECRET or the client secret.
++ const accountSubscriptions = startSubscriptions({
++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.webhookSecrets[0],
++ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ });
++
const server = app.listen(config.port, config.host, async () => {
const enabled = Object.values(adapters).filter((a) => a.enabled).map((a) => a.name);
console.log(`[Billing] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (PostgreSQL)`);
@@@ -80,6 +88,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
() => { for (const t of timers) { clearInterval(t); clearTimeout(t); } },
() => relay.stop(),
() => keys.stop(),
++ () => { if (accountSubscriptions) accountSubscriptions.stop(); },
],
close: [
() => app.locals.valkey && app.locals.valkey.close().catch(() => {}),
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..faf2a84
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * ADR-033: Billing's part of an account export and deletion (server/account-data.js), with a stand-in Network. The
++ * export carries the person's balances and every money record about them, a creator's subscriptions without the
++ * members' identities; a deletion keeps the money records (the ledger still reconciles) and removes settings (tier
++ * prices, connected payment accounts), confirmed once with its counts. The route is loopback only and signed.
++ */
++const assert = require('assert');
++const http = require('http');
++const crypto = require('crypto');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { boot, fund, check, done } = require('./helpers/app');
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_billing', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const ulid = () => Array.from(crypto.randomBytes(26), (b) => CROCKFORD[b % 32]).join('');
++const envelope = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'network' }, subject: { type: 'account', id: payload.subject }, visibility: 'internal', payload });
++
++(async () => {
++ const stub = await startNetworkStub();
++ const secret = crypto.randomBytes(32).toString('hex');
++ const t = await boot({ env: { BILLING_EVENTS_SECRET: secret, BILLING_RENEWAL_GRACE_DAYS: '0' }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'billing', clientSecret: 'shh' }) });
++ const fan = t.user(61);
++ const creator = t.user(62);
++ const accountData = t.app.locals.accountData;
++ const send = t.app.locals.accountSend;
++ const files = (part) => Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
++
++ await check('a fan and a creator make money records through the API', async () => {
++ await fund(t, fan, 2000);
++ const tip = await t.call('POST', '/api/v1/transfers', { cap: ['billing.transfer.create'], body: { from: fan, to: creator, amount: 300, kind: 'donation', message: 'gg' } });
++ assert.strictEqual(tip.status, 201, tip.text);
++ const put = await t.call('PUT', `/api/v1/tiers/${creator.id}/1`, { cap: ['billing.tier.manage'], body: { price_cents: 300 }, headers: { 'Idempotency-Key': `tier-${crypto.randomUUID()}` } });
++ assert.strictEqual(put.status, 200, put.text);
++ const sub = await t.call('POST', '/api/v1/subscriptions', { cap: ['billing.subscription.manage'], body: { subscriber: fan, streamer: creator, source: 'credit' } });
++ assert.strictEqual(sub.status, 201, sub.text);
++ });
++
++ await check("the fan's export: balances, what they sent, their subscription and entitlement", async () => {
++ const out = await accountData.apply(envelope(`evt_${ulid()}`, 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: fan.id }), { send });
++ assert.strictEqual(out, 'exported');
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
++ assert.strictEqual(part.auth, 'Bearer tok_billing');
++ const f = files(part);
++ assert.ok(f['balances.json'].some((b) => b.kind === 'user_credit' && Number(b.balance) === 2000 - 300 - 300), JSON.stringify(f['balances.json']));
++ assert.deepStrictEqual(f['transactions-sent.json'].map((x) => x.type).sort(), ['donation', 'subscription']);
++ assert.strictEqual(f['subscriptions.json'].length, 1);
++ assert.strictEqual(f['entitlements.json'].length, 1);
++ });
++
++ await check("the creator's export: what they received and their subscriptions, without who the members are", async () => {
++ await accountData.apply(envelope(`evt_${ulid()}`, 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: creator.id }), { send });
++ const f = files(stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX2/parts'));
++ assert.ok(f['transactions-received.json'].length >= 2);
++ assert.strictEqual(f['subscriptions-to-you.json'].length, 1);
++ assert.ok(!('subscriber' in f['subscriptions-to-you.json'][0]), "the member's identity is not the creator's data");
++ assert.deepStrictEqual(f['tier-prices.json'].map((x) => [x.tier, Number(x.price_cents)]), [[1, 300]]);
++ });
++
++ await check('a deletion keeps the money records, removes tier prices, and is confirmed once', async () => {
++ const before = Number(await t.db.value('SELECT COUNT(*)::int FROM transactions'));
++ const del = envelope(`evt_${ulid()}`, 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: creator.id, requested_at: new Date(Date.now() - 30 * 86400000).toISOString(), deleted_at: new Date().toISOString() });
++ assert.strictEqual(await accountData.apply(del, { send }), 'erased');
++ assert.strictEqual(Number(await t.db.value('SELECT COUNT(*)::int FROM transactions')), before, 'the ledger stays whole');
++ assert.strictEqual(Number(await t.db.value('SELECT COUNT(*)::int FROM subscription_tiers WHERE streamer = $1', [creator.id])), 0);
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.ok(conf[0].body.retained.transactions_received >= 2, JSON.stringify(conf[0].body));
++ assert.ok(conf[0].body.retained.accounts >= 1);
++ assert.notStrictEqual(await accountData.apply(del, { send }), 'erased', 'a redelivery never erases twice');
++ await t.assertReconciled('after a deletion');
++ });
++
++ await check('the route is loopback only and signed', async () => {
++ const unsigned = await fetch(`${t.base}/internal/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
++ assert.strictEqual(unsigned.status, 401);
++ const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9' }, body: '{}' });
++ assert.strictEqual(proxied.status, 403);
++ });
++
++ await t.close();
++ await stub.close();
++ done();
++})();
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 1a80660..9207075 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -40,7 +40,7 @@ async function boot(opts = {}) {
// npm run test:pg: the per-person limits count in the Valkey container (a prefix of this app's own), as in production.
const valkey = process.env.BILLING_TEST_STORE === 'pg' && process.env.OV_TEST_VALKEY_URL
? require('openvibe-sdk/valkey').createValkey({ url: process.env.OV_TEST_VALKEY_URL, prefix: `ov:billing-test:${crypto.randomBytes(6).toString('hex')}:` }) : null;
-- const app = await createApp({ config, db, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow, valkey });
++ const app = await createApp({ config, db, now: () => Date.now() + clock.offset, log, limitsNow: opts.limitsNow, valkey, accountSend: opts.accountSend });
await app.locals.keys.refresh();
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;