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)
Changes
@@@ -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=@@@ -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 tableof 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@@@ -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; }nnew file mode 100644@@@ -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++);nnew file mode 100644@@@ -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 };@@@ -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 }));@@@ -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',@@@ -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-sdkrelay.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(() => {}),nnew file mode 100644@@@ -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();++})();@@@ -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}`;
@@ -133,3 +133,7 @@ POWERCHAT_SITE_USERNAME=
@@ -133,3 +133,7 @@ POWERCHAT_SITE_USERNAME=
#BILLING_RATING_BATCH=500
#BILLING_RATING_BATCH=500
#BILLING_RATING_RETRY_MS=3600000
#BILLING_RATING_RETRY_MS=3600000
#OV_RATE_CARDS=/etc/openvibe/rate-cards.json
#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=
@@ -383,6 +383,18 @@ there — a share already recycled or cashed out goes to `chargeback_loss`, flag
@@ -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
- **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).
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
## Acceptance
`npm test` runs every `test/*.test.js` against stub Network, Events and providers, temp databases and
`npm test` runs every `test/*.test.js` against stub Network, Events and providers, temp databases and
@@ -68,6 +68,8 @@ server {
@@ -68,6 +68,8 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4600/metrics). Node also
# answers 404 to any request a proxy relayed.
# answers 404 to any request a proxy relayed.
location ~* ^/metrics(/|$) { return 404; }
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.
# The service API: loopback only. Never proxied from the public host.
location ^~ /api/v1 { deny all; }
location ^~ /api/v1 { deny all; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 };
@@ -100,6 +100,16 @@ async function createApp(opts = {}) {
@@ -100,6 +100,16 @@ async function createApp(opts = {}) {
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.use('/webhooks', webhooksRouter({ ctx, adapters }));
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.
// 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 });
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 }));
app.use('/api/v1', express.json({ limit: '64kb' }), v1Router({ ctx, auth, adapters, limits }));
@@ -159,6 +159,9 @@ function loadConfig(env = process.env) {
@@ -159,6 +159,9 @@ function loadConfig(env = process.env) {
events: {
events: {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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: {
jobs: {
enabled: env.BILLING_JOBS !== 'off',
enabled: env.BILLING_JOBS !== 'off',
@@ -12,6 +12,7 @@
@@ -12,6 +12,7 @@
* BILLING_RATING_INTERVAL_MS is set (default 0 = off). While the economy is frozen only the relay and the
* 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.
* reconciliation (it only reads the books) run.
*/
*/
+const { startSubscriptions } = require('openvibe-sdk/account-data');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
const providers = require('./providers');
const providers = require('./providers');
@@ -55,6 +56,13 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -55,6 +56,13 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
relay.start();
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 server = app.listen(config.port, config.host, async () => {
const enabled = Object.values(adapters).filter((a) => a.enabled).map((a) => a.name);
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)`);
console.log(`[Billing] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (PostgreSQL)`);
@@ -80,6 +88,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -80,6 +88,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
() => { for (const t of timers) { clearInterval(t); clearTimeout(t); } },
() => { for (const t of timers) { clearInterval(t); clearTimeout(t); } },
() => relay.stop(),
() => relay.stop(),
() => keys.stop(),
() => keys.stop(),
+ () => { if (accountSubscriptions) accountSubscriptions.stop(); },
],
],
close: [
close: [
() => app.locals.valkey && app.locals.valkey.close().catch(() => {}),
() => app.locals.valkey && app.locals.valkey.close().catch(() => {}),
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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();
+})();
@@ -40,7 +40,7 @@ async function boot(opts = {}) {
@@ -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.
// 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
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;
? 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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;