openvibers/openvibe.live Commits

Live's own money ledger goes (contract 0016, after 0009): transactions, payment_orders, subscriptions, the users money columns, and vpn_approvals (#336)

OP OpenVibers committed 20 days ago · ccbc98ac7c · 10 files, +103 −65

OpenVibe.Billing is the only ledger since 2026-10-17 (Live#182, Live#184); 0009's channel_tips copied the
donations this ledger held. Nothing under server/ reads or writes these (test/money-tables-gone.test.js);
answers that name a money column blank or omit it. account-data retains points and moderation history
only, and its donation-message clearing goes with the table. Tests insert users without the columns; the
0009 backfill replay goes, its source table gone.

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex f4c06ab4..8eaaeca1 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (the only path; the direct webhook was retired 2026-10-10). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the old local `comments` table is not in the PostgreSQL database either; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables were never created on PostgreSQL (`migrations/0002_live.sql` leaves them out; the SQLite-era boot migration 007 dropped them before the move) — `test/chat-tables-dropped.test.js` guards that nothing under `server/` names them and no migration creates one; Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** OpenVibe.Billing is the only money ledger since the cutover of 2026-10-17 ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)): every checkout, donation, cashout, recycle, subscription, balance and entitlement is a Billing call, provider and PowerChat webhooks go to Billing (Live's answer 410), and Live has no code that writes its old `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` (read-only until their contract migration drops them). The owner-only `money_writes_frozen` flag ([server/monetization/money-authority.js](server/monetization/money-authority.js), `/api/admin/money`) refuses money actions. **Cosmetics:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.
++**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (the only path; the direct webhook was retired 2026-10-10). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the old local `comments` table is not in the PostgreSQL database either; Chat owns every chat table — the staged set (`channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries`, `chat_timeline_events`; Live reads those only through `server/chat/moderation-client.js`, capability `chat.moderation.read`, cached 30 s, and the summaries through `server/chat/insight-client.js`, cached 60 s) and the twelve the chat cutover imports covered (`chat_messages`, the DM tables, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats`, `moderation_actions`); Live keeps no copy of any of them since 2026-10-05 — chat stats, queues, history, first-chat and DM-block state all come through `server/chat/chat-reads.js`, and the twelve tables were never created on PostgreSQL (`migrations/0002_live.sql` leaves them out; the SQLite-era boot migration 007 dropped them before the move) — `test/chat-tables-dropped.test.js` guards that nothing under `server/` names them and no migration creates one; Live-owned AI/transcript state for Media-hosted content lives in `vod_ai_state`/`clip_ai_state`. See ../CONTRACTS.md for the binding inter-service contracts. **Money:** OpenVibe.Billing is the only money ledger since the cutover of 2026-10-17 ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)): every checkout, donation, cashout, recycle, subscription, balance and entitlement is a Billing call, provider and PowerChat webhooks go to Billing (Live's answer 410), and Live's old `openvibe_bucks_*`/`openvibe_coins_balance` columns and `transactions`/`payment_orders`/`subscriptions` tables are gone (contract migration `0016_drop_old_money_ledger`; `test/money-tables-gone.test.js`). The owner-only `money_writes_frozen` flag ([server/monetization/money-authority.js](server/monetization/money-authority.js), `/api/admin/money`) refuses money actions. **Cosmetics:** OpenVibe.Inventory owns them since 2026-10-09 (ADR-054: `live.*` items issued by `service:live`, Live's item ids as aliases); [server/monetization/cosmetics.js](server/monetization/cosmetics.js) reads and writes through [inventory-client.js](server/monetization/inventory-client.js) (Chat's decor lookups batch 100 people per read) and Live keeps only the catalog's renderers. Live's `user_cosmetics`/`user_equipped`/`user_equipped_tag` are read by nothing (`test/cosmetics-tables-gone.test.js`) and are dropped by a contract migration.
## Commands
ddiff --git a/migrations/0016_drop_old_money_ledger.sql b/migrations/0016_drop_old_money_ledger.sql
nnew file mode 100644
iindex 00000000..6543b417
---- /dev/null
++++ b/migrations/0016_drop_old_money_ledger.sql
@@@ -0,0 +1,23 @@
++-- phase: contract
++-- after: 0009
++-- Live's own money ledger (plan 2026-10-22, J1). OpenVibe.Billing is the only ledger since the cutover of 2026-10-17:
++-- every checkout, donation, cashout, subscription and balance is a Billing call (Live#182), the home's money counts
++-- come from Billing (Live#184), and the tips Live shows are in channel_tips (0009, applied 2026-10-17 02:04 UTC, which
++-- copied the donations this ledger held). Since then nothing under server/ reads or writes these tables or the users
++-- money columns (test/money-tables-gone.test.js; the remaining mentions blank or omit the columns in answers), and a
++-- person's money history comes from Billing for export and deletion (Billing#77, Network#264). The last write to any
++-- of them was 2026-08-27.
++-- vpn_approvals goes too: Live#303 removed its routes on 2026-10-22, and it never had a row on PostgreSQL.
++-- Rollback: the operator's pg_dump of these tables and columns, taken before the deploy.
++-- users is busy: wait at most 5 s for its lock rather than queue every reader behind the ALTER (a timeout fails the
++-- boot, and the deploy keeps the running release).
++SET LOCAL lock_timeout = '5s';
++
++ALTER TABLE users DROP COLUMN IF EXISTS openvibe_bucks_balance;
++ALTER TABLE users DROP COLUMN IF EXISTS openvibe_bucks_cashout_balance;
++ALTER TABLE users DROP COLUMN IF EXISTS openvibe_coins_balance;
++
++DROP TABLE IF EXISTS transactions;
++DROP TABLE IF EXISTS payment_orders;
++DROP TABLE IF EXISTS subscriptions;
++DROP TABLE IF EXISTS vpn_approvals;
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 76a9853a..702fa360 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -33,11 +33,12 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
const FROZEN = new Set(['vods', 'clips', 'pastes', 'paste_likes', 'paste_comments', 'comments']);
--const RETAIN = new Set(['payment_orders', 'subscriptions', 'transactions', 'coin_transactions', 'channel_points_log', 'bans',
++// Money history is OpenVibe.Billing's (Live's own ledger went in migration 0016): what stays here is points and
++// moderation records.
++const RETAIN = new Set(['coin_transactions', 'channel_points_log', 'bans',
'token_revocations', 'verification_keys', 'subject_merges', 'account_data_events']);
--// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: sign-in IPs and
--// VPN requests.
--const OVERRIDE_DELETE = new Set(['ip_log.user_id', 'vpn_approvals.user_id']);
++// Rows the schema keeps without the person (SET NULL) that are theirs to erase all the same: sign-in IPs.
++const OVERRIDE_DELETE = new Set(['ip_log.user_id']);
// Copies of the person's name kept beside a person column: cleared wherever the row stays.
const NAME_COLS = ['username', 'display_name', 'user_name', 'sender_name', 'sender_username', 'author_name', 'author_username', 'avatar_url', 'user_avatar'];
// Person columns the schema declares without a foreign key.
@@@ -163,11 +164,6 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
bump(erased, t, r.changes);
}
}
-- // A donation's message is the person's words: cleared from Live's old ledger (read-only since the Billing
-- // cutover, gone with its contract migration; OpenVibe.Billing holds the money history now).
-- if (cols.has('transactions')) {
-- await d.prepare('UPDATE transactions SET message = NULL WHERE from_user_id = ? AND message IS NOT NULL').run(uid);
-- }
// The tombstone: the kept rows point at nobody; the username is free again.
const set = { username: `deleted-${uid}`, email: null, password_hash: null, display_name: null, avatar_url: null, bio: null, stream_key: null,
profile_color: null, theme_id: null, avatar_paste_id: null, ban_reason: null, deleted_at: now };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex d8c1213e..5fe1f63a 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -46,8 +46,7 @@ const ulid = () => ids.ulid();
await d.prepare("INSERT INTO streams (id, user_id, title) OVERRIDING SYSTEM VALUE VALUES (1, 20, 'dana live'), (2, 21, 'xena live')").run();
await d.prepare("INSERT INTO managed_streams (user_id, stream_key) VALUES (20, 'sk_live_secret_dana')").run();
await d.prepare("INSERT INTO api_tokens (user_id, token_hash) VALUES (20, 'hash-secret-dana')").run();
-- await d.prepare("INSERT INTO transactions (from_user_id, to_user_id, amount, type, message) VALUES (20, 21, 5, 'donation', 'love you xena'), (22, 20, 3, 'donation', 'for dana')").run();
-- await d.prepare("INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, status) VALUES (20, 'paypal', 'PO-1', 'bucks', 500, 'USD', 5, 'completed')").run();
++ await d.prepare("INSERT INTO coin_transactions (user_id, stream_id, amount, type) VALUES (20, 2, 5, 'watch'), (20, 2, 3, 'chat_bonus'), (22, 1, 2, 'watch')").run();
const app = express();
app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
@@@ -84,7 +83,7 @@ const ulid = () => ids.ulid();
}
assert.strictEqual(byName['profile.json'].username, 'dana');
assert.deepStrictEqual(byName['follows.json'].map((f) => [f.follower_id, f.streamer_id]).sort(), [[20, 21], [22, 20]]);
-- assert.ok(byName['transactions.json'] && byName['payment_orders.json'], 'money history is theirs to see');
++ assert.ok(byName['coin_transactions.json'], 'points history is theirs to see (money history is OpenVibe.Billing\'s)');
const text = JSON.stringify(part);
for (const secret of ['sk_live_secret_dana', 'hash-secret-dana', '$sso$']) assert.ok(!text.includes(secret), `no ${secret}`);
assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged', 'a redelivery sends nothing twice');
@@@ -102,8 +101,7 @@ const ulid = () => ids.ulid();
assert.strictEqual(await n('SELECT COUNT(*) AS n FROM follows'), 1, "others' follows stay");
assert.strictEqual(await n('SELECT COUNT(*) AS n FROM streams WHERE user_id = 20'), 0);
assert.strictEqual((await n('SELECT COUNT(*) AS n FROM managed_streams WHERE user_id = 20')) + (await n('SELECT COUNT(*) AS n FROM api_tokens WHERE user_id = 20')) + (await n("SELECT COUNT(*) AS n FROM linked_accounts WHERE user_id = 20")), 0);
-- assert.deepStrictEqual(await d.prepare('SELECT from_user_id AS f, message FROM transactions ORDER BY id').all(), [{ f: 20, message: null }, { f: 22, message: 'for dana' }], 'money kept; her own message cleared');
-- assert.strictEqual(await n("SELECT COUNT(*) AS n FROM payment_orders WHERE user_id = 20"), 1, 'payment orders kept');
++ assert.strictEqual(await n('SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = 20'), 2, 'points history kept');
const tomb = await d.prepare('SELECT username, email, bio, deleted_at FROM users WHERE id = 20').get();
assert.deepStrictEqual([tomb.username, tomb.email, tomb.bio, !!tomb.deleted_at], ['deleted-20', null, null, true]);
await d.prepare("INSERT INTO users (username, password_hash) VALUES ('dana', '$sso$')").run();
@@@ -112,7 +110,7 @@ const ulid = () => ids.ulid();
assert.strictEqual(conf.path, `/internal/account-deletions/${del.payload.deletion_id}/confirmations`);
assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
assert.ok(conf.body.erased.follows >= 2 && conf.body.erased.streams === 1 && conf.body.erased.accounts === 1, JSON.stringify(conf.body.erased));
-- assert.ok(conf.body.retained.transactions === 2 && conf.body.retained.payment_orders === 1, JSON.stringify(conf.body.retained));
++ assert.ok(conf.body.retained.coin_transactions === 2 && !('transactions' in conf.body.retained), JSON.stringify(conf.body.retained));
assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
assert.strictEqual(sent.length, 3);
ddiff --git a/test/authorization.test.js b/test/authorization.test.js
iindex c282ccbe..ff2ba064 100644
---- a/test/authorization.test.js
++++ b/test/authorization.test.js
@@@ -34,13 +34,13 @@ auth.optionalAuth = (req, res, next) => { signIn(req).then(() => next()).catch(n
// Accounts: an owner-admin, a second admin, two streamers, a bystander.
const addUser = async (id, username, role, extra = {}) => await raw.prepare(
-- `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner, openvibe_bucks_balance, openvibe_bucks_cashout_balance) OVERRIDING SYSTEM VALUE
-- VALUES (?, ?, ?, ?, 'x', ?, ?, ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0, extra.bucks || 0, extra.cashout || 0);
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0);
await addUser(1, 'owner', 'admin', { is_owner: 1 });
await addUser(2, 'admin2', 'admin');
--await addUser(3, 'alice', 'streamer', { cashout: 0 });
++await addUser(3, 'alice', 'streamer');
await addUser(4, 'bob', 'streamer');
--await addUser(5, 'carol', 'user', { bucks: 0 });
++await addUser(5, 'carol', 'user');
for (const id of [3, 4]) await db.ensureChannel(id);
const chanA = await db.getChannelByUserId(3), chanB = await db.getChannelByUserId(4);
const streamA = (await db.createStream({ user_id: 3, channel_id: chanA.id, title: 'A', protocol: 'webrtc' })).lastInsertRowid;
@@@ -144,10 +144,8 @@ async function check(name, fn) {
const mq = require('../server/media/media-queue');
const reqRow = await raw.prepare(`INSERT INTO media_requests (streamer_id, user_id, username, title, cost, currency, status, provider, input, canonical_url)
VALUES (3, 5, 'carol', 't', 1000, 'vibes', 'pending', 'video', 'https://x', 'https://x') RETURNING id`).run();
-- const before = (await db.getUserById(5)).openvibe_bucks_balance;
const refunded = await mq.refund(reqRow.lastInsertRowid);
assert.strictEqual(refunded, 0);
-- assert.strictEqual((await db.getUserById(5)).openvibe_bucks_balance, before);
});
await check('media playback position: only channel managers can move it', async () => {
@@@ -173,11 +171,9 @@ async function check(name, fn) {
await check('payments: a provider webhook reaching Live credits nothing and names OpenVibe.Billing', async () => {
// Billing receives every payment webhook since the cutover (its own tests cover underpaid and replayed sales).
await db.setSetting('ccbill_webhook_secret', 'sek');
-- const before = (await db.getUserById(5)).openvibe_bucks_balance;
const r = await call('POST', `/api/payments/webhook/ccbill?secret=sek&${new URLSearchParams({ 'X-order': 1, eventType: 'NewSaleSuccess', billedInitialPrice: '100.00' })}`, null, {});
assert.strictEqual(r.status, 410, r.text);
assert.match(r.json.webhook_url, /\/webhooks\/ccbill$/);
-- assert.strictEqual((await db.getUserById(5)).openvibe_bucks_balance, before);
});
await check('API tokens: scopes gate writes; money, staff and credential routes refuse tokens', () => {
ddiff --git a/test/billing-authority.test.js b/test/billing-authority.test.js
iindex 85ab09ce..9bfe6389 100644
---- a/test/billing-authority.test.js
++++ b/test/billing-authority.test.js
@@@ -2,9 +2,9 @@
/**
* OpenVibe.Billing is the ledger (since the cutover of 2026-10-17; ADR-012): every Live money action goes to
* OpenVibe.Billing with Live's service token (audience openvibe.billing), the one capability that
-- * route needs, a deterministic Idempotency-Key and canonical subjects — and Live's own money
-- * columns and tables (users.openvibe_bucks_*, transactions, payment_orders, subscriptions) are
-- * never written. A person with no subject cannot move money; Billing being down or slow fails
++ * route needs, a deterministic Idempotency-Key and canonical subjects. Live's own money columns
++ * and tables (users.openvibe_bucks_*, transactions, payment_orders, subscriptions) are gone since
++ * migration 0016. A person with no subject cannot move money; Billing being down or slow fails
* clearly; the provider and PowerChat webhooks on Live answer 410 with Billing's URL.
*
* Network and Billing are local stand-ins (test/billing-stub.js); sign-in is stubbed with an
@@@ -47,12 +47,12 @@ async function check(name, fn) {
await db.initDb();
const raw = db.getDb();
const addUser = async (id, username, role, extra = {}) => await raw.prepare(
-- `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner, openvibe_bucks_balance, openvibe_bucks_cashout_balance) OVERRIDING SYSTEM VALUE
-- VALUES (?, ?, ?, ?, 'x', ?, ?, ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0, extra.bucks || 0, extra.cashout || 0);
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0);
await addUser(1, 'owner', 'admin', { is_owner: 1 });
-- await addUser(2, 'ann', 'user', { bucks: 1000 }); // legacy columns carry values: they must never move
-- await addUser(3, 'bob', 'streamer', { cashout: 2000 });
-- await addUser(4, 'cat', 'user', { bucks: 5000 }); // no subject anywhere
++ await addUser(2, 'ann', 'user');
++ await addUser(3, 'bob', 'streamer');
++ await addUser(4, 'cat', 'user'); // no subject anywhere
await addUser(5, 'dan', 'user');
await addUser(6, 'eve', 'streamer'); // streamer with no subject
await addUser(7, 'admin2', 'admin'); // an admin who is not the owner
@@@ -64,13 +64,11 @@ async function check(name, fn) {
await db.setSetting('powerchat_client_id', 'pca_test');
await db.setSetting('powerchat_site_tip_username', 'sitepc');
++ // What Live kept of its own ledger: nothing (migration 0016).
const ledger = async () => ({
-- users: await raw.prepare('SELECT id, openvibe_bucks_balance AS b, openvibe_bucks_cashout_balance AS c FROM users ORDER BY id').all(),
-- transactions: (await raw.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,
-- payment_orders: (await raw.prepare('SELECT COUNT(*) AS n FROM payment_orders').get()).n,
-- subscriptions: await raw.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS u FROM subscriptions').get(),
++ tables: (await raw.prepare("SELECT table_name AS t FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('transactions', 'payment_orders', 'subscriptions') ORDER BY 1").all()).map((r) => r.t),
++ columns: (await raw.prepare("SELECT column_name AS c FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'users' AND column_name LIKE 'openvibe\\_%' ORDER BY 1").all()).map((r) => r.c),
});
-- const before = await ledger();
const auth = require('../server/auth/auth');
const signIn = async (req) => { const id = Number(req.headers['x-test-user'] || 0); const u = id ? await db.getUserById(id) : null; if (u) req.user = u; return u; };
@@@ -473,8 +471,8 @@ async function check(name, fn) {
assert.ok(u.json.users.every((x) => x.openvibe_bucks_balance === null));
});
-- await check('after everything: Live\'s money columns and tables are exactly as before', async () => {
-- assert.deepStrictEqual(await ledger(), before);
++ await check('after everything: Live has no money columns or tables of its own to write', async () => {
++ assert.deepStrictEqual(await ledger(), { tables: [], columns: [] });
});
server.close();
ddiff --git a/test/channel-tips.test.js b/test/channel-tips.test.js
iindex aed93d53..4c497401 100644
---- a/test/channel-tips.test.js
++++ b/test/channel-tips.test.js
@@@ -7,9 +7,7 @@
* node --import ./test/helpers/pg-preload.mjs test/channel-tips.test.js
*/
const assert = require('assert');
--const fs = require('fs');
const http = require('http');
--const path = require('path');
process.env.NODE_ENV = 'test';
const quiet = console.log;
@@@ -20,10 +18,10 @@ console.warn = () => {};
const db = require('../server/db/database');
await db.initDb();
const raw = db.getDb();
-- const addUser = async (id, username, bucks = 0) => await raw.prepare(
-- `INSERT INTO users (id, username, display_name, email, password_hash, role, openvibe_bucks_balance) OVERRIDING SYSTEM VALUE
-- VALUES (?, ?, ?, ?, 'x', 'user', ?)`).run(id, username, username[0].toUpperCase() + username.slice(1), `${username}@x`, bucks);
-- await addUser(2, 'ann', 2000);
++ const addUser = async (id, username) => await raw.prepare(
++ `INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', 'user')`).run(id, username, username[0].toUpperCase() + username.slice(1), `${username}@x`);
++ await addUser(2, 'ann');
await addUser(3, 'bob');
const streamId = Number((await db.run("INSERT INTO streams (user_id, title, protocol, is_live) VALUES (3, 'bob live', 'webrtc', 1) RETURNING id")).lastInsertRowid);
@@@ -85,18 +83,7 @@ console.warn = () => {};
assert.strictEqual(stats.supporters, 3, 'ann, pcfan and the supporter who hid the amount');
assert.strictEqual((await db.getHomeStatSeries('supporters', 30)).total, 3);
-- // The migration's backfill: completed donations in the ledger become tips once; failed ones and purchases do not.
-- const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0009_channel_tips.sql'), 'utf8');
-- const backfill = sql.slice(sql.indexOf('INSERT INTO channel_tips'));
-- const ok = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (2, 3, 70, 'donation', 'completed') RETURNING id")).lastInsertRowid);
-- const failed = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (2, 3, 80, 'donation', 'failed') RETURNING id")).lastInsertRowid);
-- const bought = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (NULL, 2, 500, 'purchase', 'completed') RETURNING id")).lastInsertRowid);
-- await raw.prepare(backfill).run();
-- await raw.prepare(backfill).run();
-- assert.deepStrictEqual(await tip(`live-txn:${ok}`), { to_user_id: 3, from_user_id: 2, username: null, stream_id: null, amount: 70, source: 'vibes' });
-- assert.strictEqual(await tip(`live-txn:${failed}`), undefined);
-- assert.strictEqual(await tip(`live-txn:${bought}`), undefined);
-- assert.strictEqual(await count(), 5, 'one copy of each completed donation');
++ // 0009's backfill copied the ledger's completed donations on 2026-10-17; the ledger itself went in 0016.
server.close();
quiet('channel tips: all checks passed');
ddiff --git a/test/money-freeze.test.js b/test/money-freeze.test.js
iindex 893df6e3..cdff9809 100644
---- a/test/money-freeze.test.js
++++ b/test/money-freeze.test.js
@@@ -34,10 +34,10 @@ async function check(name, fn) {
await db.initDb();
const raw = db.getDb();
const addUser = async (id, username, role, extra = {}) => await raw.prepare(
-- `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner, openvibe_bucks_balance, openvibe_bucks_cashout_balance) OVERRIDING SYSTEM VALUE
-- VALUES (?, ?, ?, ?, 'x', ?, ?, ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0, extra.bucks || 0, extra.cashout || 0);
++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
++ VALUES (?, ?, ?, ?, 'x', ?, ?)`).run(id, username, username, `${username}@x`, role, extra.is_owner ? 1 : 0);
await addUser(1, 'owner', 'admin', { is_owner: 1 });
-- await addUser(2, 'ann', 'user', { bucks: 2000 });
++ await addUser(2, 'ann', 'user');
await addUser(3, 'bob', 'streamer', { cashout: 1000 });
await addUser(4, 'admin2', 'admin');
await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', ?), (3, 'network', '103', ?)").run(SID_ANN, SID_BOB);
ddiff --git a/test/money-tables-gone.test.js b/test/money-tables-gone.test.js
nnew file mode 100644
iindex 00000000..d9abf55f
---- /dev/null
++++ b/test/money-tables-gone.test.js
@@@ -0,0 +1,42 @@
++'use strict';
++
++// OpenVibe.Billing is the only money ledger since the cutover of 2026-10-17. Nothing under server/ or scripts/ reads or
++// writes Live's old ledger (transactions, payment_orders, subscriptions) or the users money columns, and nothing
++// touches vpn_approvals since Live#303, so the contract migration that drops them breaks nothing. The answers that
++// still name a money column blank it (`x.col = null`) or omit it, for clients that read the field.
++
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++const TABLES = /\b(FROM|INTO|UPDATE|JOIN|TABLE)\s+(transactions|payment_orders|subscriptions|vpn_approvals)\b/i;
++const COLUMNS = /\b(openvibe_bucks_balance|openvibe_bucks_cashout_balance|openvibe_coins_balance)\b/;
++// The output-side shapes that may name a column: set to null, deleted, or listed among the fields an answer omits.
++const BLANKED = /(\.\s*(openvibe_bucks_balance|openvibe_bucks_cashout_balance|openvibe_coins_balance)\s*=\s*null\s*;|delete\s+\w+\.(openvibe_bucks_balance|openvibe_bucks_cashout_balance|openvibe_coins_balance)\b|^\s*(return omit\(|'openvibe_coins_balance', 'last_seen'))/;
++const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*|--)/.test(line);
++
++function walk(dir, out = []) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p, out); } else if (e.name.endsWith('.js')) out.push(p);
++ }
++ return out;
++}
++
++const offenders = [];
++for (const file of [...walk(path.join(ROOT, 'server')), ...walk(path.join(ROOT, 'scripts'))]) {
++ fs.readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
++ if (isCommentLine(line)) return;
++ if (TABLES.test(line) || (COLUMNS.test(line) && !BLANKED.test(line))) offenders.push(`${path.relative(ROOT, file)}:${i + 1}: ${line.trim().slice(0, 120)}`);
++ });
++}
++assert.deepStrictEqual(offenders, [], 'money is OpenVibe.Billing\'s (billing-client.js, billing-actions.js): Live keeps no ledger of its own');
++
++// The contract migration drops exactly these, after 0009's N-1 window.
++const drop = fs.readFileSync(path.join(ROOT, 'migrations', '0016_drop_old_money_ledger.sql'), 'utf8');
++assert.match(drop, /^-- phase: contract$/m);
++assert.match(drop, /^-- after: 0009$/m);
++assert.deepStrictEqual([...drop.matchAll(/^DROP TABLE IF EXISTS (\w+);$/gm)].map((m) => m[1]).sort(), ['payment_orders', 'subscriptions', 'transactions', 'vpn_approvals']);
++assert.deepStrictEqual([...drop.matchAll(/^ALTER TABLE users DROP COLUMN IF EXISTS (\w+);$/gm)].map((m) => m[1]).sort(), ['openvibe_bucks_balance', 'openvibe_bucks_cashout_balance', 'openvibe_coins_balance']);
++console.log('money tables: nothing under server/ or scripts/ reads them, and 0016 drops the four tables and three columns');
ddiff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
iindex 6dd1a5ed..e13185f4 100644
---- a/test/tips-delivery.test.js
++++ b/test/tips-delivery.test.js
@@@ -35,7 +35,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
const db = require('../server/db/database');
await db.initDb();
const d = db.getDb();
-- await d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) OVERRIDING SYSTEM VALUE VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
++ await d.prepare("INSERT INTO users (id, username, display_name, password_hash) OVERRIDING SYSTEM VALUE VALUES (501, 'alex', 'Alex', 'x')").run();
await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
// Chat persists and shows the line (Live keeps no chat table): stub the ingress call and record
@@@ -168,10 +168,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
const u = await post({ ...job, creator: { type: 'user', id: 'usr_01J0000000000000000000000Y' } }, { key: 'tint_2:chat_line' });
assert.strictEqual(u.status, 404);
-- // No Live balance moved.
-- const user = await d.prepare('SELECT openvibe_bucks_balance, openvibe_bucks_cashout_balance FROM users WHERE id = 501').get();
-- assert.deepStrictEqual(user, { openvibe_bucks_balance: 0, openvibe_bucks_cashout_balance: 0 });
-- assert.strictEqual((await d.prepare("SELECT COUNT(*) AS n FROM transactions").get()).n, 0);
++ // Live keeps no balance or ledger of its own to move (migration 0016): the tip is OpenVibe.Billing's.
++ assert.strictEqual((await d.prepare("SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'transactions'").get()).n, 0);
server.close();
process.stdout.write('tips-delivery: all passed\n');