openvibers/openvibe.live Commits
Billing is the only money mode: remove Live's old ledger code (#182)
Changes
@@@ -185,12 +185,9 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400# Voice/video calls are OpenVibe.Chat's (no setting): nginx sends /ws/call and the call routes there, and Live# creates/removes stream voice channels in Chat on go-live and stream end (server/streaming/calls-authority.js).--# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as--# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,--# recycling, subscriptions, balances and entitlements with its service token (audience--# openvibe.billing) and never writes its legacy money columns; the provider/PowerChat webhooks on--# Live answer 410. Unset = live. Cutover procedure: docs/live-cutover.md in OpenVibe.Billing.--#BILLING_AUTHORITY=billing++# OpenVibe.Billing is the money ledger (since 2026-10-17; ADR-012): Live calls it for checkouts, donations, cashouts,++# recycling, subscriptions, balances and entitlements with its service token (audience openvibe.billing). There is no++# other mode (BILLING_AUTHORITY is no longer read).OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600OV_BILLING_PUBLIC_URL=https://billing.openvibe.network@@@ -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 local `comments` table is READ-ONLY too; 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 are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); 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:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **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 local `comments` table is READ-ONLY too; 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 are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); 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.## Commands@@@ -69,7 +69,7 @@ The media subsystem lives in **OpenVibe.Media**:- Channels and streams: go-live, stream keys, managed streams, heartbeats, channel pages (`/@username`), offline screens, panels and goals.- The watch experience: the SPA in `public/`, the chat surface and its effects (TTS, sounds, emotes, overlays), anonymous chat identities.- Streamer tools: the dashboard, restreaming (via OpenRestream for managed ingest), song requests (watch party), AI viewers (the context Live builds; the runs are OpenVibe.AI's).--- Channel points, Vibes tipping flows (the ledger moves to OpenVibe.Billing when `BILLING_AUTHORITY=billing`), moderation of Live's own surfaces, the Arena (ARENA ALERT: the drama desk on streamer activity, the Lolcow Index and Clout Rankings, beefs from the mic; docs/arena.md) and after-show recaps. Live transcripts come from NVIDIA Parakeet TDT on the server (whisper for other languages).++- Channel points, Vibes tipping flows (OpenVibe.Billing keeps the ledger since 2026-10-17), moderation of Live's own surfaces, the Arena (ARENA ALERT: the drama desk on streamer activity, the Lolcow Index and Clout Rankings, beefs from the mic; docs/arena.md) and after-show recaps. Live transcripts come from NVIDIA Parakeet TDT on the server (whisper for other languages).- Live-local state: PostgreSQL `ov_live` (users' Live profiles, streams, channel state, AI state for Media-hosted recordings in `vod_ai_state` / `clip_ai_state`, request analytics).## Does not own@@@ -233,10 +233,10 @@ through the SDK inbox (`idempotency_receipts`):| `billing.transaction.settled`, `billing.entitlement.changed` | `/internal/billing-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --billing` || `tips.interaction.ready` | `/internal/tips-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --tips` |--Billing's events act only under `BILLING_AUTHORITY=billing` ([server/monetization/billing-events.js](../server/monetization/billing-events.js)):--"Vibes credited" to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's--PowerChat overlay when PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the--subscription notices Live's renewal sweeper sent in `live` mode (could not auto-renew, renewal failed, ended).++Billing's events ([server/monetization/billing-events.js](../server/monetization/billing-events.js)): "Vibes credited"++to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's PowerChat overlay when++PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the subscription notices (could not++auto-renew, renewal failed, ended).OpenVibe.Tips posts its own chat lines to OpenVibe.Chat, so a tip it settled (from openvibe.tips, or on a streamer's ownPowerChat through Billing) reaches Live only as `tips.interaction.ready` ([server/tips/tips-events.js](../server/tips/tips-events.js)):@@@ -13,7 +13,7 @@* GET /api/admin/bans - List all bans* GET /api/admin/vpn-queue - VPN approval queue* PUT /api/admin/vpn-queue/:id - Approve/deny VPN-- * GET /api/admin/money - Money authority (BILLING_AUTHORITY), freeze state, Billing health++ * GET /api/admin/money - Money authority (OpenVibe.Billing), freeze state, Billing health* POST /api/admin/money/freeze - Owner: freeze/unfreeze Live money writes { on, reason }* POST /api/admin/money/actions/:id/resolve - Owner: re-send a Billing action whose outcome is unknown* GET /api/admin/settings - Get all site settings@@@ -88,16 +88,8 @@ router.get('/stats', async (req, res) => {total: (await db.get('SELECT COUNT(*) as c FROM streams')).c,totalViewers: (await db.get('SELECT COALESCE(SUM(viewer_count), 0)::bigint as c FROM streams WHERE is_live = 1')).c,},-- // Under BILLING_AUTHORITY=billing these tables are frozen legacy copies: the numbers live-- // in OpenVibe.Billing (reconciliation report), so none are shown as current here.-- openvibeBucks: money.onBilling() ? {-- authority: 'billing', totalCirculating: null, totalTransactions: null, pendingCashouts: null, totalDonated: null,-- } : {-- totalCirculating: (await db.get('SELECT COALESCE(SUM(openvibe_bucks_balance), 0) as c FROM users')).c,-- totalTransactions: (await db.get('SELECT COUNT(*) as c FROM transactions')).c,-- pendingCashouts: (await db.get("SELECT COUNT(*) as c FROM transactions WHERE type = 'cashout' AND status = 'escrow'")).c,-- totalDonated: (await db.get("SELECT COALESCE(SUM(amount), 0)::bigint as c FROM transactions WHERE type = 'donation'")).c,-- },++ // OpenVibe.Billing keeps the money (its staff console and reconciliation report have the numbers).++ openvibeBucks: { authority: 'billing', totalCirculating: null, totalTransactions: null, pendingCashouts: null, totalDonated: null },vods: {total: vodCounts.total,public: vodCounts.public,@@@ -157,7 +149,7 @@ router.get('/users', async (req, res) => {const users = await db.all(sql, params);const total = (await db.get(countSql, countParams)).c;-- if (money.onBilling()) for (const u of users) u.openvibe_bucks_balance = null; // legacy column; Billing holds Vibes++ for (const u of users) u.openvibe_bucks_balance = null; // Live's old column; Billing holds Vibesres.json({ users, total });} catch (err) {@@@ -490,11 +482,9 @@ router.put('/vpn-queue/:id', async (req, res) => {// ── Money: who holds it, the freeze, and Billing health (roadmap Wave 8) ──────// Every admin can see the state; only the owner can freeze/unfreeze or resolve an action.router.get('/money', async (req, res) => {-- const out = { authority: money.authority(), billing_authority_env: process.env.BILLING_AUTHORITY || null, ...await money.freezeState() };-- if (money.onBilling()) {-- try { out.billing = await require('../monetization/billing-actions').adminStatus(); }-- catch (err) { out.billing = { error: err.message }; }-- }++ const out = { authority: money.authority(), ...await money.freezeState() };++ try { out.billing = await require('../monetization/billing-actions').adminStatus(); }++ catch (err) { out.billing = { error: err.message }; }res.json(out);});router.post('/money/freeze', permissions.requireOwner, async (req, res) => {@@@ -504,7 +494,6 @@ router.post('/money/freeze', permissions.requireOwner, async (req, res) => {res.json({ authority: money.authority(), ...state });});router.post('/money/actions/:id/resolve', permissions.requireOwner, async (req, res) => {-- if (!money.onBilling()) return res.status(409).json({ error: 'Only used when BILLING_AUTHORITY=billing' });const billingActions = require('../monetization/billing-actions');try {const r = await billingActions.resolveAction(parseInt(req.params.id, 10));@@@ -145,7 +145,6 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {const bump = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };const cols = await personColumns(d);const usersCols = await columnNames(d, 'users');-- const onBilling = (() => { try { return require('../monetization/money-authority').onBilling(); } catch { return false; } })();await d.tx(async () => {for (const uid of userIds) {for (const [t, list] of cols) {@@@ -164,8 +163,9 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {bump(erased, t, r.changes);}}-- // A donation's message is the person's words: cleared while Live keeps the ledger (read-only under Billing).-- if (!onBilling && cols.has('transactions')) {++ // 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.@@@ -644,11 +644,8 @@ async function sanitizeUser(user, publicOnly = false) {capabilities: await permissions.getCapabilities(user),};if (!publicOnly) {-- // Balances are real money (Vibes) and the user's own business, like the key. No email: the OpenVibe-- // account keeps it (openvibe.network/my) and Live does not read users.email (WS-B task 2).-- // Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never-- // shown as live); the balance comes from /api/funds/balance.-- if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;++ // No email: the OpenVibe account keeps it (openvibe.network/my) and Live does not read users.email (WS-B++ // task 2). The Vibes balance comes from /api/funds/balance (OpenVibe.Billing); Live's old column is never shown.// Kept as null for one release: the previous release's pages expect the field in /api/auth/me (test/n-1.test.js).// OpenCoins come from /api/coins/balance (the Network wallet); the frozen column is never read. Goes with the column.safe.openvibe_coins_balance = null;@@@ -134,8 +134,8 @@ contextRouter.get('/users/:id/follows', async (req, res) => {});// Sub-only chat (OpenVibe.Chat): does this person hold an ACTIVE subscription to this streamer's--// channel? Live's own rule (db.isActiveSubscriber: status active, current period not over; Billing's--// entitlement under BILLING_AUTHORITY=billing, read fresh here). Network VIP is not a subscription.++// channel? OpenVibe.Billing's entitlement, read fresh here (db.isActiveSubscriber answers from its short cache).++// Network VIP is not a subscription.// The person by Live user id or by Network subject. Chat caches the answer briefly and treats a// failed read as "no" for everyone but the room's moderators.contextRouter.get('/subscriber', async (req, res) => {@@@ -147,9 +147,7 @@ contextRouter.get('/subscriber', async (req, res) => {}if (!userId || !streamerId) return fail(res, 400, 'user_id (or subject) and streamer_id required');try {-- if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {-- await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});-- }++ await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});res.json({ subscriber: !!await db.isActiveSubscriber(userId, streamerId), user_id: userId, streamer_id: streamerId });} catch (err) {fail(res, 500, 'subscription lookup failed');@@@ -9,8 +9,6 @@ const path = require('path');const fs = require('fs');const crypto = require('crypto');const { createDb } = require('openvibe-sdk/db');--// BILLING_AUTHORITY tripwire: Live's money columns/tables are only written in `live` mode.--const { assertLiveLedger } = require('../monetization/money-authority');const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');@@@ -2191,166 +2189,13 @@ async function getFollowerIds(streamerId) {.map(r => r.follower_id);}--// ── Transaction helpers ──────────────────────────────────────----async function createTransaction({ from_user_id, to_user_id, stream_id, amount, type, status, message }) {-- assertLiveLedger('transactions insert');-- return await run(-- `INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status, message)-- VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,-- [from_user_id || null, to_user_id || null, stream_id || null, amount, type, status || 'completed', message || null]-- );--}----async function addVibes(userId, amount) {-- assertLiveLedger('openvibe_bucks_balance credit');-- return await run(`UPDATE users SET openvibe_bucks_balance = openvibe_bucks_balance + ? WHERE id = ?`,-- [amount, userId]);--}----async function deductVibes(userId, amount) {-- assertLiveLedger('openvibe_bucks_balance debit');-- const user = await getUserById(userId);-- if (!user || user.openvibe_bucks_balance < amount) return false;-- await run(`UPDATE users SET openvibe_bucks_balance = openvibe_bucks_balance - ? WHERE id = ?`,-- [amount, userId]);-- return true;--}----// Streamer cashout balance (received donations; the only cashout-able balance).--async function addVibesCashout(userId, amount) {-- assertLiveLedger('openvibe_bucks_cashout_balance credit');-- return await run(`UPDATE users SET openvibe_bucks_cashout_balance = openvibe_bucks_cashout_balance + ? WHERE id = ?`,-- [amount, userId]);--}--async function deductVibesCashout(userId, amount) {-- assertLiveLedger('openvibe_bucks_cashout_balance debit');-- const user = await getUserById(userId);-- if (!user || (user.openvibe_bucks_cashout_balance || 0) < amount) return false;-- await run(`UPDATE users SET openvibe_bucks_cashout_balance = openvibe_bucks_cashout_balance - ? WHERE id = ?`,-- [amount, userId]);-- return true;--}----// ── Payment orders (idempotent purchase tracking) ────────────----async function createPaymentOrder({ user_id, provider, provider_ref = null, kind = 'bucks', amount_cents = 0, currency = 'usd', bucks = 0, streamer_id = null, status = 'pending' }) {-- assertLiveLedger('payment_orders insert');-- const res = await run(-- `INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, streamer_id, status)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,-- [user_id, provider, provider_ref, kind, amount_cents, currency, bucks, streamer_id, status]-- );-- return await get('SELECT * FROM payment_orders WHERE id = ?', [res.lastInsertRowid]);--}----async function getPaymentOrderById(id) {-- return await get('SELECT * FROM payment_orders WHERE id = ?', [id]);--}----async function getPaymentOrderByRef(provider, ref) {-- if (!ref) return null;-- return await get('SELECT * FROM payment_orders WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);--}----// PowerChat checkouts still waiting for their donation.completed webhook — the--// reconciliation sweep's work list (recent only: an intent lives an hour, and an--// order nobody paid within days is an abandoned cart, not a missed webhook).--async function getPendingPowerchatOrders(days = 3) {-- return await all(`SELECT * FROM payment_orders-- WHERE provider = 'powerchat' AND status = 'pending'-- AND created_at >= datetime('now', ?)-- ORDER BY id ASC`, [`-${Math.max(1, Math.round(days))} days`]);--}----async function updatePaymentOrder(id, fields) {-- assertLiveLedger('payment_orders update');-- const allowed = new Set(['provider_ref', 'status', 'amount_cents', 'bucks', 'currency', 'streamer_id']);-- const entries = Object.entries(fields || {}).filter(([k]) => allowed.has(k));-- if (!entries.length) return await getPaymentOrderById(id);-- const sets = entries.map(([k]) => `${k} = ?`);-- sets.push('updated_at = ov_now()');-- await run(`UPDATE payment_orders SET ${sets.join(', ')} WHERE id = ?`, [...entries.map(([, v]) => v), id]);-- return await getPaymentOrderById(id);--}----// ── Subscription helpers ─────────────────────────────────────----async function upsertSubscription({ subscriber_id, streamer_id, tier = 1, provider = null, provider_ref = null, price_cents = 0, currency = 'usd', status = 'active', current_period_end = null, auto_renew = null }) {-- assertLiveLedger('subscriptions upsert');-- // Reuse an existing (subscriber,streamer) row if present, else insert.-- // auto_renew: null = leave as-is on update (0 on insert); 0/1 = set explicitly.-- const existing = await get('SELECT * FROM subscriptions WHERE subscriber_id = ? AND streamer_id = ?', [subscriber_id, streamer_id]);-- if (existing) {-- await run(`UPDATE subscriptions SET tier=?, provider=?, provider_ref=COALESCE(?, provider_ref), price_cents=?, currency=?,-- status=?, is_active=?, current_period_end=?, auto_renew=COALESCE(?, auto_renew),-- cancel_at_period_end=0, updated_at=ov_now() WHERE id=?`,-- [tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end,-- auto_renew === null ? null : (auto_renew ? 1 : 0), existing.id]);-- return await get('SELECT * FROM subscriptions WHERE id = ?', [existing.id]);-- }-- const res = await run(`INSERT INTO subscriptions (subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, is_active, current_period_end, auto_renew)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,-- [subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end, auto_renew ? 1 : 0]);-- return await get('SELECT * FROM subscriptions WHERE id = ?', [res.lastInsertRowid]);--}----// Active subs whose paid period has lapsed — the renewal sweeper's work list.--async function getSubscriptionsDueRenewal(limit = 50) {-- return await all(`SELECT * FROM subscriptions-- WHERE status = 'active' AND current_period_end IS NOT NULL-- AND datetime(current_period_end) <= ov_now()-- ORDER BY datetime(current_period_end) ASC LIMIT ?`, [limit]);--}----async function getSubscriptionByProviderRef(provider, ref) {-- if (!ref) return null;-- return await get('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);--}----async function getActiveSubscription(subscriberId, streamerId) {-- return await get(`SELECT * FROM subscriptions WHERE subscriber_id = ? AND streamer_id = ? AND status = 'active'-- AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now()) LIMIT 1`,-- [subscriberId, streamerId]);--}--++// ── Subscriptions (OpenVibe.Billing) ─────────────────────────async function isActiveSubscriber(subscriberId, streamerId) {if (!subscriberId || !streamerId) return false;-- // BILLING_AUTHORITY=billing: subscriber perks follow Billing's entitlements (short-lived cache;-- // legacy subscriptions rows are not consulted).-- if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {-- return require('../monetization/billing-actions').isSubscriberCached(subscriberId, streamerId);-- }-- return !!await getActiveSubscription(subscriberId, streamerId);--}----async function getSubscriptionsByStreamer(streamerId) {-- return await all(`SELECT s.*, u.username AS subscriber_username, u.display_name AS subscriber_display, u.avatar_url AS subscriber_avatar-- FROM subscriptions s LEFT JOIN users u ON s.subscriber_id = u.id-- WHERE s.streamer_id = ? AND s.status = 'active' ORDER BY s.started_at DESC`, [streamerId]);--}----async function getSubscriptionsBySubscriber(subscriberId) {-- return await all(`SELECT s.*, u.username AS streamer_username, u.display_name AS streamer_display, u.avatar_url AS streamer_avatar-- FROM subscriptions s LEFT JOIN users u ON s.streamer_id = u.id-- WHERE s.subscriber_id = ? ORDER BY s.started_at DESC`, [subscriberId]);++ // Subscriber perks follow OpenVibe.Billing's entitlements (a short-lived cache, billing-actions.js).++ return require('../monetization/billing-actions').isSubscriberCached(subscriberId, streamerId);}--async function getActiveSubscriberCount(streamerId) {-- const r = await get(`SELECT COUNT(*) AS n FROM subscriptions WHERE streamer_id = ? AND status = 'active'-- AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now())`, [streamerId]);-- return r ? r.n : 0;--}----async function setSubscriptionStatus(id, status, fields = {}) {-- assertLiveLedger('subscriptions status');-- const cpe = fields.current_period_end !== undefined ? fields.current_period_end : null;-- const cape = fields.cancel_at_period_end !== undefined ? (fields.cancel_at_period_end ? 1 : 0) : 0;-- await run(`UPDATE subscriptions SET status=?, is_active=?, cancel_at_period_end=?,-- current_period_end=COALESCE(?, current_period_end), updated_at=ov_now() WHERE id=?`,-- [status, status === 'active' ? 1 : 0, cape, cpe, id]);-- return await get('SELECT * FROM subscriptions WHERE id = ?', [id]);--}// ── VODs and clips ───────────────────────────────────────────// They live in OpenVibe.Media. Live asks for them through server/media-client.js and// server/media-proxy/lookups.js; Live's own AI state for them is vod_ai_state / clip_ai_state.@@@ -3705,10 +3550,7 @@ module.exports = {getPowerchatConnection, getPowerchatConnectionByUsername, getPowerchatConnectionByPcUserId,upsertPowerchatConnection, updatePowerchatTokens, setPowerchatConnectionError,deletePowerchatConnection, powerchatDeliveryIsNew, cleanupPowerchatDeliveries,-- createPaymentOrder, getPaymentOrderById, getPaymentOrderByRef, updatePaymentOrder, getPendingPowerchatOrders,-- upsertSubscription, getSubscriptionByProviderRef, getActiveSubscription, isActiveSubscriber,-- getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,-- getSubscriptionsDueRenewal,++ isActiveSubscriber,getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,// Profiles@@@ -3717,8 +3559,6 @@ module.exports = {getChannelPoints, addChannelPoints, deductChannelPoints, applyChannelPoints,// FollowsgetFollowerCount, isFollowing, getFollowerIds,-- // Transactions (Vibes)-- createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,// OpenCoinscreateCoinTransaction, getCoinTransactions,// Coin Rewards@@@ -1419,10 +1419,6 @@ async function start() {try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }// Read /chat/history back so a 202-accepted relay that moderation dropped is visible.try { require('./integrations/powerchat-platform').startChatVerifier(); } catch (e) { console.warn('[PowerChat] chat verifier not started:', e.message); }-- // Backfill checkout orders whose donation.completed webhook never arrived (/paid-messages).-- try { require('./integrations/powerchat-reconcile').startReconciler(); } catch (e) { console.warn('[PowerChat] reconciler not started:', e.message); }-- // Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).-- try { await require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }// 5-minute live-viewer samples → the home hero's 24h sparkline.try { await require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }try { require('./home/star-job').start(); } catch (e) { console.warn('[Home] star picker not started:', e.message); }@@@ -183,54 +183,6 @@ async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents =// ── Link builders ────────────────────────────────────────────// Buy Vibes: always the site account (the site must receive the money it mints// against). The checkout is PINNED to the package price and categorized "vibes".--async function buildPurchaseLink(order) {-- const site = await getSiteAccount();-- if (!site) return null;-- const bucks = Number(order.bucks) || 0;-- const link = await mintCheckoutLink(site.username, `pcorder:${order.id}`, {-- purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,-- itemName: bucks ? `${bucks.toLocaleString()} Vibes` : 'Vibes',-- });-- return { url: link.url, mode: 'site', minted: link.minted, expiresAt: link.expiresAt };--}----// Subscription: streamer-direct when possible, site fallback otherwise.--// The chosen mode (and the subscriber's auto-renew wish) is persisted on the order's--// provider_ref ("direct" | "site", ":renew" suffix) — webhook fulfillment reads it to--// decide the streamer share and the sub's auto_renew flag.--// route: 'direct' (the streamer's own PowerChat — they keep 100%, no fee) or 'site'--// (OpenVibe's PowerChat account — a small platform fee is added on top of the sub price--// and the streamer receives their normal share of the BASE price). 'auto' = direct when--// the streamer has PowerChat connected, else site.--// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".--async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {-- const suffix = autoRenew ? ':renew' : '';-- const streamer = await db.getUserById(streamerUserId);-- const who = streamer ? (streamer.display_name || streamer.username) : 'channel';-- const opts = {-- purpose: 'subscription', amountCents: order.amount_cents, returnTo: true,-- itemName: `${who} — 1 month subscription${autoRenew ? ' (auto-renew)' : ''}`,-- };-- const direct = route !== 'site' ? await _checkoutConn(streamerUserId) : null;-- if (direct) {-- await db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });-- const link = await mintCheckoutLink(direct.powerchat_username, `pcsub:${order.id}`, opts);-- return { url: link.url, mode: 'direct', minted: link.minted, expiresAt: link.expiresAt };-- }-- if (route === 'direct') return null; // caller asked for the streamer's page and they have none-- const site = await getSiteAccount();-- if (!site) return null;-- const fee = Math.max(0, Math.round(Number(feeCents) || 0));-- await db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });-- const link = await mintCheckoutLink(site.username, `pcsub:${order.id}`, opts);-- return { url: link.url, mode: 'site', feeCents: fee, minted: link.minted, expiresAt: link.expiresAt };--}----// ── Checkouts for OpenVibe.Billing intents (BILLING_AUTHORITY=billing) ───────--// Billing owns the order (a payment intent) and prices it; Live only builds the PowerChat link,--// because Live holds the PowerChat connections that can mint pinned checkouts. The ref is Billing's--// own checkout_ref ("pcorder:pi_…" / "pcsub:pi_…"): PowerChat echoes it to Billing's webhook, which--// settles it. Nothing here touches payment_orders.async function buildBillingPurchaseLink(intent) {const site = await getSiteAccount();if (!site || !intent || !intent.checkout_ref) return null;@@@ -295,125 +247,8 @@ async function buildDonateLink(streamerUserId, donorUserId, { goalId = null } =return null;}--// ── Webhook fulfillment ──────────────────────────────────────--// Called by the webhook processor for donation.completed events whose appExternalRef--// matches one of our prefixes. Returns true when the event was CONSUMED (the normal--// donation pipeline for the receiving account must then be skipped), false to fall--// through to plain donation handling.--// The checkout reference rides in the tip link and the buyer can edit it, so a reference only--// counts when the money landed where that kind of checkout sends it: purchases, site-routed--// donations and site-routed subscriptions on the SITE tips account, a direct subscription on--// the subscribed streamer's own account. Anything else (a streamer tipping their own PowerChat--// with `pcdon:<self>`, a viewer tipping themselves with `pcsub:<x>`) is an ordinary tip to the--// receiving account and falls through to normal handling.--async function handleAttributedDonation(receivingUserId, data, { viaSiteAccount = receivingUserId == null } = {}) {-- const ref = String(data.appExternalRef || '');-- const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));-- try {-- // Buy Vibes ----------------------------------------------------------- let m = ref.match(/^pcorder:(\d+)$/);-- if (m && !viaSiteAccount) {-- console.warn(`[PowerChat] ${ref} paid to a streamer account, not the site account — not a purchase`);-- return false;-- }-- if (m) {-- const order = await db.getPaymentOrderById(Number(m[1]));-- if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handle-- if (order.status === 'credited') return true; // at-least-once redelivery-- if (usdCents < 1) return true;-- // Credit what was ACTUALLY paid (intents pin the amount; the canonical-- // fallback link can't) — never the package price on faith.-- const pay = require('../monetization/payments');-- const bucks = await pay.bucksForUsd(usdCents / 100);-- await db.updatePaymentOrder(order.id, { amount_cents: usdCents, bucks, status: 'paid' });-- await pay.fulfillBucksOrder(await db.getPaymentOrderById(order.id));-- _notify(order.user_id, 'Vibes credited 🎉', `Your PowerChat tip of ${_usd(usdCents)} was confirmed — ${bucks.toLocaleString()} Vibes added to your balance.`);-- console.log(`[PowerChat] purchase order ${order.id}: ${_usd(usdCents)} → ${bucks} Vibes for user ${order.user_id}`);-- return true;-- }-- // Subscription -------------------------------------------------------- m = ref.match(/^pcsub:(\d+)$/);-- if (m) {-- const order = await db.getPaymentOrderById(Number(m[1]));-- if (!order || order.kind !== 'subscription' || !order.streamer_id) return true;-- if (order.status === 'credited') return true;-- // Underpaid (canonical-link fallback can't enforce amounts; minted intents-- // can): treat the tip as a plain-- // donation to the order's streamer rather than silently activating a-- // discounted sub. Direct mode = the receiving account IS the streamer, so-- // falling through does the right thing; site mode routes explicitly.-- const mode = String(order.provider_ref || 'site').split(':')[0];-- // Direct: the tip must land on the subscribed streamer's own account (which may also be-- // the site tips account). Site-routed: it must land on the site tips account.-- const paidWhereExpected = mode === 'direct' ? (receivingUserId != null && Number(receivingUserId) === Number(order.streamer_id)) : viaSiteAccount;-- if (!paidWhereExpected) {-- console.warn(`[PowerChat] ${ref} (${mode}) paid to the wrong account — not a subscription`);-- return false;-- }-- const autoRenew = /:renew$/.test(String(order.provider_ref || '')) ? 1 : 0;-- const feeCents = Number((String(order.provider_ref || '').match(/:fee=(\d+)/) || [])[1] || 0);-- if (usdCents + 1 < (order.amount_cents || 0)) {-- console.warn(`[PowerChat] sub order ${order.id} underpaid (${_usd(usdCents)} < ${_usd(order.amount_cents)}) — treating as a donation`);-- if (mode === 'site') { await _creditSiteRoutedDonation(order.streamer_id, order.user_id, data); return true; }-- return false;-- }-- const pay = require('../monetization/payments');-- await db.updatePaymentOrder(order.id, { status: 'paid' });-- // Streamer-direct: they already hold the cash — no cashout-Vibes share.-- // Site route: the streamer's share is computed on the sub price, not on the fee.-- await pay.fulfillSubscriptionOrder(await db.getPaymentOrderById(order.id), { creditShare: mode !== 'direct', autoRenew, shareBaseCents: Math.max(0, (order.amount_cents || 0) - feeCents) });-- _notify(order.user_id, 'Subscribed! ⭐', 'Your PowerChat tip was confirmed — your channel subscription is active.');-- console.log(`[PowerChat] sub order ${order.id} activated via PowerChat (${order.provider_ref}) for user ${order.user_id}`);-- return true;-- }-- // Site-routed donation ------------------------------------------------ m = ref.match(/^pcdon:(\d+):(\d+)$/);-- if (m && !viaSiteAccount) {-- console.warn(`[PowerChat] ${ref} paid to a streamer account, not the site account — not a site-routed donation`);-- return false;-- }-- if (m) {-- await _creditSiteRoutedDonation(Number(m[1]), Number(m[2]) || null, data);-- return true;-- }-- } catch (e) {-- console.warn('[PowerChat] attributed-donation fulfillment failed:', e.message);-- return true; // never fall through to crediting the receiving account on our refs-- }-- return false; // not one of our checkout refs (e.g. goal:12) — normal handling--}----// A tip that landed on the SITE account on behalf of a PowerChat-less streamer:--// full amount becomes the streamer's cashout-able Vibes (the site holds the cash),--// plus the normal on-site donation celebration/goal credit.--async function _creditSiteRoutedDonation(streamerUserId, donorUserId, data) {-- const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));-- if (!streamerUserId || usdCents < 1) return;-- if (!await db.getUserById(streamerUserId)) { console.warn(`[PowerChat] pcdon for unknown streamer ${streamerUserId}`); return; }-- await db.addVibesCashout(streamerUserId, usdCents); // 1¢ = 1 Vibe, same rate as on-site donations-- try {-- await db.createTransaction({-- from_user_id: donorUserId || null, to_user_id: streamerUserId, amount: usdCents,-- type: 'donation', status: 'completed',-- message: `PowerChat tip via site account (${_usd(usdCents)})`,-- });-- } catch { /* ledger best-effort */ }-- // Same celebration pipeline a direct-connected streamer gets from their own webhook.-- try { await require('./powerchat-webhook').creditDonationPipeline(streamerUserId, data, { fromUserId: donorUserId || null }); } catch (e) { console.warn('[PowerChat] pcdon pipeline:', e.message); }-- console.log(`[PowerChat] site-routed donation: ${_usd(usdCents)} → ${usdCents} cashout Vibes for streamer ${streamerUserId}`);--}----function _usd(cents) { return `$${(Math.round(cents) / 100).toFixed(2)}`; }--function _notify(userId, title, message) {-- try {-- const { pushNotification } = require('../utils/notify');-- pushNotification({ user_id: userId, type: 'PAYMENT', title, message, url: '/' });-- } catch { /* optional */ }--}--module.exports = {isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, checkoutIntentSupport, subscribeRoutes,_test: { resetIntentSupport: () => _intentSupport.clear() },-- buildPurchaseLink, buildSubscribeLink, buildDonateLink, buildBillingPurchaseLink, buildBillingSubscribeLink,-- handleAttributedDonation,++ buildDonateLink, buildBillingPurchaseLink, buildBillingSubscribeLink,};ddeleted file mode 100644@@@ -1,181 +0,0 @@--/**-- * powerchat-reconcile.js — backfill missed donation.completed webhooks from-- * GET /streamers/:username/paid-messages (scope paid_messages:read).-- *-- * The signed webhook is the ONLY authoritative confirmation and stays the primary-- * credit path. But webhooks can be missed for good: the receiver was down past-- * PowerChat's ~24h retry tail, ~20 consecutive failures tripped its circuit-- * breaker, or a 410 disabled the endpoint. Until now a viewer who paid during-- * such a window simply never got their Vibes / subscription.-- *-- * /paid-messages is the same confirmed money as a cursor-paginated feed, and every-- * row echoes `appExternalRef` — the pcorder:/pcsub: ref we minted onto the checkout-- * link — so paid-but-uncredited orders are found by exact match, never guessed.-- *-- * Safe to run repeatedly and concurrently with live webhooks: everything is credited-- * through the same handleAttributedDonation() the webhook uses, which is idempotent-- * on the order's status. Work-list driven: no pending order → no API call at all.-- *-- * Envelope: written against { data: { rows, nextCursor } } (the corrected upstream-- * shape) and defensive on older deployments that left nextCursor outside `data`.-- */--'use strict';----const db = require('../db/database');--const oauth = require('./powerchat-oauth');--const checkout = require('./powerchat-checkout');----const PAGE_LIMIT = 100;--const MAX_PAGES_PER_HOST = 5; // 500 newest confirmed tips per host per sweep--const LOOKBACK_DAYS = 3; // intents live 1h; older pending orders are abandoned carts--const GRACE_MS = 2 * 60 * 60 * 1000; // rows may be a little older than the order (clock skew, slow checkout)--const SWEEP_MS = 15 * 60 * 1000;--const FIRST_SWEEP_MS = 2 * 60 * 1000;----function _sqliteTs(s) {-- if (!s) return 0;-- const t = Date.parse(String(s).includes('T') ? String(s) : String(s).replace(' ', 'T') + 'Z');-- return Number.isFinite(t) ? t : 0;--}--async function _testAllowed() {-- try { const v = await db.getSetting('powerchat_allow_test_fulfillment'); return v === true || v === 'true' || v === 1 || v === '1'; }-- catch { return false; }--}--function _hasScope(conn, scope) {-- return !!(conn && conn.access_token && conn.refresh_token && (!conn.scope || String(conn.scope).split(/\s+/).includes(scope)));--}----// Which PowerChat account hosted this order's checkout (where the money landed), and--// which local user "receives" it for the fulfillment pipeline.--// pcsub direct → the streamer's own PowerChat; site → the site tips account.--async function _hostFor(order) {-- const mode = String(order.provider_ref || 'site').split(':')[0];-- if (order.kind === 'subscription' && mode === 'direct' && order.streamer_id) {-- const conn = await db.getPowerchatConnection(order.streamer_id);-- return conn && conn.powerchat_username ? { username: conn.powerchat_username, conn, receivingUserId: order.streamer_id, viaSiteAccount: false } : null;-- }-- const site = await checkout.getSiteAccount();-- if (!site) return null;-- const conn = await db.getPowerchatConnectionByUsername(site.username);-- return { username: site.username, conn: conn || null, receivingUserId: conn ? conn.user_id : null, viaSiteAccount: true };--}--function _refFor(order) {-- return order.kind === 'subscription' ? `pcsub:${order.id}` : `pcorder:${order.id}`;--}--function _page(json) {-- const d = json && json.data !== undefined ? json.data : json;-- const rows = Array.isArray(d) ? d : (d && Array.isArray(d.rows) ? d.rows : []);-- // Corrected shape nests nextCursor in data; older deployments put it beside it.-- const nextCursor = (d && !Array.isArray(d) && d.nextCursor != null) ? d.nextCursor-- : (json && json.nextCursor != null ? json.nextCursor : null);-- return { rows, nextCursor: nextCursor || null };--}----const _hostWarned = new Map();--function _warnHost(username, msg) {-- const now = Date.now();-- if ((_hostWarned.get(username) || 0) > now - 3600000) return;-- _hostWarned.set(username, now);-- console.warn(`[PowerChat] reconcile: ${msg} (@${username})`);--}----/** One sweep. Returns a summary; never throws. */--async function reconcileOnce() {-- const summary = { orders: 0, hosts: 0, pages: 0, rows: 0, credited: [], underpaid: [], skippedTest: 0, skippedHosts: 0, errors: 0 };-- // BILLING_AUTHORITY=billing: PowerChat money settles in OpenVibe.Billing; Live credits nothing.-- if (require('../monetization/money-authority').authority() !== 'live') return { ...summary, skipped: 'billing_authority' };-- let pending;-- try { pending = await db.getPendingPowerchatOrders(LOOKBACK_DAYS) || []; } catch (e) { summary.errors++; return summary; }-- summary.orders = pending.length;-- if (!pending.length) return summary;---- // Group the work list by host account: one paged read per host, not per order.-- const hosts = new Map(); // username → { host, orders: Map(ref → order), oldestAt }-- for (const order of pending) {-- const host = await _hostFor(order);-- if (!host) continue;-- const key = host.username.toLowerCase();-- let h = hosts.get(key);-- if (!h) { h = { host, orders: new Map(), oldestAt: Infinity }; hosts.set(key, h); }-- h.orders.set(_refFor(order), order);-- h.oldestAt = Math.min(h.oldestAt, _sqliteTs(order.created_at) || Date.now());-- }-- const allowTest = await _testAllowed();---- for (const [, h] of hosts) {-- const { host, orders } = h;-- if (!_hasScope(host.conn, 'paid_messages:read')) {-- summary.skippedHosts++;-- _warnHost(host.username, `${orders.size} pending checkout(s) can't be reconciled — no connected account with paid_messages:read for the host`);-- continue;-- }-- summary.hosts++;-- let cursor = null;-- for (let page = 0; page < MAX_PAGES_PER_HOST && orders.size; page++) {-- let pg;-- try {-- const json = await oauth.apiRequest(host.conn.user_id, {-- method: 'GET', path: '/paid-messages', username: host.username,-- query: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },-- });-- pg = _page(json);-- } catch (e) {-- summary.errors++;-- _warnHost(host.username, `paid-messages read failed: ${e.status || ''} ${e.message}`.trim());-- break;-- }-- summary.pages++;-- if (!pg.rows.length) break;-- let pastOldest = false;-- for (const row of pg.rows) {-- summary.rows++;-- if (_sqliteTs(row.occurredAt) && _sqliteTs(row.occurredAt) < h.oldestAt - GRACE_MS) pastOldest = true;-- const ref = row.appExternalRef ? String(row.appExternalRef) : '';-- if (!ref) continue; // someone else's tip — not ours to credit-- const order = orders.get(ref);-- if (!order) continue; // already credited, or not on this sweep's list-- if (row.source === 'developer_app') continue; // our own forwarded tip echo, never money in-- // /paid-messages rows carry NO isTest field, yet include source='manual_test'-- // rows (the no-payment-provider test tip path). PowerChat's own webhook-- // rule is isTest = event.isTest || source === 'manual_test' — mirror it, or-- // a test tip would be reconciled as real money on a production PowerChat.-- const isTest = !!row.isTest || row.source === 'manual_test';-- if (isTest && !allowTest) { summary.skippedTest++; continue; }-- // Same shape the webhook hands to the checkout module (money in integer cents).-- const data = {-- eventId: row.eventId, occurredAt: row.occurredAt, source: row.source,-- donorName: row.donorName, isAnonymous: !!row.isAnonymous, message: row.message,-- amountCents: row.amountCents, currency: row.currency,-- amountUsdCents: row.amountUsdCents != null ? row.amountUsdCents : row.amountCents,-- appExternalRef: ref, isTest, reconciled: true,-- };-- let consumed = false;-- try { consumed = await checkout.handleAttributedDonation(host.receivingUserId, data, { viaSiteAccount: host.viaSiteAccount }); }-- catch (e) { summary.errors++; console.warn(`[PowerChat] reconcile: fulfillment for ${ref} failed: ${e.message}`); }-- orders.delete(ref);-- let after = null;-- try { after = await db.getPaymentOrderById(order.id); } catch { /* */ }-- if (after && after.status === 'credited') {-- summary.credited.push({ ref, eventId: row.eventId, amountUsdCents: data.amountUsdCents, userId: order.user_id });-- console.log(`[PowerChat] reconcile: credited ${ref} from paid-messages (event ${row.eventId || '?'}, $${(Number(data.amountUsdCents || 0) / 100).toFixed(2)}) — its webhook never arrived`);-- } else if (!consumed || (after && after.status !== 'credited')) {-- summary.underpaid.push({ ref, eventId: row.eventId, amountUsdCents: data.amountUsdCents });-- }-- }-- if (!pg.nextCursor || pastOldest) break; // null cursor = last page; older rows can't match-- cursor = pg.nextCursor;-- }-- }-- return summary;--}----let _timer = null;--function startReconciler() {-- if (_timer) return;-- if (require('../monetization/money-authority').onBilling()) { console.log('[PowerChat] paid-messages reconciler not started: OpenVibe.Billing settles PowerChat money (BILLING_AUTHORITY=billing)'); return; }-- // Credits money from paginated upstream calls: a slow sweep must never be joined by the next one.-- _timer = require('../utils/jobs').every('powerchat-reconcile', SWEEP_MS, () => reconcileOnce(), { initialDelayMs: FIRST_SWEEP_MS, jitterMs: 30 * 1000 });-- console.log('[PowerChat] paid-messages reconciler started (backfills missed checkout webhooks every 15 min)');--}----module.exports = { reconcileOnce, startReconciler, _test: { page: _page } };@@@ -333,18 +333,6 @@ router.get('/donate-link', optionalAuth, async (req, res) => {}});--// ── POST /reconcile — admin: backfill missed checkout webhooks from /paid-messages ─--// The 15-minute sweeper runs this on its own; the route exists so an operator can--// run it on demand after an outage (or to see the summary) without waiting.--router.post('/reconcile', requireAdmin, async (req, res) => {-- try {-- const summary = await require('./powerchat-reconcile').reconcileOnce();-- res.json({ ok: true, ...summary });-- } catch (err) {-- res.status(500).json({ error: 'Reconcile failed' });-- }--});--// ── POST /test-tip — simulate a tip through the LOCAL pipeline (no PowerChat call) ─// The most useful test: confirms the streamer's alert sound + chat celebration render.router.post('/test-tip', requireAuth, async (req, res) => {@@@ -437,42 +425,12 @@ router.get('/authorize-url', requireAuth, (req, res) => {res.json({ url: `${String(config.baseUrl).replace(/\/+$/, '')}/api/powerchat/oauth/start` });});--// ── POST /webhook — signed event receiver ────────────────────────────────────--// No auth middleware: authenticity is the HMAC signature. Ack fast, process async.--router.post('/webhook', async (req, res) => {-- // BILLING_AUTHORITY=billing: PowerChat money settles in OpenVibe.Billing, and this receiver-- // must not process anything. 410 names where the webhook lives now (the PowerChat dashboard-- // is re-pointed by hand — docs/live-cutover.md in OpenVibe.Billing).-- if (require('../monetization/money-authority').onBilling()) {-- return res.status(410).json({-- error: 'PowerChat webhooks are received by OpenVibe.Billing now.',-- webhook_url: `${require('../monetization/billing-client').publicUrl()}/webhooks/powerchat`,-- });-- }-- try {-- const raw = req.rawBody || (req.body ? Buffer.from(JSON.stringify(req.body)) : Buffer.alloc(0));-- const check = await webhook.verifySignature(raw, req.headers);-- if (!check.ok) {-- console.warn('[PowerChat] webhook rejected:', check.reason);-- return res.status(401).json({ error: 'invalid signature' });-- }-- const deliveryId = req.headers['x-powerchat-delivery-id'] || null;-- const eventType = req.headers['x-powerchat-event-type'] || (req.body && req.body.type) || null;---- // Dedupe at-least-once deliveries.-- if (deliveryId && !await db.powerchatDeliveryIsNew(deliveryId, eventType)) {-- return res.status(200).json({ ok: true, deduped: true });-- }---- // Ack immediately; process off the response path.-- res.status(200).json({ ok: true });-- const envelope = req.body && typeof req.body === 'object' ? req.body : (() => { try { return JSON.parse(raw.toString('utf8')); } catch { return null; } })();-- db.getDb().afterCommit(() => {-- if (envelope) webhook.processEvent(envelope).catch((e) => console.warn('[PowerChat] webhook process error:', e.message));-- });-- } catch (err) {-- if (!res.headersSent) res.status(500).json({ error: 'webhook error' });-- }--});++// ── POST /webhook — received by OpenVibe.Billing ─────────────────────────────++// openvibe.live's nginx hands /api/powerchat/webhook to Billing (Live#172; the signature covers the timestamp and the++// body, not the URL). Should a delivery reach Live anyway, it is told where the webhook lives and nothing is processed.++router.post('/webhook', (req, res) => res.status(410).json({++ error: 'PowerChat webhooks are received by OpenVibe.Billing now.',++ webhook_url: `${require('../monetization/billing-client').publicUrl()}/webhooks/powerchat`,++}));module.exports = router;@@@ -1,63 +1,19 @@/**-- * powerchat-webhook.js — verify + process PowerChat webhook deliveries.++ * powerchat-webhook.js — the on-stream celebration of a PowerChat tip.*-- * Deliveries are signed and at-least-once. We:-- * 1. Verify the HMAC-SHA256 signature over "<timestamp>.<raw body>" (timing-safe).-- * 2. Reject timestamps older than 15 minutes.-- * 3. Dedupe on X-PowerChat-Delivery-Id.-- * 4. Ack 2xx fast; process async.-- *-- * `donation.completed` is mapped onto OpenVibe.Live's existing donation pipeline: it credits-- * the streamer's active donation goal and fires the same live chat event + alert sound +-- * goal-reached celebration that an on-site Vibes donation does.++ * PowerChat's webhook is received by OpenVibe.Billing since the cutover of 2026-10-17 (openvibe.live's nginx hands++ * /api/powerchat/webhook to it). What stays here is what a tip looks like on Live: the chat line, the alert sound, the++ * donation goal and the channel's tip history (creditDonationPipeline), run by billing-events.js when Billing announces++ * a PowerChat tip, and the streamer's own test tip (simulateDonation).*/'use strict';const crypto = require('crypto');const db = require('../db/database');const delivery = require('../chat/chat-delivery');--const powerchatOAuth = require('./powerchat-oauth');const { publicGoal, announceGoal } = require('../monetization/goal-announce');--const MAX_SKEW_MS = 15 * 60 * 1000;----// ── Signature verification ───────────────────────────────────────────────────--// Returns { ok, reason }. rawBody must be the exact bytes received (Buffer or string).--async function verifySignature(rawBody, headers) {-- const secret = (await powerchatOAuth.getConfig()).webhookSecret;-- if (!secret) return { ok: false, reason: 'webhook secret not configured' };---- const sigHeader = headers['x-powerchat-signature'] || '';-- const tsHeader = headers['x-powerchat-timestamp'] || '';-- if (!sigHeader || !tsHeader) return { ok: false, reason: 'missing signature/timestamp headers' };---- // Timestamp is unix ms; reject stale deliveries (replay protection).-- const ts = Number(tsHeader);-- if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > MAX_SKEW_MS) {-- return { ok: false, reason: 'timestamp outside allowed window' };-- }---- const bodyBuf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8');-- const expected = 'sha256=' + crypto.createHmac('sha256', secret)-- .update(String(tsHeader) + '.').update(bodyBuf).digest('hex');---- const a = Buffer.from(sigHeader);-- const bexp = Buffer.from(expected);-- if (a.length !== bexp.length) return { ok: false, reason: 'signature mismatch' };-- if (!crypto.timingSafeEqual(a, bexp)) return { ok: false, reason: 'signature mismatch' };-- return { ok: true };--}--// ── Helpers ──────────────────────────────────────────────────────────────────--// Resolve the OpenVibe.Live user who owns the PowerChat account this event is for.--async function _resolveStreamerUserId(streamer) {-- if (!streamer) return null;-- let conn = null;-- if (streamer.id) conn = await db.getPowerchatConnectionByPcUserId(String(streamer.id));-- if (!conn && streamer.username) conn = await db.getPowerchatConnectionByUsername(streamer.username);-- return conn ? conn.user_id : null;--}--// The viewer's chosen donation goal rides in app_purpose ("goal:12" — the purpose// key is the category vocabulary; see the checkout-attribution docs). Older links// encoded it in app_ref, so that stays as a fallback.@@@ -70,16 +26,6 @@ function _goalIdFromDonation(data) {return null;}--// data.isTest = NO money moved (free test method, no payout provider connected,--// dashboard test fires). Honoring those in production would grant Vibes/subs for--// tips that charged nothing — so they're dropped unless the owner explicitly flips--// powerchat_allow_test_fulfillment on (for dev/sandbox, where a PowerChat with no--// payout provider delivers EVERYTHING as isTest).--async function _testFulfillmentAllowed() {-- try { const v = await db.getSetting('powerchat_allow_test_fulfillment'); return v === true || v === 'true' || v === 1 || v === '1'; }-- catch { return false; }--}--// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────// A persisted chat line: one Chat ingress call (Chat persists and shows it; `mirror` also puts it// in global chat). Live keeps no chat table.@@@ -134,115 +80,6 @@ async function _handleDonation(userId, data, { fromUserId = null } = {}) {console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);}--// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).--async function _handleSubscription(userId, data) {-- const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);-- try {-- await _chatLine({-- key: data.eventId ? `powerchat:${data.eventId}` : undefined,-- line: {-- stream_id: null, channel_user_id: userId, user_id: null, username: name,-- message: `${name} subscribed (PowerChat)`, message_type: 'donation',-- metadata: { kind: 'donation', amount: 0, username: name, source: 'powerchat-sub' },-- },-- });-- } catch { /* */ }--}----// A lightweight chat notice for non-money events (follow / host / points redeem).--async function _handleNotice(userId, message, kind) {-- try {-- await _chatLine({-- line: {-- stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',-- message, message_type: 'system', metadata: { kind: kind || 'powerchat', source: 'powerchat' },-- },-- });-- } catch { /* */ }--}----// ── Entry point: process a verified, deduped envelope ────────────────────────--async function processEvent(envelope) {-- if (!envelope || !envelope.type) return;-- const userId = await _resolveStreamerUserId(envelope.streamer);-- const data = envelope.data || {};-- if (!userId) {-- // The site tips account is a bare PowerChat username (no OpenVibe connection-- // row) — its checkout-attributed donations must still be fulfilled. Anything-- // else arriving for it (e.g. someone tipping that account directly, outside a-- // checkout link) has no on-site home and is deliberately ignored.-- const siteName = String(await db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();-- const from = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();-- if (siteName && from === siteName) {-- if (envelope.type === 'donation.completed' && data.appExternalRef-- && (!data.isTest || await _testFulfillmentAllowed()) && data.source !== 'developer_app') {-- if (data.isTest) console.warn(`[PowerChat] fulfilling TEST checkout ${data.appExternalRef} (powerchat_allow_test_fulfillment is ON — no money moved)`);-- try { await require('./powerchat-checkout').handleAttributedDonation(null, data); } catch (e) { console.warn('[PowerChat] site-account fulfillment:', e.message); }-- }-- return;-- }-- console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer);-- return;-- }-- // data.isTest = no money moved (dashboard test fires, free test method, no payout-- // provider). Never credit those — unless the owner explicitly enabled test-- // fulfillment for dev/sandbox, where every delivery is isTest.-- if (data.isTest && !await _testFulfillmentAllowed()) {-- // Make the skip self-explanatory when it's an attributed checkout — this is the-- // #1 "my tip went through but nothing credited" question: the PowerChat side-- // confirmed a TEST checkout (no payout provider / free test method).-- const ref = data.appExternalRef ? ` (checkout ref ${data.appExternalRef} — no money moved; enable powerchat_allow_test_fulfillment in admin to credit test checkouts in dev)` : '';-- console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)${ref}`);-- return;-- }-- if (data.isTest) console.warn(`[PowerChat] processing TEST ${envelope.type} (powerchat_allow_test_fulfillment is ON — no money moved)`);-- // App-sourced events are OUR OWN pushes (tips/subs/follows/redemptions we forwarded-- // via the platform scopes) echoing back through the webhook — they already happened-- // locally, so replaying them would double-count/duplicate every one.-- if (data.source === 'developer_app') {-- console.log(`[PowerChat] skipping app-sourced ${envelope.type} echo (${data.eventId || 'no id'})`);-- return;-- }-- try {-- switch (envelope.type) {-- case 'donation.completed':-- // The authoritative money event. paid_message.created is a subset of this-- // (a tip that carried a message) — we DON'T credit on it to avoid double-count.-- // Checkout-attributed tips (Vibes purchases, PowerChat-paid subs, and-- // site-routed donations for PowerChat-less streamers) are fulfilled by the-- // checkout module; when it consumes the event, the receiving account's-- // normal donation pipeline must NOT also run.-- if (data.appExternalRef && /^(pcorder|pcsub|pcdon):/.test(String(data.appExternalRef))) {-- const siteName = String(await db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();-- const receiving = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();-- const viaSiteAccount = !!siteName && receiving === siteName;-- if (await require('./powerchat-checkout').handleAttributedDonation(userId, data, { viaSiteAccount })) break;-- }-- await _handleDonation(userId, data);-- break;-- case 'subscription.created':-- await _handleSubscription(userId, data);-- break;-- case 'follow.created':-- await _handleNotice(userId, `${String(data.followerName || 'Someone').slice(0, 80)} followed on PowerChat`, 'follow');-- break;-- case 'host.received':-- await _handleNotice(userId, `${String(data.hostChannel || 'Someone').slice(0, 80)} hosted with ${data.viewers || 0} viewers (PowerChat)`, 'host');-- break;-- case 'channel_points.redeemed':-- await _handleNotice(userId, `${String(data.redeemerName || 'Someone').slice(0, 80)} redeemed ${String(data.rewardName || 'a reward').slice(0, 80)} (PowerChat)`, 'points');-- break;-- // paid_message.created → display twin of donation.completed (ignored to avoid-- // double-counting). goal.updated/completed reflect PowerChat's own goals;-- // OpenVibe.Live runs its own goals credited by donations above, so we ignore them.-- default:-- break;-- }-- } catch (err) {-- console.warn(`[PowerChat] processEvent(${envelope.type}) failed:`, err.message);-- }--}--// Fire a fake tip through the LIVE pipeline so a streamer can verify their alert sound +// chat celebration render, WITHOUT touching PowerChat (no scope needed) and WITHOUT// permanently crediting a goal. Broadcasts the donation chat event + plays the alert sound;@@@ -285,8 +122,7 @@ async function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper',}module.exports = {-- verifySignature, processEvent, publicGoal, simulateDonation,-- // The full on-site donation celebration (chat event, alert sound, goal credit) for-- // a given streamer — reused by the checkout module for site-routed donations.++ publicGoal, simulateDonation,++ // The full on-site donation celebration (chat line, alert sound, goal credit, tip history) for a streamer.creditDonationPipeline: _handleDonation,};@@@ -138,33 +138,21 @@ class MediaQueue {return;}if (currency === 'vibes') {-- // Vibes are money (ADR-012): refused while money writes are frozen, in both modes.++ // Vibes are money (ADR-012): refused while money writes are frozen.await require('../monetization/money-authority').assertWritable();-- if (require('../monetization/money-authority').onBilling()) {-- // A paid interaction through OpenVibe.Billing (the requester's credit → the-- // streamer's payable); the returned charge is linked to the request for refunds.-- const billingActions = require('../monetization/billing-actions');-- try {-- return await billingActions.chargeMedia({ userId, streamerId, streamId, cost, label, requestId });-- } catch (e) {-- const live = billingActions.toLive(e, { insufficient: `Not enough Vibes — this costs ${cost}.` });-- const err = new Error(live ? live.body.error : (e?.message || `Could not charge ${cost} Vibes.`));-- // Billing never answered: the request row stays ('unknown') for the staff money-- // page, which resolves the billing action keyed live:media_charge:<request id>.-- if (e && e.kind === 'unknown') err.outcomeUnknown = true;-- throw err;-- }-- }-- // Vibes have no generic spend: paying a streamer for a request IS a donation-- // to them, so it goes through the same path and shows up in their totals.++ // A paid interaction through OpenVibe.Billing (the requester's credit → the++ // streamer's payable); the returned charge is linked to the request for refunds.++ const billingActions = require('../monetization/billing-actions');try {-- require('../monetization/vibes').donate(userId, streamerId, streamId || null, cost, label);++ return await billingActions.chargeMedia({ userId, streamerId, streamId, cost, label, requestId });} catch (e) {-- throw new Error(/insufficient/i.test(e?.message || '')-- ? `Not enough Vibes — this costs ${cost}.`-- : (e?.message || `Could not charge ${cost} Vibes.`));++ const live = billingActions.toLive(e, { insufficient: `Not enough Vibes — this costs ${cost}.` });++ const err = new Error(live ? live.body.error : (e?.message || `Could not charge ${cost} Vibes.`));++ // Billing never answered: the request row stays ('unknown') for the staff money++ // page, which resolves the billing action keyed live:media_charge:<request id>.++ if (e && e.kind === 'unknown') err.outcomeUnknown = true;++ throw err;}-- return;}// opencoins — network walletconst wallet = require('../monetization/wallet-client');@@@ -589,24 +577,11 @@ class MediaQueue {console.warn(`[MediaQueue] refund of request ${request.id} deferred: money writes are frozen`);return 0;}-- if (money.onBilling()) {-- return require('../monetization/billing-actions').refundMedia(request).then(async (n) => {-- if (n > 0) await db.updateMediaRequest(requestId, { refunded: 1 });-- return n;-- });-- }-- // The charge was booked as a donation to the streamer, so unwind both sides — atomically,-- // and only if the streamer still holds the money. Crediting the requester after a failed-- // deduction minted Vibes: pay, move the balance out (recycle or cash out), then refund.-- const unwound = await db.getDb().tx(async () => {-- if (!await db.deductVibesCashout(request.streamer_id, amount)) return false;-- await db.addVibes(request.user_id, amount);-- return true;++ // Billing reverses the charge linked to the request (and only while the streamer still holds it).++ return require('../monetization/billing-actions').refundMedia(request).then(async (n) => {++ if (n > 0) await db.updateMediaRequest(requestId, { refunded: 1 });++ return n;});-- if (!unwound) {-- console.warn(`[MediaQueue] refund of request ${request.id} refused: streamer ${request.streamer_id} no longer holds ${amount} Vibes`);-- return 0;-- }} else {// Idempotent per request id, so a double-refund is a no-op server-side.require('../monetization/wallet-client')@@@ -189,10 +189,8 @@ router.post('/quote', optionalAuth, async (req, res) => {let balance = null;if (req.user && currency === 'points') balance = await db.getChannelPoints(req.user.id, streamerId);else if (req.user && currency === 'vibes') {-- if (require('../monetization/money-authority').onBilling()) {-- // Billing holds Vibes; unknown (null) when it cannot answer — never the legacy column.-- try { balance = (await require('../monetization/billing-actions').balance(req.user.id, req.headers)).balance; } catch { balance = null; }-- } else balance = (await db.getUserById(req.user.id))?.openvibe_bucks_balance ?? null;++ // Billing holds Vibes; unknown (null) when it cannot answer.++ try { balance = (await require('../monetization/billing-actions').balance(req.user.id, req.headers)).balance; } catch { balance = null; }}res.json({@@@ -1,9 +1,8 @@'use strict';/**-- * Live's money actions when BILLING_AUTHORITY=billing (roadmap Wave 8, ADR-012).++ * Live's money actions, all on OpenVibe.Billing, the money ledger since the cutover of 2026-10-17 (ADR-012).*-- * Each function is the Billing-backed twin of what vibes.js / payments-routes.js do against Live's-- * own columns, returning the SAME response shapes so the routes and the SPA do not change:++ * Each function answers in the response shapes Live's routes and the SPA had before the cutover:** checkout (Buy Vibes) → POST /intents {kind: purchase} billing.intent.create* PayPal return → POST /intents/:id/capture billing.intent.create@@@ -1,8 +1,7 @@'use strict';/**-- * OpenVibe.Events → Live: what OpenVibe.Billing settled, told to the people it concerns, once Billing is the ledger-- * (BILLING_AUTHORITY=billing). In `live` mode Live's own checkout, webhook and renewal sweeper send all of this, so-- * every event is acknowledged and left alone. Closes items 2 and 5 of Billing's cutover list (docs/live-cutover.md).++ * OpenVibe.Events → Live: what OpenVibe.Billing (the money ledger) settled, told to the people it concerns: the++ * notices and celebrations Live's own checkout, webhook and renewal sweeper sent before the cutover of 2026-10-17.** POST /internal/billing-events Live's subscription to billing.transaction.settled and billing.entitlement.changed* (scripts/subscribe-media-events.js --billing), signed with LIVE_EVENTS_SECRET@@@ -21,7 +20,6 @@* transaction never notifies.*/const db = require('../db/database');--const money = require('./money-authority');const CONSUMER = 'live-billing-notices';const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -115,7 +113,6 @@ async function apply(ev) {if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';if (ev.source !== 'billing') return 'ignored:source';if (ev.event_type !== 'billing.transaction.settled' && ev.event_type !== 'billing.entitlement.changed') return 'ignored:type';-- if (!money.onBilling()) return 'ignored:live-ledger';let after = null;const out = await ensureInbox().once(CONSUMER, `billing:${ev.event_id}`, async () => { after = await plan(ev); return after ? 'notify' : 'nothing'; });if (out.duplicate) return 'duplicate';ddeleted file mode 100644@@@ -1,190 +0,0 @@--/**-- * OpenVibe.Live — Community Funds (legacy Vibes engine variant)-- *-- * Kept for the community-funds flows (donations, goals, escrow cashout with-- * admin approval). The active tipping currency engine is vibes.js — this module-- * is not currently mounted; see monetization/routes.js for the live routes.-- */--const db = require('../db/database');--const config = require('../config');--const { assertLiveLedger } = require('./money-authority');----class CommunityFunds {-- /**-- * Purchase Vibes-- * @param {number} userId-- * @param {number} amount - Number of Vibes to purchase-- * @param {string} paypalTxId - PayPal transaction ID-- */-- async purchase(userId, amount, paypalTxId) {-- assertLiveLedger('transactions insert');-- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)-- VALUES (NULL, ?, ?, 'purchase', 'completed', ?) RETURNING id`, [userId, amount, `Purchased ${amount} Vibes`]);---- // Update PayPal reference-- if (paypalTxId) {-- await db.run('UPDATE transactions SET paypal_transaction_id = ? WHERE id = ?',-- [paypalTxId, tx.lastInsertRowid]);-- }---- await db.addVibes(userId, amount);-- return tx;-- }---- /**-- * Donate Vibes to a streamer-- * @param {number} fromUserId - Donor-- * @param {number} toUserId - Streamer-- * @param {number} streamId - Current stream-- * @param {number} amount - Vibes to donate-- * @param {string} message - Donation message-- */-- async donate(fromUserId, toUserId, streamId, amount, message) {-- if (amount <= 0) throw new Error('Amount must be positive');---- // Deduct from donor-- if (!await db.deductVibes(fromUserId, amount)) {-- throw new Error('Insufficient Vibes');-- }---- // Credit streamer (held in their balance)-- await db.addVibes(toUserId, amount);---- // Record transaction-- await db.createTransaction({-- from_user_id: fromUserId,-- to_user_id: toUserId,-- stream_id: streamId,-- amount,-- type: 'donation',-- status: 'completed',-- message: message || null,-- });---- // Update donation goals-- await this.updateGoals(toUserId, amount);---- return { success: true, amount };-- }---- /**-- * Update active donation goals for a user-- */-- async updateGoals(userId, amount) {-- const goals = await db.all(-- 'SELECT * FROM donation_goals WHERE user_id = ? AND is_active = 1 ORDER BY created_at',-- [userId]-- );---- for (const goal of goals) {-- const newAmount = Math.min(goal.current_amount + amount, goal.target_amount);-- await db.run('UPDATE donation_goals SET current_amount = ? WHERE id = ?',-- [newAmount, goal.id]);---- if (newAmount >= goal.target_amount) {-- await db.run('UPDATE donation_goals SET is_active = 0 WHERE id = ?', [goal.id]);-- }-- }-- }---- /**-- * Request cashout (goes to escrow for admin review)-- */-- async requestCashout(userId, amount, paypalEmail) {-- assertLiveLedger('transactions insert');-- if (amount < config.openvibeBucks.minCashoutBucks) {-- throw new Error(`Minimum cashout is ${config.openvibeBucks.minCashoutBucks.toLocaleString()} Vibes`);-- }---- if (!await db.deductVibes(userId, amount)) {-- throw new Error('Insufficient Vibes');-- }---- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)-- VALUES (?, NULL, ?, 'cashout', 'escrow', ?) RETURNING id`, [userId, amount, `Cashout to PayPal: ${paypalEmail}`]);---- return {-- transaction_id: tx.lastInsertRowid,-- amount,-- usd_value: (amount / 100).toFixed(2),-- status: 'escrow',-- hold_days: config.openvibeBucks.escrowDays,-- };-- }---- /**-- * Admin: Approve a cashout (release from escrow)-- */-- async approveCashout(transactionId) {-- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',-- [transactionId, 'escrow']);-- if (!tx) throw new Error('Transaction not found or not in escrow');---- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['completed', transactionId]);-- return tx;-- }---- /**-- * Admin: Deny a cashout (refund to user)-- */-- async denyCashout(transactionId, reason) {-- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',-- [transactionId, 'escrow']);-- if (!tx) throw new Error('Transaction not found or not in escrow');---- // Refund the amount-- await db.addVibes(tx.from_user_id, tx.amount);-- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['refunded', transactionId]);---- return tx;-- }---- /**-- * Get user's transaction history-- */-- async getHistory(userId, limit = 50) {-- return await db.all(`-- SELECT * FROM transactions-- WHERE from_user_id = ? OR to_user_id = ?-- ORDER BY created_at DESC LIMIT ?-- `, [userId, userId, limit]);-- }---- /**-- * Get donation leaderboard for a stream-- */-- async getLeaderboard(streamId, limit = 10) {-- return await db.all(`-- SELECT from_user_id, u.username, u.display_name, u.avatar_url,-- SUM(amount)::bigint as total_donated-- FROM transactions t-- JOIN users u ON t.from_user_id = u.id-- WHERE t.stream_id = ? AND t.type = 'donation' AND t.status = 'completed'-- GROUP BY from_user_id, u.id-- ORDER BY total_donated DESC-- LIMIT ?-- `, [streamId, limit]);-- }---- /**-- * Get active donation goals for a user-- */-- async getGoals(userId) {-- return await db.all(-- 'SELECT * FROM donation_goals WHERE user_id = ? AND is_active = 1 ORDER BY created_at',-- [userId]-- );-- }---- /**-- * Create a donation goal-- */-- async createGoal(userId, title, targetAmount) {-- return await db.run(-- 'INSERT INTO donation_goals (user_id, title, target_amount) VALUES (?, ?, ?)',-- [userId, title, targetAmount]-- );-- }--}----module.exports = new CommunityFunds();@@@ -1,55 +1,21 @@'use strict';/**-- * Who holds the money, and whether it may move (roadmap Wave 8, ADR-012).++ * Who holds the money, and whether it may move (ADR-012).*-- * BILLING_AUTHORITY is read here and nowhere else:-- * live (default) Live's own columns and tables, exactly as before the Billing cutover.-- * billing OpenVibe.Billing is the ledger. Live asks it for every money action (billing-client.js,-- * billing-actions.js) and never writes users.openvibe_bucks_balance /-- * openvibe_bucks_cashout_balance, transactions, payment_orders or subscriptions — those-- * become legacy, read-only (the database-level tripwire below enforces it).-- * anything else is a misconfiguration: every Live money write is refused, never guessed.++ * OpenVibe.Billing is the money ledger since the cutover of 2026-10-17: Live asks it for every money action++ * (billing-client.js, billing-actions.js) and keeps no balance, order or subscription of its own. BILLING_AUTHORITY is++ * no longer read: there is no other mode to switch back to.*-- * money_writes_frozen (site setting, owner-only, both modes) is runbook step 3 of the cutover: it-- * refuses every money action a person or an operator starts on Live — checkouts, donations and-- * tips, Vibes-paid media requests and their refunds, cashout requests/approvals/denials, recycling,-- * subscribing, cancelling, and the renewal sweep. Reads keep working. Provider confirmations of-- * checkouts that were started BEFORE the freeze (PowerChat/Stripe/PayPal/CCBill/crypto webhooks,-- * the PayPal return, the PowerChat paid-messages reconciler) still settle in `live` mode: that-- * money has already been taken by the provider, and refusing it would drop a real payment. The-- * runbook waits for them before the final snapshot.++ * money_writes_frozen (site setting, owner-only) refuses every money action a person or an operator starts on Live:++ * checkouts, donations and tips, Vibes-paid media requests and their refunds, cashout requests, recycling, subscribing++ * and cancelling. Reads keep working. Billing has its own freeze for its side.*/--const MODES = ['live', 'billing'];----/** 'live' | 'billing' | 'invalid' — read on every call so tests and the admin page see the truth. */--function authority() {-- const raw = process.env.BILLING_AUTHORITY;-- if (raw === undefined || String(raw).trim() === '') return 'live';-- const v = String(raw).trim().toLowerCase();-- return MODES.includes(v) ? v : 'invalid';--}--const onBilling = () => authority() === 'billing';----class LedgerReadOnlyError extends Error {-- constructor(what, mode) {-- super(mode === 'billing'-- ? `Live's money columns are read-only (BILLING_AUTHORITY=billing): ${what} must go through OpenVibe.Billing`-- : `BILLING_AUTHORITY=${JSON.stringify(process.env.BILLING_AUTHORITY)} is not 'live' or 'billing': ${what} refused`);-- this.name = 'LedgerReadOnlyError';-- this.status = 503;-- this.code = 'ledger_read_only';-- }--}----/** Tripwire for database.js: a Live money column/table write outside `live` mode throws. */--function assertLiveLedger(what) {-- const mode = authority();-- if (mode !== 'live') throw new LedgerReadOnlyError(what, mode);--}++/** Always 'billing' (the admin page and the client config still show it). */++const authority = () => 'billing';// ── Freeze (money_writes_frozen) ─────────────────────────────--const db = () => require('../db/database'); // lazy: database.js requires this module++const db = () => require('../db/database'); // lazy: no load cycle with database.jsasync function isFrozen() {try {@@@ -81,10 +47,6 @@ async function setFrozen(on, { reason = null, by = null } = {}) {* `{ error }` bodies plus a stable `code`.*/async function writeRefusal() {-- const mode = authority();-- if (mode === 'invalid') {-- return { status: 503, body: { error: 'Payments are unavailable: the server\'s billing configuration is invalid.', code: 'billing_misconfigured' } };-- }if (await isFrozen()) {const st = await freezeState();return { status: 503, body: { error: 'Payments, donations and cashouts are paused for maintenance. Nothing was charged — please try again later.', code: 'money_writes_frozen', reason: st.reason || undefined } };@@@ -110,4 +72,4 @@ async function assertWritable() {}}--module.exports = { authority, onBilling, assertLiveLedger, LedgerReadOnlyError, isFrozen, freezeState, setFrozen, writeRefusal, guardWrite, assertWritable, MODES };++module.exports = { authority, isFrozen, freezeState, setFrozen, writeRefusal, guardWrite, assertWritable };@@@ -1,36 +1,23 @@/*** OpenVibe.Live — Payments & Subscriptions API (mounted at /api/payments)*-- * - Buy Vibes via PayPal / Stripe / CCBill / crypto (hosted checkout).-- * - Subscribe to a channel via Stripe (recurring) or Vibes (30 days).-- * - Provider webhooks credit bucks / activate subs idempotently.++ * OpenVibe.Billing is the money ledger (since the cutover of 2026-10-17). Every action here asks it++ * (billing-actions.js): buying Vibes (PayPal / Stripe / CCBill / crypto / PowerChat checkouts),++ * subscribing to a channel (tier 1, at the creator's price), cancelling and reading subscriptions.++ * Live keeps no money record. money_writes_frozen (money-authority.js) still refuses new money++ * actions. The old provider webhooks answer 410 with Billing's address: providers deliver there.*/const express = require('express');const db = require('../db/database');--const config = require('../config');const { requireAuth, optionalAuth } = require('../auth/auth');const pay = require('./payments');const openvibeBucks = require('./vibes');--// BILLING_AUTHORITY=billing: checkouts, subscriptions and entitlements go to OpenVibe.Billing and--// the provider webhooks below answer 410 (they land in Billing). money_writes_frozen refuses new--// money actions in both modes; provider confirmations of checkouts started before a freeze still--// settle in `live` mode (see money-authority.js).const money = require('./money-authority');const billingActions = require('./billing-actions');const billingClient = require('./billing-client');--const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill', crypto: 'nowpayments' };--function webhookMovedToBilling(provider) {-- return (req, res, next) => {-- if (!money.onBilling()) return next();-- return res.status(410).json({-- error: 'Payment webhooks are received by OpenVibe.Billing now.',-- webhook_url: `${billingClient.publicUrl()}/webhooks/${BILLING_WEBHOOK[provider]}`,-- });-- };--}++const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill', crypto: 'nowpayments' };const router = express.Router();--function base() { return config.baseUrl.replace(/\/+$/, ''); }// ── Public config (which providers are live + pricing) ───────router.get('/config', async (req, res) => {@@@ -43,93 +30,27 @@ router.post('/bucks/checkout', requireAuth, money.guardWrite, async (req, res) =// PowerChat purchases run on their own enablement (powerchat_enabled + site tips// account) — the master payments switch only gates the card/PayPal/crypto rails.if (!await pay.isEnabled() && provider !== 'powerchat') return res.status(403).json({ error: 'Payments are not enabled' });-- // The client picks a Vibes amount (bit-style); the USD price is derived from the-- // volume-discount tiers so bigger buys are cheaper per buck and the platform keeps the spread.let bucks;try { bucks = openvibeBucks.normalizeBucks(req.body.bucks); } catch (e) { return res.status(400).json({ error: e.message }); }const minBucks = await pay._num('bucks_min_purchase_bucks', 100);if (bucks < minBucks) return res.status(400).json({ error: `Minimum purchase is ${minBucks.toLocaleString()} Vibes` });if (bucks > 1_000_000) return res.status(400).json({ error: 'Amount too large' });---- if (money.onBilling()) {-- if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });-- try {-- const r = await billingActions.checkout(req, { provider, bucks });-- return res.status(r.status).json(r.body);-- } catch (err) {-- if (billingActions.sendError(res, err)) return;-- console.error('[Payments] billing checkout error:', err.message);-- return res.status(502).json({ error: 'Payment provider error. Try again.' });-- }-- }---- const amountUsd = openvibeBucks.priceUsdForBucks(bucks);-- const order = await db.createPaymentOrder({-- user_id: req.user.id, provider, kind: 'bucks',-- amount_cents: Math.round(amountUsd * 100), bucks,-- });-- const name = `${bucks.toLocaleString()} Vibes`;++ if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });try {-- if (provider === 'stripe') {-- const r = await pay.stripeCheckout({-- order, name, amountCents: order.amount_cents, kind: 'bucks',-- successUrl: `${base()}/?purchase=success`, cancelUrl: `${base()}/?purchase=cancel`,-- });-- return res.json({ url: r.url });-- }-- if (provider === 'paypal') {-- const r = await pay.paypalCreateOrder({ order, amountUsd, description: name });-- return res.json({ url: r.url });-- }-- if (provider === 'ccbill') {-- return res.json({ url: await pay.ccbillUrl({ order, amountUsd }) });-- }-- if (provider === 'crypto') {-- const r = await pay.cryptoCreateInvoice({ order, amountUsd, description: name });-- return res.json({ url: r.url });-- }-- if (provider === 'powerchat') {-- // Tip the site's PowerChat account; the checkout is PINNED to the package-- // price (a server-minted intent locks the tip page's amount) and the-- // donation.completed webhook credits the buyer once it confirms.-- const link = await require('../integrations/powerchat-checkout').buildPurchaseLink(order);-- if (!link) { await db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }-- return res.json({-- url: link.url, powerchat: true, amountUsd, pinned: !!link.minted, expires_at: link.expiresAt || null,-- note: `Complete the $${amountUsd.toFixed(2)} tip on PowerChat — your Vibes are credited automatically once it confirms.`,-- });-- }-- return res.status(400).json({ error: 'Unknown payment provider' });++ const r = await billingActions.checkout(req, { provider, bucks });++ return res.status(r.status).json(r.body);} catch (err) {-- console.error('[Payments] checkout error:', err.message);-- await db.updatePaymentOrder(order.id, { status: 'failed' });++ if (billingActions.sendError(res, err)) return;++ console.error('[Payments] billing checkout error:', err.message);return res.status(502).json({ error: 'Payment provider error. Try again.' });}});--// PayPal buyer returns here after approving — capture + fulfill.++// PayPal buyer returns here after approving: Billing's PayPal intents come back with ?token=<PayPal order id>,++// and Billing captures and settles.router.get('/paypal/return', async (req, res) => {-- if (money.onBilling()) {-- // Billing's PayPal intents come back with ?token=<PayPal order id>; Billing captures + settles.-- try { return res.redirect(await billingActions.paypalReturn(req.query.token) ? '/?purchase=success' : '/?purchase=error'); }-- catch (err) { console.error('[Payments] billing paypal return:', err.detail || err.message); return res.redirect('/?purchase=error'); }-- }-- try {-- const order = await db.getPaymentOrderById(parseInt(req.query.order, 10));-- if (!order || order.provider !== 'paypal' || !order.provider_ref) return res.redirect('/?purchase=error');-- const cap = await pay.paypalCaptureOrder(order.provider_ref);-- const captured = cap.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value;-- const ok = cap.status === 'COMPLETED' && pay.paidAmountCovers(order, captured) !== false;-- if (ok) {-- if (order.kind === 'subscription') await pay.fulfillSubscriptionOrder(order);-- else await pay.fulfillBucksOrder(order);-- return res.redirect('/?purchase=success');-- }-- return res.redirect('/?purchase=error');-- } catch (err) {-- console.error('[Payments] paypal return:', err.message);-- return res.redirect('/?purchase=error');-- }++ try { return res.redirect(await billingActions.paypalReturn(req.query.token) ? '/?purchase=success' : '/?purchase=error'); }++ catch (err) { console.error('[Payments] billing paypal return:', err.detail || err.message); return res.redirect('/?purchase=error'); }});// ── Subscriptions ────────────────────────────────────────────@@@ -142,101 +63,23 @@ router.post('/subscribe', requireAuth, money.guardWrite, async (req, res) => {const streamer = await db.getUserByUsername(String(req.body.streamer || ''));if (!streamer) return res.status(404).json({ error: 'Streamer not found' });if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });-- if (money.onBilling()) {-- try {-- const r = await billingActions.subscribe(req, { streamer, provider, autoRenewRaw: req.body.auto_renew });-- return res.status(r.status).json(r.body);-- } catch (err) {-- if (billingActions.sendError(res, err, { insufficientStatus: 402, insufficient: (d) => `Not enough Vibes${d.required ? ` (need ${d.required})` : ''}`, self: 'You cannot subscribe to yourself' })) return;-- console.error('[Payments] billing subscribe error:', err.message);-- return res.status(502).json({ error: 'Payment provider error. Try again.' });-- }-- }-- if (await db.isActiveSubscriber(req.user.id, streamer.id)) return res.status(409).json({ error: 'Already subscribed' });---- const priceUsd = await pay._num('sub_price_usd', 4.99);-- const amountCents = Math.round(priceUsd * 100);---- // Pay with Vibes — instant 30-day sub; auto-renews from the Vibes balance unless-- // the subscriber opted out.-- if (provider === 'bucks') {-- const autoRenew = req.body.auto_renew === undefined ? 1 : (req.body.auto_renew ? 1 : 0);-- const cost = await pay.bucksForUsd(priceUsd);-- if (!await db.deductVibes(req.user.id, cost)) return res.status(402).json({ error: `Not enough Vibes (need ${cost})` });-- const order = await db.createPaymentOrder({-- user_id: req.user.id, provider: 'bucks', kind: 'subscription',-- amount_cents: amountCents, streamer_id: streamer.id, status: 'paid',-- });-- const sub = await pay.fulfillSubscriptionOrder(order, { autoRenew });-- return res.json({ ok: true, subscription: { streamer: streamer.username, current_period_end: sub.current_period_end, auto_renew: !!autoRenew } });-- }---- // Pay with a PowerChat tip — streamer's own tip page when they have PowerChat-- // (they keep the money directly), else the site-wide PowerChat account (the site-- // holds the money and the streamer gets their normal cashout-Vibes share). The-- // donation.completed webhook confirms and activates the sub. Renewals come from-- // the Vibes balance when auto_renew is on (a tip can't be auto-charged).-- if (viaPowerchat) {-- const autoRenew = req.body.auto_renew ? 1 : 0;-- const checkout = require('../integrations/powerchat-checkout');-- const routes = await checkout.subscribeRoutes(streamer.id);-- // 'powerchat' = the streamer's own PowerChat (no fee). 'powerchat_site' = OpenVibe's-- // PowerChat account with the platform routing fee on top. A plain 'powerchat'-- // request for a streamer without PowerChat falls back to the site route (+fee) so-- // older clients keep working — the new UI always asks explicitly.-- const wantSite = provider === 'powerchat_site' || !routes.direct;-- if (wantSite && !routes.site) return res.status(400).json({ error: 'PowerChat payments are not available right now' });-- const feePct = wantSite ? await pay._num('sub_site_route_fee_pct', 10) : 0;-- const feeCents = wantSite ? Math.round(amountCents * feePct / 100) : 0;-- const totalCents = amountCents + feeCents;-- const order = await db.createPaymentOrder({-- user_id: req.user.id, provider: 'powerchat', kind: 'subscription',-- amount_cents: totalCents, streamer_id: streamer.id,-- });-- const link = await checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });-- if (!link) { await db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }-- const totalUsd = totalCents / 100;-- return res.json({-- url: link.url, powerchat: true, route: link.mode, amountUsd: totalUsd, feeUsd: feeCents / 100,-- pinned: !!link.minted, expires_at: link.expiresAt || null,-- note: link.mode === 'direct'-- ? `Tip $${totalUsd.toFixed(2)} on ${streamer.display_name || streamer.username}'s PowerChat — your subscription activates automatically once the tip confirms.`-- : `Tip $${totalUsd.toFixed(2)} on OpenVibe's PowerChat (includes a $${(feeCents / 100).toFixed(2)} platform fee) — your subscription activates automatically once the tip confirms.`,-- });-- }---- // Stripe recurring subscription (auto-renew monthly).-- if (provider === 'stripe') {-- const order = await db.createPaymentOrder({-- user_id: req.user.id, provider: 'stripe', kind: 'subscription',-- amount_cents: amountCents, streamer_id: streamer.id,-- });-- try {-- const r = await pay.stripeCheckout({-- order, name: `Subscription to ${streamer.display_name || streamer.username}`,-- amountCents, kind: 'subscription',-- successUrl: `${base()}/@${streamer.username}?sub=success`, cancelUrl: `${base()}/@${streamer.username}?sub=cancel`,-- });-- return res.json({ url: r.url });-- } catch (err) {-- console.error('[Payments] stripe sub:', err.message);-- await db.updatePaymentOrder(order.id, { status: 'failed' });-- return res.status(502).json({ error: 'Stripe error. Try again.' });-- }++ try {++ const r = await billingActions.subscribe(req, { streamer, provider, autoRenewRaw: req.body.auto_renew });++ return res.status(r.status).json(r.body);++ } catch (err) {++ if (billingActions.sendError(res, err, { insufficientStatus: 402, insufficient: (d) => `Not enough Vibes${d.required ? ` (need ${d.required})` : ''}`, self: 'You cannot subscribe to yourself' })) return;++ console.error('[Payments] billing subscribe error:', err.message);++ return res.status(502).json({ error: 'Payment provider error. Try again.' });}-- return res.status(400).json({ error: 'Subscriptions support Stripe or Vibes' });});// My subscriptionsrouter.get('/subscriptions/mine', requireAuth, async (req, res) => {-- if (money.onBilling()) {-- try { return res.json({ subscriptions: await billingActions.mySubscriptions(req.user.id) }); }-- catch (err) {-- if (billingActions.sendError(res, err, { unavailable: 'Subscriptions unavailable right now — the billing service is not answering.' })) return;-- return res.status(503).json({ error: 'Subscriptions unavailable right now', unavailable: true });-- }++ try { return res.json({ subscriptions: await billingActions.mySubscriptions(req.user.id) }); }++ catch (err) {++ if (billingActions.sendError(res, err, { unavailable: 'Subscriptions unavailable right now — the billing service is not answering.' })) return;++ return res.status(503).json({ error: 'Subscriptions unavailable right now', unavailable: true });}-- res.json({ subscriptions: await db.getSubscriptionsBySubscriber(req.user.id) });});// The channel's memberships on OpenVibe.VIP (tiers) and other ways to support it (OpenVibe.Tips): public, the same@@@ -246,19 +89,14 @@ router.get('/channel/:username/elsewhere', async (req, res) => {res.set('Cache-Control', 'public, max-age=60').json(out);});--// Am I subscribed to this channel? + subscriber count++// Am I subscribed to this channel? + subscriber count + what Billing charges for tier 1 (the creator's price on VIP,++// else Billing's default; the site setting only while Billing does not answer).router.get('/channel/:username', optionalAuth, async (req, res) => {const streamer = await db.getUserByUsername(req.params.username);if (!streamer) return res.status(404).json({ error: 'Not found' });let subscribed = false, subscriberCount = null, billingUnavailable = false, billingPriceCents = null;-- if (money.onBilling()) {-- try { ({ subscribed, subscriberCount, priceCents: billingPriceCents } = await billingActions.channelState(streamer, req.user || null)); }-- catch { billingUnavailable = true; }-- } else {-- subscribed = req.user ? await db.isActiveSubscriber(req.user.id, streamer.id) : false;-- subscriberCount = await db.getActiveSubscriberCount(streamer.id);-- }-- // Under Billing the price is the creator's tier 1 (VIP tiers), which Billing charges; else the site setting.++ try { ({ subscribed, subscriberCount, priceCents: billingPriceCents } = await billingActions.channelState(streamer, req.user || null)); }++ catch { billingUnavailable = true; }const priceUsd = billingPriceCents ? billingPriceCents / 100 : await pay._num('sub_price_usd', 4.99);let powerchat = { direct: false, site: false };try { powerchat = await require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }@@@ -270,118 +108,24 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {});});--// Cancel a subscription (Stripe: at period end; bucks: immediate)++// Cancel a subscription (at the period end; Billing cancels at the provider too).router.post('/subscriptions/:id/cancel', requireAuth, money.guardWrite, async (req, res) => {-- if (money.onBilling()) {-- try {-- const r = await billingActions.cancelSubscription(req, { id: req.params.id, streamerId: parseInt(req.body.streamerId, 10) || null });-- return res.status(r.status).json(r.body);-- } catch (err) {-- if (billingActions.sendError(res, err)) return;-- return res.status(502).json({ error: 'Could not cancel right now. Try again.' });-- }++ try {++ const r = await billingActions.cancelSubscription(req, { id: req.params.id, streamerId: parseInt(req.body.streamerId, 10) || null });++ return res.status(r.status).json(r.body);++ } catch (err) {++ if (billingActions.sendError(res, err)) return;++ return res.status(502).json({ error: 'Could not cancel right now. Try again.' });}-- const sub = await db.getActiveSubscription(req.user.id, parseInt(req.body.streamerId, 10) || null) ||-- (await db.getSubscriptionsBySubscriber(req.user.id) || []).find(x => String(x.id) === String(req.params.id));-- if (!sub || sub.subscriber_id !== req.user.id) return res.status(404).json({ error: 'Subscription not found' });-- // Stripe subs auto-renew; mark cancel-at-period-end (provider stops billing via dashboard/API).-- await db.setSubscriptionStatus(sub.id, sub.provider === 'stripe' ? 'active' : 'canceled', { cancel_at_period_end: true, current_period_end: sub.current_period_end });-- res.json({ ok: true });});--// ════════════════════════ WEBHOOKS ════════════════════════════--function rawBody(req) { return req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(req.body || {}); }----// Every webhook: the order must belong to that provider, and when the provider reports what was--// paid it must cover the order (an order id is passed through the buyer's browser for some--// providers, so without this a small payment could be pointed at a large order).--function payable(order, provider, paidUsd) {-- if (!order || order.provider !== provider) return false;-- if (pay.paidAmountCovers(order, paidUsd) === false) {-- console.warn(`[Payments] ${provider} paid ${paidUsd} for order ${order.id} (${order.amount_cents / 100}) — not credited`);-- return false;-- }-- return true;++// ── Provider webhooks: received by OpenVibe.Billing ──────────++// A provider dashboard still pointing here learns where to deliver; Live credits nothing.++for (const provider of ['stripe', 'paypal', 'ccbill', 'crypto']) {++ router.all(`/webhook/${provider}`, (req, res) => res.status(410).json({++ error: 'Payment webhooks are received by OpenVibe.Billing now.',++ webhook_url: `${billingClient.publicUrl()}/webhooks/${BILLING_WEBHOOK[provider]}`,++ }));}--// Stripe--router.post('/webhook/stripe', webhookMovedToBilling('stripe'), async (req, res) => {-- const event = await pay.stripeVerify(rawBody(req), req.headers['stripe-signature']);-- if (!event) return res.status(400).send('bad signature');-- try {-- const obj = event.data && event.data.object;-- const orderId = obj && ((obj.metadata && obj.metadata.order_id) || obj.client_reference_id);-- if (event.type === 'checkout.session.completed' && orderId) {-- const order = await db.getPaymentOrderById(parseInt(orderId, 10));-- const paid = Number.isFinite(obj.amount_total) ? obj.amount_total / 100 : null;-- if (payable(order, 'stripe', paid)) {-- if (order.kind === 'subscription') await pay.fulfillSubscriptionOrder(order, { providerRef: obj.subscription });-- else await pay.fulfillBucksOrder(order);-- }-- } else if (event.type === 'invoice.paid') {-- // Recurring renewal — extend the sub by ~1 month.-- const subRef = obj.subscription;-- const existing = subRef && await db.getSubscriptionByProviderRef('stripe', subRef);-- if (existing) {-- const end = new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();-- await db.setSubscriptionStatus(existing.id, 'active', { current_period_end: end });-- }-- } else if (event.type === 'customer.subscription.deleted') {-- const existing = await db.getSubscriptionByProviderRef('stripe', obj.id);-- if (existing) await db.setSubscriptionStatus(existing.id, 'canceled');-- }-- } catch (err) { console.error('[Payments] stripe webhook:', err.message); }-- res.json({ received: true });--});----// PayPal--router.post('/webhook/paypal', webhookMovedToBilling('paypal'), async (req, res) => {-- try {-- const ok = await pay.paypalVerify(req.headers, rawBody(req));-- if (!ok) return res.status(400).send('unverified');-- const event = req.body;-- const rsrc = event.resource || {};-- if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {-- const orderId = rsrc.custom_id || (rsrc.supplementary_data && rsrc.supplementary_data.related_ids && rsrc.supplementary_data.related_ids.order_id);-- const order = orderId && await db.getPaymentOrderById(parseInt(orderId, 10));-- const paid = rsrc.amount && rsrc.amount.currency_code === 'USD' ? rsrc.amount.value : null;-- if (payable(order, 'paypal', paid)) (order.kind === 'subscription' ? await pay.fulfillSubscriptionOrder(order) : await pay.fulfillBucksOrder(order));-- }-- } catch (err) { console.error('[Payments] paypal webhook:', err.message); }-- res.json({ received: true });--});----// CCBill (FlexForms datalink / webhook). Verified via shared secret in query.--router.all('/webhook/ccbill', webhookMovedToBilling('ccbill'), async (req, res) => {-- if (!await pay.ccbillVerify(req.query)) return res.status(403).send('forbidden');-- try {-- const p = { ...req.query, ...req.body };-- const orderId = p['X-order'] || p.order;-- const order = orderId && await db.getPaymentOrderById(parseInt(orderId, 10));-- const success = String(p.eventType || p.transactionType || '').toLowerCase().includes('success')-- || p.accountingAmount || p.priceInfo || p.eventType === 'NewSaleSuccess';-- // X-order rides in the buyer-editable form URL (the digest covers only the price), so-- // CCBill's reported price is required here, not optional.-- const paid = [p.billedInitialPrice, p.subscriptionInitialPrice, p.accountingInitialPrice, p.initialPrice, p.accountingAmount]-- .find((v) => v != null && v !== '' && Number.isFinite(Number(v)));-- if (order && success && paid == null) console.warn(`[Payments] ccbill webhook for order ${order.id} carried no price — not credited`);-- else if (success && payable(order, 'ccbill', paid)) (order.kind === 'subscription' ? await pay.fulfillSubscriptionOrder(order) : await pay.fulfillBucksOrder(order));-- } catch (err) { console.error('[Payments] ccbill webhook:', err.message); }-- res.status(200).send('OK');--});----// Crypto (NOWPayments IPN)--router.post('/webhook/crypto', webhookMovedToBilling('crypto'), async (req, res) => {-- const body = await pay.cryptoVerify(rawBody(req), req.headers['x-nowpayments-sig']);-- if (!body) return res.status(400).send('bad signature');-- try {-- if (['finished', 'confirmed', 'sending'].includes(String(body.payment_status))) {-- const order = await db.getPaymentOrderById(parseInt(body.order_id, 10));-- const paid = String(body.price_currency || 'usd').toLowerCase() === 'usd' ? body.price_amount : null;-- if (payable(order, 'crypto', paid)) (order.kind === 'subscription' ? await pay.fulfillSubscriptionOrder(order) : await pay.fulfillBucksOrder(order));-- }-- } catch (err) { console.error('[Payments] crypto webhook:', err.message); }-- res.json({ received: true });--});--module.exports = router;@@@ -1,39 +1,20 @@/**-- * OpenVibe.Live — Payments++ * OpenVibe.Live — payment settings for the client: which ways to pay are shown, and the prices.*-- * Real-money purchase of Vibes + channel subscriptions across PayPal,-- * Stripe, CCBill and crypto (NOWPayments). All provider credentials live in-- * site_settings (configured in openvibe.network/admin → Payments). The master switch-- * `payments_enabled` gates everything (default OFF) so nothing is live until an-- * admin turns it on.-- *-- * Design:-- * - Every purchase creates a `payment_orders` row (pending) BEFORE redirecting.-- * - Providers redirect the buyer to a hosted checkout; on success the provider-- * calls our webhook, which we verify, then credit bucks / activate the sub-- * idempotently (an order is only credited once).-- * - We never see raw card data — Stripe/CCBill host the card form.++ * OpenVibe.Billing runs every checkout, webhook, subscription and renewal since the cutover of 2026-10-17++ * (billing-actions.js); the provider adapters live there. The master switch `payments_enabled` (site_settings,++ * openvibe.network/admin → Payments) still decides whether Live offers card/PayPal/crypto at all; PowerChat has its++ * own enablement.*/--const crypto = require('crypto');const db = require('../db/database');--const config = require('../config');const money = require('./money-authority');--function baseUrl() { return config.baseUrl.replace(/\/+$/, ''); }async function s(key) { return (await db.getSetting(key) || '').toString().trim(); }async function b(key) { const v = await db.getSetting(key); return v === true || v === 'true' || v === 1 || v === '1'; }async function n(key, dflt) { const v = parseFloat(await db.getSetting(key)); return Number.isFinite(v) ? v : dflt; }async function isEnabled() { return await b('payments_enabled'); }--/** USD (dollars) → whole Vibes at the VALUE rate (100 bucks = $1). Used for-- * converting real income (sub share, external tips) into bucks — NOT for purchase-- * pricing (buying adds a margin; see vibes.js priceUsdForBucks). */--async function bucksForUsd(usd) {-- const rate = await n('bucks_per_usd', 100);-- return Math.max(0, Math.round(usd * rate));--}--/** Public provider availability + pricing for the client. */async function publicConfig() {const hb = require('./vibes');@@@ -62,349 +43,14 @@ async function publicConfig() {powerchat: powerchatAvailable,},stripePublishableKey: await s('stripe_publishable_key'),-- // Who holds the money (BILLING_AUTHORITY) and whether money actions are paused.++ // Who holds the money (OpenVibe.Billing) and whether money actions are paused.authority: money.authority(),moneyFrozen: await money.isFrozen(),};}--// ── HTTP helpers ─────────────────────────────────────────────--async function httpJson(url, { method = 'GET', headers = {}, body } = {}) {-- const res = await fetch(url, { method, headers, body });-- const text = await res.text();-- let json = null; try { json = text ? JSON.parse(text) : {}; } catch { /* */ }-- if (!res.ok) {-- const msg = (json && (json.message || json.error?.message || json.error || json.error_description)) || text || res.statusText;-- throw new Error(`${method} ${url} -> ${res.status}: ${msg}`);-- }-- return json || {};--}----// ══════════════════════════════════════════ STRIPE ══════════--const STRIPE_API = 'https://api.stripe.com/v1';--async function stripeHeaders() {-- return { Authorization: `Bearer ${await s('stripe_secret_key')}`, 'Content-Type': 'application/x-www-form-urlencoded' };--}--/** Create a Stripe Checkout Session. kind: 'bucks' (one-time) | 'subscription'. */--async function stripeCheckout({ order, name, amountCents, kind, successUrl, cancelUrl }) {-- const p = new URLSearchParams();-- p.set('success_url', successUrl);-- p.set('cancel_url', cancelUrl);-- p.set('client_reference_id', String(order.id));-- p.set('metadata[order_id]', String(order.id));-- p.set('line_items[0][quantity]', '1');-- p.set('line_items[0][price_data][currency]', 'usd');-- p.set('line_items[0][price_data][unit_amount]', String(amountCents));-- p.set('line_items[0][price_data][product_data][name]', name);-- if (kind === 'subscription') {-- p.set('mode', 'subscription');-- p.set('line_items[0][price_data][recurring][interval]', 'month');-- p.set('subscription_data[metadata][order_id]', String(order.id));-- } else {-- p.set('mode', 'payment');-- }-- const session = await httpJson(`${STRIPE_API}/checkout/sessions`, { method: 'POST', headers: await stripeHeaders(), body: p.toString() });-- await db.updatePaymentOrder(order.id, { provider_ref: session.id });-- return { url: session.url, ref: session.id };--}--/** Verify a Stripe webhook signature (t=..,v1=..) against the raw body. */--async function stripeVerify(rawBody, sigHeader) {-- const secret = await s('stripe_webhook_secret');-- if (!secret || !sigHeader) return null;-- const parts = Object.fromEntries(sigHeader.split(',').map(kv => kv.split('=')));-- if (!parts.t || !parts.v1) return null;-- const signed = `${parts.t}.${rawBody}`;-- const expected = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');-- const a = Buffer.from(expected), c = Buffer.from(parts.v1);-- if (a.length !== c.length || !crypto.timingSafeEqual(a, c)) return null;-- try { return JSON.parse(rawBody); } catch { return null; }--}----// ══════════════════════════════════════════ PAYPAL ══════════--async function paypalBase() { return await s('paypal_mode') === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; }--async function paypalToken() {-- const auth = Buffer.from(`${await s('paypal_client_id')}:${await s('paypal_client_secret')}`).toString('base64');-- const j = await httpJson(`${await paypalBase()}/v1/oauth2/token`, {-- method: 'POST',-- headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },-- body: 'grant_type=client_credentials',-- });-- return j.access_token;--}--async function paypalCreateOrder({ order, amountUsd, description }) {-- const token = await paypalToken();-- const j = await httpJson(`${await paypalBase()}/v2/checkout/orders`, {-- method: 'POST',-- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },-- body: JSON.stringify({-- intent: 'CAPTURE',-- purchase_units: [{-- custom_id: String(order.id),-- description: description.slice(0, 127),-- amount: { currency_code: 'USD', value: amountUsd.toFixed(2) },-- }],-- application_context: {-- brand_name: 'OpenVibe.Live', user_action: 'PAY_NOW',-- return_url: `${baseUrl()}/api/payments/paypal/return?order=${order.id}`,-- cancel_url: `${baseUrl()}/?purchase=cancel`,-- },-- }),-- });-- await db.updatePaymentOrder(order.id, { provider_ref: j.id });-- const approve = (j.links || []).find(l => l.rel === 'approve');-- return { url: approve ? approve.href : null, ref: j.id };--}--async function paypalCaptureOrder(paypalOrderId) {-- const token = await paypalToken();-- return await httpJson(`${await paypalBase()}/v2/checkout/orders/${paypalOrderId}/capture`, {-- method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: '{}',-- });--}--/** Verify a PayPal webhook via the verify-webhook-signature API. */--async function paypalVerify(headers, rawBody) {-- const webhookId = await s('paypal_webhook_id');-- if (!webhookId) return false;-- const token = await paypalToken();-- const j = await httpJson(`${await paypalBase()}/v1/notifications/verify-webhook-signature`, {-- method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },-- body: JSON.stringify({-- auth_algo: headers['paypal-auth-algo'],-- cert_url: headers['paypal-cert-url'],-- transmission_id: headers['paypal-transmission-id'],-- transmission_sig: headers['paypal-transmission-sig'],-- transmission_time: headers['paypal-transmission-time'],-- webhook_id: webhookId,-- webhook_event: JSON.parse(rawBody),-- }),-- });-- return j.verification_status === 'SUCCESS';--}----// ══════════════════════════════════════════ CCBILL ══════════--/** Build a CCBill FlexForms hosted-payment URL for a one-time charge. */--async function ccbillUrl({ order, amountUsd }) {-- const formId = await s('ccbill_flexform_id');-- const salt = await s('ccbill_salt');-- const price = amountUsd.toFixed(2);-- const currency = '840'; // USD-- // FlexForms digest: md5(price + period + currencyCode + salt) for a single charge-- const period = '2'; // days is unused for single charge but required; FlexForms uses formDigest-- const digest = crypto.createHash('md5').update(`${price}${period}${currency}${salt}`).digest('hex');-- const qs = new URLSearchParams({-- clientAccnum: await s('ccbill_client_account'),-- clientSubacc: await s('ccbill_subaccount'),-- initialPrice: price,-- initialPeriod: period,-- currencyCode: currency,-- formDigest: digest,-- 'X-order': String(order.id), // custom passthrough (X- prefix survives to the webhook)-- });-- return `https://api.ccbill.com/wap-frontflex/flexforms/${formId}?${qs.toString()}`;--}--/** CCBill webhook auth: we require a shared secret in the query (?secret=...). */--async function ccbillVerify(query) {-- const secret = await s('ccbill_webhook_secret');-- if (!secret) return false;-- // Constant-time: compare SHA-256 digests so neither length nor content leaks through timing.-- const a = crypto.createHash('sha256').update(String(query.secret || '')).digest();-- const b = crypto.createHash('sha256').update(String(secret)).digest();-- return crypto.timingSafeEqual(a, b);--}----// ══════════════════════════════════════════ CRYPTO (NOWPayments) ══--async function cryptoCreateInvoice({ order, amountUsd, description }) {-- const j = await httpJson('https://api.nowpayments.io/v1/invoice', {-- method: 'POST',-- headers: { 'x-api-key': await s('crypto_api_key'), 'Content-Type': 'application/json' },-- body: JSON.stringify({-- price_amount: amountUsd, price_currency: 'usd',-- order_id: String(order.id), order_description: description,-- ipn_callback_url: `${baseUrl()}/api/payments/webhook/crypto`,-- success_url: `${baseUrl()}/?purchase=success`, cancel_url: `${baseUrl()}/?purchase=cancel`,-- }),-- });-- await db.updatePaymentOrder(order.id, { provider_ref: String(j.id || j.invoice_id || '') });-- return { url: j.invoice_url, ref: String(j.id || j.invoice_id || '') };--}--/** Verify a NOWPayments IPN via HMAC-SHA512 of the sorted JSON body. */--async function cryptoVerify(rawBody, sigHeader) {-- const secret = await s('crypto_ipn_secret');-- if (!secret || !sigHeader) return null;-- let obj; try { obj = JSON.parse(rawBody); } catch { return null; }-- const sorted = JSON.stringify(sortObject(obj));-- const expected = crypto.createHmac('sha512', secret).update(sorted).digest('hex');-- const a = Buffer.from(expected), c = Buffer.from(String(sigHeader));-- if (a.length !== c.length || !crypto.timingSafeEqual(a, c)) return null;-- return obj;--}--function sortObject(o) {-- if (Array.isArray(o)) return o.map(sortObject);-- if (o && typeof o === 'object') {-- return Object.keys(o).sort().reduce((acc, k) => { acc[k] = sortObject(o[k]); return acc; }, {});-- }-- return o;--}----// ── Fulfillment (idempotent) ─────────────────────────────────----/** The caller's copy of an order can predate an await (the PayPal return captures while the-- * webhook credits); crediting that stale 'pending' copy would pay twice. Read it again. */--async function _freshOrder(order) {-- return order && order.id ? (await db.getPaymentOrderById(order.id) || order) : order;--}----/** Does a provider-reported amount (USD) cover the order? Unknown amounts return null. */--function paidAmountCovers(order, paidUsd) {-- const paid = Number(paidUsd);-- if (paidUsd == null || paidUsd === '' || !Number.isFinite(paid)) return null;-- return Math.round(paid * 100) >= Number(order.amount_cents || 0) - 1;--}----/** Credit a paid bucks order exactly once. Returns true if newly credited. */--async function fulfillBucksOrder(order) {-- order = await _freshOrder(order);-- if (!order || order.status === 'credited') return false;-- const bucks = order.bucks || await bucksForUsd(order.amount_cents / 100);-- await db.addVibes(order.user_id, bucks);-- try {-- await db.createTransaction({-- from_user_id: null, to_user_id: order.user_id, amount: bucks, // ledger is in bucks-- type: 'purchase', status: 'completed',-- message: `Purchased ${bucks.toLocaleString()} Vibes via ${order.provider}`,-- });-- } catch { /* ledger is best-effort */ }-- await db.updatePaymentOrder(order.id, { status: 'credited', bucks });-- return true;--}----/** Activate/extend a subscription from a paid order (idempotent-ish).-- * creditShare=false: the streamer already received the money directly (e.g. a-- * PowerChat tip on their own page) — activate the sub without minting their-- * cashout-Vibes share on top. autoRenew: null leaves the sub's flag untouched. */--async function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null, creditShare = true, autoRenew = null, shareBaseCents = null } = {}) {-- order = await _freshOrder(order);-- if (!order || !order.streamer_id) return null;-- if (order.status === 'credited') {-- // A replayed webhook must not push the period out again or re-pay the streamer.-- try { const existing = await db.getActiveSubscription(order.user_id, order.streamer_id); if (existing) return existing; } catch { /* */ }-- }-- const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();-- // Note BEFORE the upsert whether this subscriber already had a sub row — that makes-- // a renewal a resub for the PowerChat alert below.-- let isResub = false;-- try { isResub = !!await db.getActiveSubscription(order.user_id, order.streamer_id); } catch { /* */ }-- const sub = await db.upsertSubscription({-- subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,-- provider: order.provider, provider_ref: providerRef || order.provider_ref,-- price_cents: order.amount_cents, currency: order.currency || 'usd',-- status: 'active', current_period_end: end, auto_renew: autoRenew,-- });-- if (order.status !== 'credited') {-- // Pay the streamer their share as Vibes — this is income they received, so-- // it lands in their cashout balance (the only cashout-able balance).-- const sharePct = await n('sub_streamer_share_pct', 70);-- // shareBaseCents: the sub price EXCLUDING any platform routing fee the subscriber-- // paid on top (site-routed PowerChat subs) — the fee is the site's, not shareable.-- const baseCents = shareBaseCents != null ? shareBaseCents : order.amount_cents;-- const streamerBucks = creditShare ? await bucksForUsd((baseCents / 100) * (sharePct / 100)) : 0;-- if (streamerBucks > 0) await db.addVibesCashout(order.streamer_id, streamerBucks);-- await db.updatePaymentOrder(order.id, { status: 'credited' });-- // Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —-- // alerts + sub-goal/subathon credit. Keyed by the order id so a replayed-- // fulfillment can't double-alert. Only on first credit, never on re-runs.-- try {-- const subscriber = await db.getUserById(order.user_id);-- await require('../integrations/powerchat-platform').forwardSubscription(order.streamer_id, {-- subscriberName: subscriber?.display_name || subscriber?.username || 'Someone',-- externalId: `sub-order:${order.id}`,-- tier: '1',-- isResub,-- });-- } catch { /* non-critical */ }-- }-- return sub;--}----// ── Subscription renewal sweeper ─────────────────────────────--// Auto-renew for every method that CAN renew:--// - Stripe subs renew natively (invoice.paid extends the period). The sweeper only--// expires them after a 3-day grace window with no renewal — Stripe stopped billing.--// - Everything else (vibes/paypal/ccbill/crypto/powerchat) is a one-time charge the--// processor can't repeat, so auto_renew=1 renews from the subscriber's VIBES--// balance — the universal wallet every method can top up. Insufficient balance--// (or auto_renew off / cancel-at-period-end) → the sub expires with a notification.--const STRIPE_GRACE_MS = 3 * 24 * 3600 * 1000;--async function _sweepRenewals() {-- // OpenVibe.Billing renews its own subscriptions; a freeze pauses renewals until it lifts.-- if (money.authority() !== 'live' || await money.isFrozen()) return;-- // Runs from an hourly timer; an uncaught throw there exits the whole server.-- let due = [];-- try { due = await db.getSubscriptionsDueRenewal(50) || []; }-- catch (e) { console.warn('[Payments] renewal sweep skipped:', e.message); return; }-- for (const sub of due) {-- try {-- const endMs = Date.parse(sub.current_period_end) || 0;-- if (sub.provider === 'stripe') {-- if (Date.now() - endMs < STRIPE_GRACE_MS) continue; // let Stripe's invoice.paid land-- await db.setSubscriptionStatus(sub.id, 'expired');-- await _renewNotify(sub, 'Your card subscription was not renewed by Stripe and has ended.');-- continue;-- }-- if (sub.cancel_at_period_end || !sub.auto_renew) {-- await db.setSubscriptionStatus(sub.id, sub.cancel_at_period_end ? 'canceled' : 'expired');-- if (!sub.cancel_at_period_end) await _renewNotify(sub, 'Your channel subscription has ended. Resubscribe any time!');-- continue;-- }-- const priceCents = sub.price_cents || Math.round(await n('sub_price_usd', 4.99) * 100);-- const cost = await bucksForUsd(priceCents / 100);-- if (!await db.deductVibes(sub.subscriber_id, cost)) {-- await db.setSubscriptionStatus(sub.id, 'expired');-- await _renewNotify(sub, `Your subscription could not auto-renew (${cost.toLocaleString()} Vibes needed). Top up and resubscribe!`);-- continue;-- }-- const order = await db.createPaymentOrder({-- user_id: sub.subscriber_id, provider: 'bucks', kind: 'subscription',-- amount_cents: priceCents, streamer_id: sub.streamer_id, status: 'paid',-- });-- await fulfillSubscriptionOrder(order, { autoRenew: 1 });-- console.log(`[Payments] auto-renewed sub ${sub.id} (user ${sub.subscriber_id} → streamer ${sub.streamer_id}) from Vibes balance`);-- } catch (e) { console.warn(`[Payments] renewal sweep sub ${sub.id}:`, e.message); }-- }--}--async function _renewNotify(sub, message) {-- try {-- const { pushNotification } = require('../utils/notify');-- const streamer = await db.getUserById(sub.streamer_id);-- pushNotification({-- user_id: sub.subscriber_id, type: 'PAYMENT', title: 'Subscription update',-- message: `${streamer ? (streamer.display_name || streamer.username) + ': ' : ''}${message}`,-- url: streamer ? `/@${encodeURIComponent(streamer.username)}` : '/',-- });-- } catch { /* optional */ }--}--let _renewTimer = null;--async function startRenewalSweeper() {-- if (_renewTimer) return;-- if (money.onBilling()) { console.log('[Payments] renewal sweeper not started: OpenVibe.Billing renews subscriptions (BILLING_AUTHORITY=billing)'); return; }-- await _sweepRenewals(); // catch up immediately on boot-- _renewTimer = setInterval(() => _sweepRenewals().catch((e) => console.warn('[Payments] renewal sweep:', e.message)), 60 * 60 * 1000);-- if (_renewTimer.unref) _renewTimer.unref();-- console.log('[Payments] subscription renewal sweeper started (hourly)');--}--module.exports = {-- isEnabled, publicConfig, bucksForUsd, startRenewalSweeper,-- // stripe-- stripeCheckout, stripeVerify,-- // paypal--paidAmountCovers,-- paypalCreateOrder, paypalCaptureOrder, paypalVerify,-- // ccbill-- ccbillUrl, ccbillVerify,-- // crypto-- cryptoCreateInvoice, cryptoVerify,-- // fulfillment-- fulfillBucksOrder, fulfillSubscriptionOrder,++ isEnabled, publicConfig,// settings accessors (for routes)_get: s, _bool: b, _num: n,};@@@ -18,8 +18,8 @@ const db = require('../db/database');const delivery = require('../chat/chat-delivery');const { publicGoal, announceGoal } = require('./goal-announce');const channelTips = require('./channel-tips');--// BILLING_AUTHORITY=billing: every money action below goes to OpenVibe.Billing instead of Live's--// columns (billing-actions.js); money_writes_frozen refuses them in both modes.++// OpenVibe.Billing is the ledger (since 2026-10-17): every money action below asks it (billing-actions.js);++// money_writes_frozen refuses them. Goals and the channel's tip history stay Live's.const money = require('./money-authority');const billingActions = require('./billing-actions');const cashoutsInBilling = (res) => res.status(409).json({@@@ -30,22 +30,10 @@ const cashoutsInBilling = (res) => res.status(409).json({const router = express.Router();// ── Buy Vibes ───────────────────────────────────────────--/**-- * DISABLED — this route credited Vibes with no payment verification whatsoever.-- *-- * It took an `amount` and a `paypal_transaction_id` from the request body, never checked the-- * transaction against PayPal, and called purchase() directly. The comment in its place read-- * "In production, validate PayPal transaction here". Any signed-in account could mint up to the-- * balance ceiling for free and then move it somewhere spendable.-- *-- * Real purchases are credited by the verified payment webhooks (fulfillBucksOrder), which check a-- * signature and an order record. Returning 410 rather than deleting the route so an old client-- * calling it gets a clear answer instead of a 404 that looks like a routing bug.-- */++// Disabled (it once credited Vibes with no payment check). Purchases are checkouts through OpenVibe.Billing++// (/api/payments/bucks/checkout); 410 rather than 404 so an old client gets a clear answer.router.post('/purchase', (req, res) => {-- res.status(410).json({-- error: 'Direct purchase is disabled. Vibes are credited by the payment provider webhook.',-- });++ res.status(410).json({ error: 'Direct purchase is disabled. Buy Vibes with a checkout: OpenVibe.Billing credits them when the payment confirms.' });});// Serialize a goal for the client (no private fields to leak).@@@ -73,16 +61,14 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {if (!s || s.user_id !== streamer_id) return res.status(400).json({ error: 'That stream does not belong to this streamer' });}-- const result = money.onBilling()-- ? await billingActions.donate(req, { toUserId: streamer_id, streamId: stream_id, amount, message, goalId: goal_id || null })-- : await openvibeBucks.donate(req.user.id, streamer_id, stream_id, amount, message, goal_id || null);++ const result = await billingActions.donate(req, { toUserId: streamer_id, streamId: stream_id, amount, message, goalId: goal_id || null });// A request the browser repeated with the same Idempotency-Key: already celebrated.if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });-- // The channel's tip history (leaderboard, recap, home stats), whichever ledger moved the Vibes.++ // The channel's tip history (leaderboard, recap, home stats).await channelTips.record({toUserId: streamer_id, fromUserId: req.user.id, streamId: stream_id || null, amount: result.amount, source: 'vibes',-- ref: result.transactionId ? `${money.onBilling() ? 'billing-txn' : 'live-txn'}:${result.transactionId}` : `vibes:${require('crypto').randomUUID()}`,++ ref: result.transactionId ? `billing-txn:${result.transactionId}` : `vibes:${require('crypto').randomUUID()}`,});const alerts = require('./alerts');@@@ -124,8 +110,7 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {key: result.transactionId ? `goal:${result.transactionId}` : undefined,});-- const balance = money.onBilling() ? result.balance : (await db.getUserById(req.user.id)).openvibe_bucks_balance;-- res.json({ success: true, amount: result.amount, balance, goal_reached: !!result.goalReached });++ res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: !!result.goalReached });} catch (err) {if (billingActions.sendError(res, err, { insufficient: 'Insufficient Vibes', self: 'You cannot donate to yourself' })) return;res.status(400).json({ error: err.message });@@@ -140,9 +125,7 @@ router.post('/cashout', requireAuth, money.guardWrite, async (req, res) => {return res.status(400).json({ error: 'Amount and PayPal email required' });}-- const result = money.onBilling()-- ? await billingActions.requestCashout(req, { amount, paypalEmail: paypal_email })-- : await openvibeBucks.requestCashout(req.user.id, amount, paypal_email);++ const result = await billingActions.requestCashout(req, { amount, paypalEmail: paypal_email });res.json(result);} catch (err) {if (billingActions.sendError(res, err, { insufficient: 'Insufficient cashout balance — only Vibes sent to you can be cashed out' })) return;@@@ -152,33 +135,18 @@ router.post('/cashout', requireAuth, money.guardWrite, async (req, res) => {// ── Get Balance ──────────────────────────────────────────────router.get('/balance', requireAuth, async (req, res) => {-- if (money.onBilling()) {-- // Billing is the only truth: when it cannot answer, the balance is "unavailable" — never-- // the frozen legacy column.-- try { return res.json(await billingActions.balance(req.user.id, req.headers)); }-- catch (err) {-- if (billingActions.sendError(res, err, { unavailable: 'Vibes balance unavailable right now — the billing service is not answering.' })) return;-- return res.status(503).json({ error: 'Vibes balance unavailable right now', unavailable: true });-- }++ // Billing is the only truth: when it cannot answer, the balance is "unavailable", never a guess.++ try { return res.json(await billingActions.balance(req.user.id, req.headers)); }++ catch (err) {++ if (billingActions.sendError(res, err, { unavailable: 'Vibes balance unavailable right now — the billing service is not answering.' })) return;++ return res.status(503).json({ error: 'Vibes balance unavailable right now', unavailable: true });}-- const user = await db.getUserById(req.user.id);-- const bal = Math.round(user.openvibe_bucks_balance || 0);-- const cashout = Math.round(user.openvibe_bucks_cashout_balance || 0);-- res.json({-- // Integer Vibes; USD is the streamer cashout value (100 bucks = $1).-- balance: bal,-- usd_value: openvibeBucks.cashoutUsd(bal).toFixed(2),-- cashout_balance: cashout,-- cashout_usd_value: openvibeBucks.cashoutUsd(cashout).toFixed(2),-- });});// ── Recycle cashout balance → spendable Vibes ───────────router.post('/recycle', requireAuth, money.guardWrite, async (req, res) => {try {-- const result = money.onBilling()-- ? await billingActions.recycle(req, req.body.amount)-- : await openvibeBucks.recycleCashout(req.user.id, req.body.amount);++ const result = await billingActions.recycle(req, req.body.amount);res.json(result);} catch (err) {if (billingActions.sendError(res, err, { insufficient: 'Insufficient cashout balance' })) return;@@@ -189,15 +157,11 @@ router.post('/recycle', requireAuth, money.guardWrite, async (req, res) => {// ── Transaction History ──────────────────────────────────────router.get('/history', requireAuth, async (req, res) => {const limit = Math.min(parseInt(req.query.limit || '50'), 200);-- if (money.onBilling()) {-- try { return res.json({ transactions: await billingActions.history(req.user.id, limit) }); }-- catch (err) {-- if (billingActions.sendError(res, err, { unavailable: 'Vibes history unavailable right now — the billing service is not answering.' })) return;-- return res.status(503).json({ error: 'Vibes history unavailable right now', unavailable: true });-- }++ try { return res.json({ transactions: await billingActions.history(req.user.id, limit) }); }++ catch (err) {++ if (billingActions.sendError(res, err, { unavailable: 'Vibes history unavailable right now — the billing service is not answering.' })) return;++ return res.status(503).json({ error: 'Vibes history unavailable right now', unavailable: true });}-- const history = await openvibeBucks.getHistory(req.user.id, limit);-- res.json({ transactions: history });});// ── Stream Donation Leaderboard ──────────────────────────────@@@ -265,39 +229,10 @@ router.get('/goals/:userId', async (req, res) => {res.json({ goals });});--// ── Admin: Approve Cashout ───────────────────────────────────--router.post('/cashout/:id/approve', requireOwner, money.guardWrite, async (req, res) => {-- if (money.onBilling()) return cashoutsInBilling(res);-- try {-- await openvibeBucks.approveCashout(req.params.id);-- res.json({ message: 'Cashout approved' });-- } catch (err) {-- res.status(400).json({ error: err.message });-- }--});----// ── Admin: Deny Cashout ──────────────────────────────────────--router.post('/cashout/:id/deny', requireOwner, money.guardWrite, async (req, res) => {-- if (money.onBilling()) return cashoutsInBilling(res);-- try {-- await openvibeBucks.denyCashout(req.params.id, req.body.reason);-- res.json({ message: 'Cashout denied, funds refunded' });-- } catch (err) {-- res.status(400).json({ error: err.message });-- }--});----// ── Admin: Get Pending Cashouts ──────────────────────────────--router.get('/cashouts/pending', requireOwner, async (req, res) => {-- if (money.onBilling()) return cashoutsInBilling(res);-- const pending = await db.all(`-- SELECT t.*, u.username, u.display_name-- FROM transactions t-- JOIN users u ON t.from_user_id = u.id-- WHERE t.type = 'cashout' AND t.status = 'escrow'-- ORDER BY t.created_at ASC-- `);-- res.json({ cashouts: pending });--});++// ── Cashout decisions: OpenVibe.Billing's staff console ──────++// Old admin clients get a clear answer instead of a 404.++router.post('/cashout/:id/approve', requireOwner, (req, res) => cashoutsInBilling(res));++router.post('/cashout/:id/deny', requireOwner, (req, res) => cashoutsInBilling(res));++router.get('/cashouts/pending', requireOwner, (req, res) => cashoutsInBilling(res));module.exports = router;@@@ -1,18 +1,13 @@/*** OpenVibe.Live — Vibes Engine*-- * Virtual currency: integer "bit"-style Vibes. 100 Bucks = $1.00 streamer cashout.-- * Viewers buy at a premium with volume discounts (priceUsdForBucks) — the platform margin.-- * Features:-- * - Buy Vibes (PayPal)-- * - Donate to streamers-- * - Donation goals with progress bars-- * - Escrow cashout with admin approval-- * - Subscription tiers++ * Vibes are integer "bit"-style units: 100 = $1.00 of streamer cashout value, bought at a premium with volume++ * discounts (priceUsdForBucks). OpenVibe.Billing keeps every balance and moves every Vibe since 2026-10-17++ * (billing-actions.js); what stays here is the arithmetic, the channel's donation goals and its tip leaderboard++ * (channel_tips).*/const db = require('../db/database');const config = require('../config');--const { assertLiveLedger } = require('./money-authority');// Vibes are integer "bit"-style units: 100 bucks = $1.00 streamer cashout.const CASHOUT_BUCKS_PER_USD = 100;@@@ -58,77 +53,7 @@ function normalizeText(value, maxLen = 300) {return text;}--function validatePaypalEmail(value) {-- const email = String(value || '').trim();-- if (!email) throw new Error('PayPal email required');-- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {-- throw new Error('Invalid PayPal email');-- }-- return email;--}--class Vibes {-- /**-- * Purchase Vibes-- * @param {number} userId-- * @param {number} amount - Number of Vibes to purchase-- * @param {string} paypalTxId - PayPal transaction ID-- */-- async purchase(userId, amount, paypalTxId) {-- amount = normalizeBucks(amount);-- assertLiveLedger('transactions insert');-- const txId = normalizeText(paypalTxId, 128);-- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)-- VALUES (NULL, ?, ?, 'purchase', 'completed', ?) RETURNING id`, [userId, amount, `Purchased ${amount} Vibes`]);---- // Update PayPal reference-- if (txId) {-- await db.run('UPDATE transactions SET paypal_transaction_id = ? WHERE id = ?',-- [txId, tx.lastInsertRowid]);-- }---- await db.addVibes(userId, amount);-- return tx;-- }---- /**-- * Donate Vibes to a streamer-- * @param {number} fromUserId - Donor-- * @param {number} toUserId - Streamer-- * @param {number} streamId - Current stream-- * @param {number} amount - Vibes to donate-- * @param {string} message - Donation message-- */-- async donate(fromUserId, toUserId, streamId, amount, message, goalId = null) {-- amount = normalizeBucks(amount);-- assertLiveLedger('transactions insert');-- message = normalizeText(message, 300);---- // Deduct from donor-- if (!await db.deductVibes(fromUserId, amount)) {-- throw new Error('Insufficient Vibes');-- }---- // Credit the streamer's CASHOUT balance (received bucks are the only cashout-able-- // ones; their spendable balance is for bucks they bought).-- await db.addVibesCashout(toUserId, amount);---- // Record transaction-- const txn = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status, message)-- VALUES (?, ?, ?, ?, 'donation', 'completed', ?) RETURNING id`, [fromUserId, toUserId, streamId || null, amount, message || null]);---- // Apply toward a donation goal (the donor's pick, else the sole active goal).-- const goalResult = await this.applyDonationToGoal(toUserId, amount, goalId);---- return {-- success: true,-- amount,-- transactionId: txn && txn.lastInsertRowid ? Number(txn.lastInsertRowid) : null,-- goal: goalResult ? goalResult.goal : null, // the goal that advanced (for the widget)-- goalReached: goalResult && goalResult.reached ? goalResult.goal : null,-- };-- }--/*** Route a donation toward a single goal: the donor's chosen goal if valid+active,* otherwise the streamer's sole active goal (if exactly one). Returns@@@ -149,105 +74,6 @@ class Vibes {return await db.addToDonationGoal(target.id, amount);}-- /**-- * Request cashout (goes to escrow for admin review)-- */-- async requestCashout(userId, amount, paypalEmail) {-- amount = normalizeBucks(amount);-- assertLiveLedger('transactions insert');-- paypalEmail = validatePaypalEmail(paypalEmail);-- const minBucks = config.openvibeBucks.minCashoutBucks;-- if (amount < minBucks) {-- throw new Error(`Minimum cashout is ${minBucks.toLocaleString()} Vibes ($${cashoutUsd(minBucks).toFixed(2)})`);-- }---- // Only the cashout balance (received donations) can be cashed out.-- if (!await db.deductVibesCashout(userId, amount)) {-- throw new Error('Insufficient cashout balance — only Vibes sent to you can be cashed out');-- }---- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)-- VALUES (?, NULL, ?, 'cashout', 'escrow', ?) RETURNING id`, [userId, amount, `Cashout to PayPal: ${paypalEmail}`]);---- return {-- transaction_id: tx.lastInsertRowid,-- amount,-- usd_value: cashoutUsd(amount).toFixed(2),-- status: 'escrow',-- hold_days: config.openvibeBucks.escrowDays,-- };-- }---- /**-- * Admin: Approve a cashout (release from escrow)-- */-- async approveCashout(transactionId) {-- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',-- [transactionId, 'escrow']);-- if (!tx) throw new Error('Transaction not found or not in escrow');---- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['completed', transactionId]);-- return tx;-- }---- /**-- * Admin: Deny a cashout (refund to user)-- */-- async denyCashout(transactionId, reason) {-- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',-- [transactionId, 'escrow']);-- if (!tx) throw new Error('Transaction not found or not in escrow');---- // Refund back to the cashout balance it came from.-- await db.addVibesCashout(tx.from_user_id, tx.amount);-- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['refunded', transactionId]);---- return tx;-- }---- /**-- * Recycle: move Vibes from the streamer's cashout balance into their spendable-- * balance, so they can re-donate / give back to the community instead of cashing out.-- */-- async recycleCashout(userId, amount) {-- amount = normalizeBucks(amount);-- if (!await db.deductVibesCashout(userId, amount)) {-- throw new Error('Insufficient cashout balance');-- }-- await db.addVibes(userId, amount);-- await db.createTransaction({-- from_user_id: userId,-- to_user_id: userId,-- amount,-- type: 'recycle',-- status: 'completed',-- message: 'Moved cashout balance to spendable Vibes',-- });-- const user = await db.getUserById(userId);-- return {-- success: true,-- amount,-- balance: user.openvibe_bucks_balance,-- cashout_balance: user.openvibe_bucks_cashout_balance,-- };-- }---- /**-- * Get user's transaction history-- */-- async getHistory(userId, limit = 50) {-- return await db.all(`-- SELECT t.*,-- fu.username AS from_username, fu.display_name AS from_display,-- tu.username AS to_username, tu.display_name AS to_display-- FROM transactions t-- LEFT JOIN users fu ON t.from_user_id = fu.id-- LEFT JOIN users tu ON t.to_user_id = tu.id-- WHERE t.from_user_id = ? OR t.to_user_id = ?-- ORDER BY t.created_at DESC LIMIT ?-- `, [userId, userId, limit]);-- }--/*** A stream's tip leaderboard from channel_tips (on-site Vibes, PowerChat and OpenVibe.Tips alike). A supporter* without a Live account has from_user_id and username null and their name as display_name.@@@ -170,26 +170,14 @@ async function check(name, fn) {assert.strictEqual(r.status, 404, r.text);});-- await check('payments: a CCBill sale cannot be pointed at a larger order, and replays credit once', async () => {++ 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 pay = require('../server/monetization/payments');-- const big = await db.createPaymentOrder({ user_id: 5, provider: 'ccbill', kind: 'bucks', amount_cents: 10000, bucks: 10000 });-- const other = await db.createPaymentOrder({ user_id: 5, provider: 'paypal', kind: 'bucks', amount_cents: 100, bucks: 100 });-- const bal = async () => (await db.getUserById(5)).openvibe_bucks_balance;-- const start = await bal();-- const hook = (q) => call('POST', `/api/payments/webhook/ccbill?secret=sek&${new URLSearchParams(q)}`, null, {});-- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess', billedInitialPrice: '1.00' });-- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess' });-- await hook({ 'X-order': other.id, eventType: 'NewSaleSuccess', billedInitialPrice: '1.00' });-- assert.strictEqual(await bal(), start, 'underpaid, unpriced or wrong-provider sale credited');-- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess', billedInitialPrice: '100.00' });-- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess', billedInitialPrice: '100.00' });-- assert.strictEqual(await bal(), start + 10000);-- // A stale copy read before an await (PayPal return vs. webhook) must not credit again.-- const stale = await db.getPaymentOrderById(other.id);-- assert.strictEqual(await pay.fulfillBucksOrder(await db.getPaymentOrderById(other.id)), true);-- assert.strictEqual(await pay.fulfillBucksOrder(stale), false);-- assert.strictEqual(await bal(), start + 10100);++ 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', () => {@@@ -1,6 +1,6 @@'use strict';/**-- * BILLING_AUTHORITY=billing (roadmap Wave 8, ADR-012): every Live money action goes to++ * 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@@@ -15,7 +15,6 @@ const http = require('http');const { startNetwork, startBilling } = require('./billing-stub');process.env.NODE_ENV = 'test';--process.env.BILLING_AUTHORITY = 'billing';process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';process.env.OV_BILLING_TIMEOUT_MS = '1500';@@@ -104,7 +103,7 @@ async function check(name, fn) {billing.fund(SID.ann, 3000);-- await check('the switch is read in one place and says billing', async () => {++ await check('Billing is the authority, and the client config says so', async () => {const money = require('../server/monetization/money-authority');assert.strictEqual(money.authority(), 'billing');const cfg = await call('GET', '/api/payments/config');@@@ -369,8 +368,6 @@ async function check(name, fn) {const r = await call('POST', `/api/payments/webhook/${p}`, null, {});assert.strictEqual(r.status, 410, p); assert.strictEqual(r.json.webhook_url, `https://billing.openvibe.network/webhooks/${name}`);}-- const rec = await require('../server/integrations/powerchat-reconcile').reconcileOnce();-- assert.strictEqual(rec.skipped, 'billing_authority');});await check('a missing Network grant fails clearly (misconfigured), nothing moves', async () => {@@@ -416,12 +413,10 @@ async function check(name, fn) {assert.strictEqual(st.json.authority, "billing"); assert.strictEqual(st.json.billing.reachable, false, JSON.stringify(st.json));});-- await check('the tripwire: Live\'s money columns cannot be written under billing', async () => {-- await assert.rejects(db.addVibes(2, 5), /read-only/);-- await assert.rejects(db.addVibesCashout(3, 5), /read-only/);-- await assert.rejects(db.createPaymentOrder({ user_id: 2, provider: 'powerchat' }), /read-only/);-- await assert.rejects(db.upsertSubscription({ subscriber_id: 2, streamer_id: 3 }), /read-only/);-- await assert.rejects(db.createTransaction({ to_user_id: 2, amount: 1, type: 'purchase' }), /read-only/);++ await check('Live has no way left to write its old money columns and tables', async () => {++ for (const fn of ['addVibes', 'deductVibes', 'addVibesCashout', 'deductVibesCashout', 'createPaymentOrder', 'updatePaymentOrder', 'upsertSubscription', 'setSubscriptionStatus', 'createTransaction']) {++ assert.strictEqual(db[fn], undefined, `database.js still has ${fn}`);++ }});await check('legacy balances are not shown as live (admin stats, admin user list)', async () => {@@@ -11,7 +11,6 @@ const assert = require('assert');const crypto = require('crypto');process.env.NODE_ENV = 'test';--delete process.env.BILLING_AUTHORITY;process.env.LIVE_EVENTS_SECRET = crypto.randomBytes(32).toString('hex');const quiet = console.log;console.log = () => {};@@@ -62,14 +61,9 @@ const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7Hsubscription: { id: `sub_${ulid()}`, status: 'expired', auto_renew: 1, cancel_at_period_end: 0, provider: null }, reason, ...extra,});-- // Live is the ledger: its own checkout and sweeper notify, so Billing's events change nothing.const purchase = settled('purchase', { metadata: { bits: 500, paid_cents: 500 } });-- assert.strictEqual(await post(purchase), 204);-- assert.strictEqual(await events.apply(purchase), 'ignored:live-ledger');-- assert.deepStrictEqual(sent, []);---- process.env.BILLING_AUTHORITY = 'billing';assert.strictEqual(await post(purchase, 'x'.repeat(64)), 401, 'a delivery signed with another secret is refused');++ assert.deepStrictEqual(sent, []);assert.strictEqual(await post(purchase), 204);assert.deepStrictEqual(sent.map((n) => [n.user_id, n.title, n.message]), [[2, 'Vibes credited 🎉', '500 Vibes were added to your balance ($5.00).']]);assert.strictEqual(await post(purchase), 204);ddeleted file mode 100644@@@ -1,133 +0,0 @@--'use strict';--/**-- * BILLING_AUTHORITY unset (= live, the default): the Billing client changes nothing. The money-- * routes move Live's own columns exactly as before, OpenVibe.Billing is never called (a stand-in-- * is running and counts calls), the Live webhooks still receive, and the billing_actions journal-- * stays empty. The rest of the suite (authorization, powerchat-*, migrations …) covers the-- * existing money behaviour unchanged.-- */--const assert = require('assert');--const http = require('http');--const { startNetwork, startBilling } = require('./billing-stub');----process.env.NODE_ENV = 'test';--delete process.env.BILLING_AUTHORITY;--process.env.OV_OAUTH_CLIENT_ID = 'live';--process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';--const quiet = console.log;--console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };--console.warn = () => {};----let failures = 0;--async function check(name, fn) {-- try { await fn(); quiet(' ✓', name); }-- catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }--}----(async () => {-- const network = await startNetwork();-- const billing = await startBilling({ network });-- process.env.OV_NETWORK_INTERNAL_URL = network.url;-- process.env.OV_BILLING_INTERNAL_URL = billing.url;---- const db = require('../server/db/database');-- 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);-- await addUser(1, 'owner', 'admin', { is_owner: 1 });-- await addUser(2, 'ann', 'user', { bucks: 1000 });-- await addUser(3, 'bob', 'streamer');-- // Subjects exist, so nothing but the switch keeps these actions on Live's columns.-- await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1'), (3, 'network', '103', 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2')").run();-- const cols = async (id) => { const u = await db.getUserById(id); return { b: u.openvibe_bucks_balance, c: u.openvibe_bucks_cashout_balance }; };---- 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; };-- auth.requireAuth = (req, res, next) => { signIn(req).then((u) => u ? next() : res.status(401).json({ error: 'Authentication required' })).catch(next); };-- auth.optionalAuth = (req, res, next) => { signIn(req).then(() => next()).catch(next); };-- const express = require('express');-- const app = express();-- app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));-- app.use((req, res, next) => { signIn(req).then(() => next()).catch(next); });-- app.use('/api/funds', require('../server/monetization/routes'));-- app.use('/api/payments', require('../server/monetization/payments-routes'));-- app.use('/api/admin', require('../server/admin/routes'));-- app.use('/api/powerchat', require('../server/integrations/powerchat-routes'));-- const server = http.createServer(app);-- await new Promise((r) => server.listen(0, '127.0.0.1', r));-- const call = async (method, p, user, body) => {-- const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) }, body: body ? JSON.stringify(body) : undefined });-- const text = await res.text();-- let json = null; try { json = JSON.parse(text); } catch { /* */ }-- return { status: res.status, json, text };-- };---- await check('the switch defaults to live', async () => {-- assert.strictEqual(require('../server/monetization/money-authority').authority(), 'live');-- assert.strictEqual((await call('GET', '/api/payments/config')).json.authority, 'live');-- });---- await check('donate moves Live\'s columns and writes the transactions row, as before', async () => {-- const r = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 150, message: 'gg' });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.json.balance, 850);-- assert.deepStrictEqual(await cols(2), { b: 850, c: 0 });-- assert.deepStrictEqual(await cols(3), { b: 0, c: 150 });-- const t = await raw.prepare("SELECT * FROM transactions WHERE type = 'donation'").get();-- assert.deepStrictEqual([t.from_user_id, t.to_user_id, t.amount, t.status], [2, 3, 150, 'completed']);-- const bad = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 99999 });-- assert.strictEqual(bad.status, 400); assert.strictEqual(bad.json.error, 'Insufficient Vibes');-- });---- await check('cashout request, owner approval and the balance read use Live\'s columns', async () => {-- await raw.prepare('UPDATE users SET openvibe_bucks_cashout_balance = 1000 WHERE id = 3').run();-- const r = await call('POST', '/api/funds/cashout', 3, { amount: 600, paypal_email: '[email protected]' });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.json.status, 'escrow'); assert.strictEqual(r.json.usd_value, '6.00');-- assert.deepStrictEqual(await cols(3), { b: 0, c: 400 });-- const pending = await call('GET', '/api/funds/cashouts/pending', 1);-- assert.strictEqual(pending.json.cashouts.length, 1);-- assert.strictEqual((await call('POST', `/api/funds/cashout/${r.json.transaction_id}/approve`, 1)).status, 200);-- const bal = await call('GET', '/api/funds/balance', 3);-- assert.deepStrictEqual({ b: bal.json.balance, c: bal.json.cashout_balance }, { b: 0, c: 400 });-- });---- await check('recycle behaves exactly as before (including the pre-existing transactions CHECK defect)', async () => {-- const r = await call('POST', '/api/funds/recycle', 3, { amount: 100 });-- // Economic inventory defect 7: 'recycle' is not in the transactions type CHECK, so the ledger-- // insert throws after both balances moved. Unchanged under `live`; Billing fixes it.-- assert.strictEqual(r.status, 400, r.text);-- assert.deepStrictEqual(await cols(3), { b: 100, c: 300 });-- });---- await check('subscribe with Vibes, the channel state and cancel use Live\'s subscriptions table', async () => {-- const r = await call('POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'bucks' });-- assert.strictEqual(r.status, 200, r.text);-- assert.deepStrictEqual(await cols(2), { b: 351, c: 0 });-- assert.deepStrictEqual(await cols(3), { b: 100, c: 649 });-- const ch = await call('GET', '/api/payments/channel/bob', 2);-- assert.deepStrictEqual([ch.json.subscribed, ch.json.subscriberCount], [true, 1]);-- assert.strictEqual(await db.isActiveSubscriber(2, 3), true);-- const sub = await raw.prepare('SELECT * FROM subscriptions').get();-- assert.strictEqual((await call('POST', `/api/payments/subscriptions/${sub.id}/cancel`, 2, {})).status, 200);-- assert.strictEqual((await raw.prepare('SELECT status FROM subscriptions WHERE id = ?').get(sub.id)).status, 'canceled');-- });---- await check('the PowerChat receiver still runs on Live (verifies, never 410)', async () => {-- const r = await call('POST', '/api/powerchat/webhook', null, { type: 'donation.completed', data: {} });-- assert.strictEqual(r.status, 401, r.text);-- });---- await check('OpenVibe.Billing was never called and no Billing journal exists', async () => {-- assert.strictEqual(billing.calls.length, 0);-- assert.strictEqual((await raw.prepare('SELECT COUNT(*) AS n FROM billing_actions').get()).n, 0, 'the migrated journal stays empty in live mode');-- });---- server.close();-- await billing.close();-- await network.close();-- quiet(failures ? `\n${failures} failed` : '\nall passed');-- process.exit(failures ? 1 : 0);--})().catch((e) => { quiet(e); process.exit(1); });@@@ -12,7 +12,6 @@ const http = require('http');const path = require('path');process.env.NODE_ENV = 'test';--delete process.env.BILLING_AUTHORITY;const quiet = console.log;console.log = () => {};console.warn = () => {};@@@ -33,6 +32,8 @@ console.warn = () => {};delivery.message = async () => 1;delivery.event = async () => {};++ // OpenVibe.Billing moves the Vibes (a stand-in here): the route records the tip under Billing's transaction.++ require('../server/monetization/billing-actions').donate = async (req, { amount }) => ({ amount, transactionId: 'txn_01DONATION', balance: 1850, goal: null, goalReached: null });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; };auth.requireAuth = (req, res, next) => { signIn(req).then((u) => (u ? next() : res.status(401).json({ error: 'Authentication required' }))).catch(next); };@@@ -49,11 +50,11 @@ console.warn = () => {};const tip = (ref) => raw.prepare('SELECT to_user_id, from_user_id, username, stream_id, amount, source FROM channel_tips WHERE ref = ?').get(ref);const count = async () => Number((await raw.prepare('SELECT COUNT(*) AS n FROM channel_tips').get()).n);-- // An on-site Vibes donation (Live is the ledger here): one ledger row, one tip named after it.++ // An on-site Vibes donation (Billing moved it): one tip named after Billing's transaction.const d = await donate(2, { streamer_id: 3, stream_id: streamId, amount: 150, message: 'gg' });assert.strictEqual(d.status, 200, JSON.stringify(d.json));-- const txn = await raw.prepare("SELECT id FROM transactions WHERE type = 'donation' ORDER BY id DESC LIMIT 1").get();-- assert.deepStrictEqual(await tip(`live-txn:${txn.id}`), { to_user_id: 3, from_user_id: 2, username: null, stream_id: streamId, amount: 150, source: 'vibes' });++ assert.strictEqual(d.json.balance, 1850, "the donor's balance is Billing's");++ assert.deepStrictEqual(await tip('billing-txn:txn_01DONATION'), { to_user_id: 3, from_user_id: 2, username: null, stream_id: streamId, amount: 150, source: 'vibes' });// A tip on bob's own PowerChat: no Live account, so the name is kept; the same event twice is one tip.const powerchat = require('../server/integrations/powerchat-webhook');@@@ -95,7 +96,7 @@ console.warn = () => {};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, 'the donation the route already recorded is not copied again');++ assert.strictEqual(await count(), 5, 'one copy of each completed donation');server.close();quiet('channel tips: all checks passed');@@@ -406,21 +406,25 @@ const waitFor = async (pred, what, ms = 3000) => {assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: streamer, sources: { ffz: true, bttv: false } } })).status, 200);assert.strictEqual((await db.getChannelByUserId(streamer)).emote_sources, JSON.stringify({ ffz: true, bttv: false }));-- // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel:-- // by user id or Network subject; a lapsed period or a cancelled row is not one.++ // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel, by user id or++ // Network subject. OpenVibe.Billing answers (billing-actions.js; a stand-in here): a lapsed or cancelled one is not.++ const ba = require('../server/monetization/billing-actions');++ const held = new Set();++ const refreshed = [];++ ba.refreshEntitlement = async (u, st) => { refreshed.push(`${u}:${st}`); };++ ba.isSubscriberCached = (u, st) => held.has(`${u}:${st}`);const subOf = (q) => call('GET', `/internal/chat-context/subscriber?${q}`, { token: READ });assert.strictEqual((await call('GET', `/internal/chat-context/subscriber?user_id=${viewer}&streamer_id=${streamer}`, { token: WRITE })).status, 403, 'a read capability');assert.strictEqual((await subOf(`streamer_id=${streamer}`)).status, 400);assert.deepStrictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body, { subscriber: false, user_id: viewer, streamer_id: streamer });-- await db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() + 86400e3).toISOString() });++ held.add(`${viewer}:${streamer}`);assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, true);++ assert.ok(refreshed.includes(`${viewer}:${streamer}`), 'read fresh from Billing');assert.strictEqual((await subOf(`subject=usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, true, 'by Network subject');assert.strictEqual((await subOf(`subject=usr_01J9UNKNOWNZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, false, 'an unknown subject');assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${mod}`)).body.subscriber, false, 'another channel');-- await db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() - 60e3).toISOString() });-- assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'the paid period is over');-- await db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'canceled', current_period_end: new Date(Date.now() + 86400e3).toISOString() });-- assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'not active');++ held.delete(`${viewer}:${streamer}`);++ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'Billing says it is over');// Alert sounds moved to Chat with channel_moderation_settings (roadmap T3): Chat resolves the// sound from its own row and plays it on an `alert` event (test/chat-ingress.test.js), so Live's effect is gone.@@@ -1,19 +1,14 @@'use strict';/**-- * money_writes_frozen — step 3 of the Live → Billing cutover runbook. In BOTH modes it refuses-- * every money action a person or an operator starts on Live (checkout, donation, cashout request,-- * approve/deny, recycle, subscribe, cancel, Vibes media request and refund, renewal sweep) while++ * money_writes_frozen: refuses every money action a person or an operator starts on Live (checkout, donation, cashout++ * request, recycle, subscribe, cancel, Vibes media request and refund) before OpenVibe.Billing is ever called, while* reads keep working. Only the owner can set it; every admin can see it at /api/admin/money.-- * Provider confirmations of checkouts started before the freeze still settle in `live` mode (the-- * provider already took that money). A BILLING_AUTHORITY that is neither live nor billing refuses-- * every money write too.*/const assert = require('assert');const http = require('http');const { startNetwork, startBilling } = require('./billing-stub');process.env.NODE_ENV = 'test';--delete process.env.BILLING_AUTHORITY;process.env.OV_OAUTH_CLIENT_ID = 'live';process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';const quiet = console.log;@@@ -49,13 +44,6 @@ async function check(name, fn) {await db.setSetting('powerchat_enabled', 'true');await db.setSetting('powerchat_client_id', 'pca_test');await db.setSetting('powerchat_site_tip_username', 'sitepc');-- const snapshot = async () => ({-- users: await raw.prepare('SELECT id, openvibe_bucks_balance AS b, openvibe_bucks_cashout_balance AS c FROM users ORDER BY id').all(),-- tx: (await raw.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,-- orders: (await raw.prepare('SELECT COUNT(*) AS n FROM payment_orders').get()).n,-- subs: await raw.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS u, COALESCE(string_agg(status::text, \',\'), \'\') AS s FROM subscriptions').get(),-- });--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; };auth.requireAuth = (req, res, next) => { signIn(req).then((u) => u ? next() : res.status(401).json({ error: 'Authentication required' })).catch(next); };@@@ -83,8 +71,6 @@ async function check(name, fn) {['POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 }],['POST', '/api/funds/cashout', 3, { amount: 600, paypal_email: '[email protected]' }],['POST', '/api/funds/recycle', 3, { amount: 10 }],-- ['POST', '/api/funds/cashout/1/approve', 1, {}],-- ['POST', '/api/funds/cashout/1/deny', 1, {}],['POST', '/api/payments/bucks/checkout', 2, { provider: 'powerchat', bucks: 500 }],['POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'bucks' }],['POST', '/api/payments/subscriptions/1/cancel', 2, {}],@@@ -103,79 +89,43 @@ async function check(name, fn) {assert.strictEqual((await call('POST', '/api/admin/money/freeze', 2, { on: true })).status, 403);assert.strictEqual((await call('PUT', '/api/admin/settings/money_writes_frozen', 4, { value: 'true' })).status, 403, 'not through the generic settings either');assert.strictEqual(await money.isFrozen(), false);-- const r = await call('POST', '/api/admin/money/freeze', 1, { on: true, reason: 'Billing cutover step 3' });++ const r = await call('POST', '/api/admin/money/freeze', 1, { on: true, reason: 'maintenance' });assert.strictEqual(r.status, 200, r.text);assert.strictEqual(r.json.frozen, true);const seen = await call('GET', '/api/admin/money', 4);-- assert.deepStrictEqual([seen.json.authority, seen.json.frozen, seen.json.reason, seen.json.by], ['live', true, 'Billing cutover step 3', 'owner']);++ assert.deepStrictEqual([seen.json.authority, seen.json.frozen, seen.json.reason, seen.json.by], ['billing', true, 'maintenance', 'owner']);assert.strictEqual((await call('GET', '/api/admin/money', 2)).status, 403, 'not for non-admins');assert.strictEqual((await call('GET', '/api/payments/config')).json.moneyFrozen, true);});-- await check('live mode, frozen: every money write is refused and nothing moves', async () => {-- const before = await snapshot();-- // A due auto-renewing subscription: the sweep must not renew or expire it while frozen.-- await raw.prepare("INSERT INTO subscriptions (subscriber_id, streamer_id, tier, provider, price_cents, status, is_active, current_period_end, auto_renew) VALUES (2, 3, 1, 'bucks', 499, 'active', 1, datetime('now', '-1 hour'), 1)").run();-- const withSub = await snapshot();-- await expectAllRefused('live');-- await require('../server/monetization/payments').startRenewalSweeper(); // sweeps once immediately-- assert.deepStrictEqual(await snapshot(), withSub, 'no column, ledger, order or subscription change');-- assert.notDeepStrictEqual(before, withSub);++ await check('frozen: every money write is refused before Billing is ever called', async () => {++ billing.fund(SID_ANN, 1000);++ const n = billing.calls.length;++ await expectAllRefused('frozen');++ assert.strictEqual(billing.calls.length, n, 'zero Billing calls while frozen');});-- await check('live mode, frozen: a Vibes media refund is deferred (not lost, not marked refunded)', async () => {++ await check('frozen: a Vibes media refund is deferred (not lost, not marked refunded)', async () => {const id = Number((await db.run("INSERT INTO media_requests (streamer_id, user_id, username, input, canonical_url, provider, title, duration_seconds, cost, queue_position, currency) VALUES (3, 2, 'ann', 'x', 'https://x.test/1', 'youtube', 't', 60, 10, 1, 'vibes') RETURNING id")).lastInsertRowid);assert.strictEqual(await mediaQueue.refund(id), 0);assert.strictEqual((await db.getMediaRequestById(id)).refunded, 0);});-- await check('live mode, frozen: reads work', async () => {++ await check('frozen: reads are served (from Billing)', async () => {const b = await call('GET', '/api/funds/balance', 2);-- assert.strictEqual(b.status, 200); assert.strictEqual(b.json.balance, 2000);++ assert.strictEqual(b.status, 200, b.text);assert.strictEqual((await call('GET', '/api/funds/history', 2)).status, 200);assert.strictEqual((await call('GET', '/api/payments/channel/bob', 2)).status, 200);});-- await check('live mode, frozen: a checkout started before the freeze still settles when the provider confirms it', async () => {-- await money.setFrozen(false);-- const order = await db.createPaymentOrder({ user_id: 2, provider: 'powerchat', kind: 'bucks', amount_cents: 500, bucks: 500 });-- await money.setFrozen(true, { reason: 'x', by: 'test' });-- const consumed = await require('../server/integrations/powerchat-checkout').handleAttributedDonation(null, { appExternalRef: `pcorder:${order.id}`, amountUsdCents: 500 });-- assert.strictEqual(consumed, true);-- assert.strictEqual((await db.getPaymentOrderById(order.id)).status, 'credited');-- assert.strictEqual((await db.getUserById(2)).openvibe_bucks_balance, 2500);-- });---- await check('unfreeze: money moves again', async () => {++ await check('unfreeze: money moves again, through Billing', async () => {const r = await call('POST', '/api/admin/money/freeze', 1, { on: false });assert.strictEqual(r.json.frozen, false);const d = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });assert.strictEqual(d.status, 200, d.text);-- });---- await check('billing mode, frozen: refused before Billing is ever called; unfrozen: Billing is called', async () => {-- process.env.BILLING_AUTHORITY = 'billing';-- billing.fund(SID_ANN, 1000);-- await money.setFrozen(true, { reason: 'cutover', by: 'test' });-- const n = billing.calls.length;-- await expectAllRefused('billing');-- assert.strictEqual(billing.calls.length, n, 'zero Billing calls while frozen');-- assert.strictEqual((await call('GET', '/api/funds/balance', 2)).status, 200, 'reads still served (from Billing)');-- await money.setFrozen(false);-- const d = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });-- assert.strictEqual(d.status, 200, d.text);assert.strictEqual(billing.calls[billing.calls.length - 1].path, '/transfers');});-- await check('a BILLING_AUTHORITY typo refuses money writes instead of guessing', async () => {-- process.env.BILLING_AUTHORITY = 'bilIing';-- assert.strictEqual(money.authority(), 'invalid');-- const r = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });-- assert.strictEqual(r.status, 503); assert.strictEqual(r.json.code, 'billing_misconfigured');-- await assert.rejects(db.addVibes(2, 1), /not 'live' or 'billing'/);-- delete process.env.BILLING_AUTHORITY;-- });--server.close();await billing.close();await network.close();@@@ -31,6 +31,10 @@ const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|// now answer 404, and every one of them was already wrapped to fail quietly (the controls panel, the dashboard's// controls and ONVIF cards, the broadcast profile pickers), so they are not this release's to answer either.const RETIRED_PATHS = /^\/api\/(controls|onvif)(\/|$)/;++// Vibes balances and history are OpenVibe.Billing's (the money ledger since 2026-10-17). The harness runs in drill mode,++// where every other service looks down, so this release answers them 503 "unavailable"; the recorded N-1 answered from++// Live's old columns. In production both releases read them from Billing.++const BILLING_READS = /^\/api\/funds\/(balance|history)$/;function freePort() {return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });@@@ -80,7 +84,7 @@ module.exports = {{ name: 'navigator.sendBeacon', method: 'POST' },],strip: ['API', 'location.origin', 'window.location.origin'],-- keep: (pathname) => !CHAT_PATHS.test(pathname) && !RETIRED_PATHS.test(pathname),++ keep: (pathname) => !CHAT_PATHS.test(pathname) && !RETIRED_PATHS.test(pathname) && !BILLING_READS.test(pathname),/** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */crawl: ['/', '/@n1star'],origins: ['https://openvibe.live'],@@@ -110,16 +110,16 @@ const mint = (opts) => checkout.mintCheckoutLink('alex', 'pcsub:42', { purpose:r = await mint({ amountCents: 10 });assert.ok(!('amount_cents' in api.calls.at(-1).query));-- // ── builders: subscription persists route + renew flag and returns expiry ──++ // ── builders for OpenVibe.Billing's intents: the checkout_ref rides as the ref, the label names what is bought ──api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK5', expiresAt: '2026-08-27T12:00:00.000Z' } } });-- const sub = await checkout.buildSubscribeLink(orders.get(42), 1, { autoRenew: 1, route: 'direct' });++ const sub = await checkout.buildBillingSubscribeLink({ checkout_ref: 'pcsub:pi_SUB', amount_cents: 499, route: 'direct', auto_renew: 1 }, 1);assert.strictEqual(sub.mode, 'direct'); assert.strictEqual(sub.minted, true); assert.strictEqual(sub.expiresAt, '2026-08-27T12:00:00.000Z');-- assert.strictEqual(orders.get(42).provider_ref, 'direct:renew');-- assert.strictEqual(api.calls.at(-1).query.ref, 'pcsub:42');++ assert.strictEqual(api.calls.at(-1).query.ref, 'pcsub:pi_SUB');assert.strictEqual(api.calls.at(-1).query.item_name, 'goosely — 1 month subscription (auto-renew)');api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK6' } } });-- const buy = await checkout.buildPurchaseLink(orders.get(41));++ const buy = await checkout.buildBillingPurchaseLink({ checkout_ref: 'pcorder:pi_BUY', bits: 500, amount_cents: 500 });assert.strictEqual(buy.mode, 'site'); assert.strictEqual(buy.minted, true);++ assert.strictEqual(api.calls.at(-1).query.ref, 'pcorder:pi_BUY');assert.strictEqual(api.calls.at(-1).query.item_name, '500 Vibes');assert.strictEqual(api.calls.at(-1).query.purpose, 'vibes');ddeleted file mode 100644@@@ -1,121 +0,0 @@--// /paid-messages reconciliation: finds paid-but-uncredited checkouts by appExternalRef,--// credits through the same idempotent fulfillment the webhook uses, pages on--// nextCursor (both envelope shapes), skips echoes / test rows / hosts without the--// scope, and makes no API call when nothing is pending.--const assert = require('assert');--const path = require('path');--function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }----const settings = { powerchat_site_tip_username: 'alex', powerchat_allow_test_fulfillment: 'false' };--const orders = new Map();--const seed = () => {-- orders.clear();-- orders.set(41, { id: 41, user_id: 7, provider: 'powerchat', kind: 'bucks', amount_cents: 500, bucks: 500, status: 'pending', created_at: '2026-08-27 09:00:00' });-- orders.set(42, { id: 42, user_id: 8, provider: 'powerchat', kind: 'subscription', amount_cents: 500, streamer_id: 1, provider_ref: 'direct:renew', status: 'pending', created_at: '2026-08-27 09:05:00' });-- orders.set(43, { id: 43, user_id: 9, provider: 'powerchat', kind: 'subscription', amount_cents: 500, streamer_id: 3, provider_ref: 'direct', status: 'pending', created_at: '2026-08-27 09:06:00' });--};--const conns = {-- 1: { user_id: 1, powerchat_username: 'alex', access_token: 'a', refresh_token: 'r', scope: 'checkout:attribute paid_messages:read' },-- 3: { user_id: 3, powerchat_username: 'other', access_token: 'a', refresh_token: 'r', scope: 'checkout:attribute' }, // no paid_messages:read--};--const notes = [];--stub(path.join(__dirname, '../server/db/database'), {-- getSetting: (k) => settings[k] ?? null,-- getPendingPowerchatOrders: () => [...orders.values()].filter((o) => o.status === 'pending'),-- getPowerchatConnection: (id) => conns[id] || null,-- getPowerchatConnectionByUsername: (u) => Object.values(conns).find((c) => c.powerchat_username === String(u).toLowerCase()) || null,-- getPaymentOrderById: (id) => orders.get(id) || null,-- updatePaymentOrder: (id, f) => Object.assign(orders.get(id), f),-- getUserById: (id) => ({ id, username: 'u' + id, display_name: 'U' + id }),--});--stub(path.join(__dirname, '../server/config'), { baseUrl: 'https://openvibe.live' });--stub(path.join(__dirname, '../server/monetization/payments'), {-- bucksForUsd: (usd) => Math.round(usd * 100),-- fulfillBucksOrder: (o) => { orders.get(o.id).status = 'credited'; },-- fulfillSubscriptionOrder: (o, opts) => { orders.get(o.id).status = 'credited'; orders.get(o.id)._opts = opts; },--});--stub(path.join(__dirname, '../server/utils/notify'), { pushNotification: (n) => notes.push(n) });--const api = { calls: [], pages: [] };--stub(path.join(__dirname, '../server/integrations/powerchat-oauth'), {-- getConfig: () => ({ enabled: true, clientId: 'pca', baseUrl: 'https://pc.example', apiBase: 'https://pc.example/api/dev/v1' }),-- redirectUri: () => 'https://openvibe.live/cb',-- apiRequest: async (userId, req) => { api.calls.push({ userId, ...req }); const p = api.pages.shift(); if (!p) throw new Error('unscripted'); if (p.throw) throw Object.assign(new Error('x'), { status: p.throw }); return p; },--});--const reconcile = require('../server/integrations/powerchat-reconcile');----(async () => {-- // ── nothing pending → nothing read ──-- let sum = await reconcile.reconcileOnce();-- assert.strictEqual(sum.orders, 0); assert.strictEqual(api.calls.length, 0);---- // ── envelope parsing (corrected + legacy shapes) ──-- assert.deepStrictEqual(reconcile._test.page({ data: { rows: [{ a: 1 }], nextCursor: 'c1' } }), { rows: [{ a: 1 }], nextCursor: 'c1' });-- assert.deepStrictEqual(reconcile._test.page({ data: { rows: [] }, nextCursor: 'c2' }), { rows: [], nextCursor: 'c2' }, 'older deployments left nextCursor outside data');-- assert.deepStrictEqual(reconcile._test.page({ data: [{ b: 2 }] }), { rows: [{ b: 2 }], nextCursor: null });-- assert.deepStrictEqual(reconcile._test.page(null), { rows: [], nextCursor: null });---- // ── the main sweep: 3 pending orders, two hosts ──-- seed();-- api.calls.length = 0;-- // Host alex: page 1 (legacy cursor placement) → page 2 (corrected).-- api.pages.push({ data: { rows: [-- { eventId: 'e-other', occurredAt: '2026-08-27T09:30:00Z', source: 'powerchat', amountCents: 300, amountUsdCents: 300, appExternalRef: null },-- { eventId: 'e-echo', occurredAt: '2026-08-27T09:20:00Z', source: 'developer_app', amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcorder:41' },-- { eventId: 'e-test', occurredAt: '2026-08-27T09:19:00Z', source: 'powerchat', isTest: true, amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcorder:41' },-- // Real feed rows have NO isTest field; manual_test is PowerChat's no-provider test tip → must be treated as test.-- { eventId: 'e-manual', occurredAt: '2026-08-27T09:18:00Z', source: 'manual_test', amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcorder:41' },-- { eventId: 'e-41', occurredAt: '2026-08-27T09:10:00Z', source: 'powerchat', donorName: 'Pat', amountCents: 500, currency: 'USD', amountUsdCents: 500, appExternalRef: 'pcorder:41' },-- ] }, nextCursor: 'cur-2' });-- api.pages.push({ data: { rows: [-- { eventId: 'e-42', occurredAt: '2026-08-27T09:08:00Z', source: 'powerchat', donorName: 'Sam', amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcsub:42' },-- ], nextCursor: 'cur-3' } });-- sum = await reconcile.reconcileOnce();-- assert.strictEqual(sum.orders, 3);-- assert.strictEqual(sum.hosts, 1, 'alex hosts both 41 (site) and 42 (direct, streamer 1 = alex)');-- assert.strictEqual(sum.skippedHosts, 1, "'other' has no paid_messages:read → skipped, no call");-- assert.strictEqual(sum.pages, 2, 'stopped after page 2: no orders left for that host despite a cursor');-- assert.strictEqual(sum.skippedTest, 2, 'explicit isTest AND source=manual_test are both skipped');-- assert.deepStrictEqual(sum.credited.map((c) => c.ref).sort(), ['pcorder:41', 'pcsub:42']);-- assert.deepStrictEqual(sum.underpaid, []);-- assert.strictEqual(orders.get(41).status, 'credited');-- assert.strictEqual(orders.get(42).status, 'credited');-- assert.strictEqual(orders.get(42)._opts.autoRenew, 1, 'renew flag read off provider_ref as the webhook path does');-- assert.strictEqual(orders.get(42)._opts.creditShare, false, 'direct: streamer already holds the cash');-- assert.strictEqual(orders.get(43).status, 'pending');-- const reads = api.calls.filter((c) => c.path === '/paid-messages');-- assert.strictEqual(reads.length, 2);-- assert.strictEqual(reads[0].userId, 1); assert.strictEqual(reads[0].username, 'alex');-- assert.deepStrictEqual(reads[0].query, { limit: 100 });-- assert.deepStrictEqual(reads[1].query, { limit: 100, cursor: 'cur-2' }, 'legacy top-level nextCursor was honoured');-- assert.strictEqual(notes.length, 2, 'buyers are notified exactly like a webhook credit');---- // ── idempotent: run again → credited orders are no longer pending → only 43 remains, host skipped ──-- api.calls.length = 0;-- sum = await reconcile.reconcileOnce();-- assert.strictEqual(sum.orders, 1); assert.strictEqual(api.calls.length, 0); assert.strictEqual(sum.credited.length, 0);---- // ── underpaid sub (direct route): not credited, reported, order stays pending ──-- // (a Vibes pack credits whatever was actually paid, so only subs have this guard)-- seed(); orders.delete(41); orders.delete(43);-- api.calls.length = 0; api.pages.length = 0;-- api.pages.push({ data: { rows: [{ eventId: 'e-low', occurredAt: '2026-08-27T09:10:00Z', source: 'powerchat', amountCents: 100, amountUsdCents: 100, appExternalRef: 'pcsub:42' }], nextCursor: null } });-- sum = await reconcile.reconcileOnce();-- assert.strictEqual(sum.credited.length, 0);-- assert.deepStrictEqual(sum.underpaid.map((u) => u.ref), ['pcsub:42']);-- assert.strictEqual(orders.get(42).status, 'pending');---- // ── a paging stop: rows older than the oldest pending order (minus grace) end the walk ──-- seed(); orders.delete(42); orders.delete(43);-- api.calls.length = 0; api.pages.length = 0;-- api.pages.push({ data: { rows: [{ eventId: 'e-ancient', occurredAt: '2026-08-20T00:00:00Z', source: 'powerchat', amountCents: 1, amountUsdCents: 1, appExternalRef: null }], nextCursor: 'more' } });-- sum = await reconcile.reconcileOnce();-- assert.strictEqual(sum.pages, 1, 'did not follow the cursor into rows that predate every pending order');---- // ── read failure is contained: summary.errors, nothing credited, no throw ──-- seed(); orders.delete(42); orders.delete(43);-- api.pages.length = 0; api.pages.push({ throw: 429 });-- sum = await reconcile.reconcileOnce();-- assert.strictEqual(sum.errors, 1); assert.strictEqual(orders.get(41).status, 'pending');---- console.log('✅ powerchat reconcile tests passed');--})().catch((e) => { console.error('❌', e); process.exit(1); });ddeleted file mode 100644@@@ -1,65 +0,0 @@--'use strict';--// A PowerChat checkout reference (pcorder/pcsub/pcdon) rides in the tip link, so the buyer can edit it.--// It must only count when the money landed where that checkout sends it: the SITE tips account for--// purchases, site-routed donations and site-routed subscriptions; the subscribed streamer's own--// account for a direct subscription. A streamer tipping their own PowerChat with `pcdon:<self>`, or a--// viewer tipping their own account with `pcsub:<x>`, must fall through to ordinary tip handling.--const assert = require('assert');--const path = require('path');--function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }----const orders = new Map();--const credited = { cashout: [], bucks: [], subs: [] };--const seed = () => {-- orders.clear();-- orders.set(41, { id: 41, kind: 'bucks', amount_cents: 500, bucks: 500, user_id: 7, status: 'pending' });-- orders.set(42, { id: 42, kind: 'subscription', amount_cents: 500, user_id: 8, streamer_id: 3, provider_ref: 'direct', status: 'pending' });-- orders.set(43, { id: 43, kind: 'subscription', amount_cents: 500, user_id: 9, streamer_id: 3, provider_ref: 'site', status: 'pending' });-- credited.cashout.length = 0; credited.bucks.length = 0; credited.subs.length = 0;--};--stub(path.join(__dirname, '../server/db/database'), {-- getSetting: (k) => (k === 'powerchat_site_tip_username' ? 'siteacct' : null),-- getPaymentOrderById: (id) => orders.get(id) || null,-- updatePaymentOrder: (id, f) => Object.assign(orders.get(id), f),-- getUserById: (id) => ({ id, username: 'u' + id }),-- addVibesCashout: (uid, cents) => credited.cashout.push([uid, cents]),-- createTransaction: () => {},-- getPowerchatConnectionByUsername: () => null,-- getPowerchatConnection: () => null,--});--stub(path.join(__dirname, '../server/monetization/payments'), {-- bucksForUsd: (usd) => Math.round(usd * 100),-- fulfillBucksOrder: (o) => { credited.bucks.push(o.id); orders.get(o.id).status = 'credited'; },-- fulfillSubscriptionOrder: (o) => { credited.subs.push(o.id); orders.get(o.id).status = 'credited'; },--});--stub(path.join(__dirname, '../server/integrations/powerchat-webhook'), { creditDonationPipeline: () => {} });--stub(path.join(__dirname, '../server/utils/notify'), { pushNotification: () => {} });--const checkout = require('../server/integrations/powerchat-checkout');--const log = console.warn; console.warn = () => {};--const tip = (ref, cents = 500) => ({ appExternalRef: ref, amountUsdCents: cents });---- (async () => {--seed();--// Exploit: streamer 3 tips their OWN PowerChat with a site-routed donation ref naming themselves.--assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcdon:3:0'), { viaSiteAccount: false }), false, 'pcdon on a streamer account is an ordinary tip');--assert.deepStrictEqual(credited.cashout, [], 'no cashout Vibes minted');--// Exploit: a purchase ref on a streamer account credits no Vibes.--assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcorder:41'), { viaSiteAccount: false }), false);--assert.deepStrictEqual(credited.bucks, []); assert.strictEqual(orders.get(41).status, 'pending');--// Exploit: viewer 9 tips their own account (user 9) with a direct sub ref for streamer 3.--assert.strictEqual(await checkout.handleAttributedDonation(9, tip('pcsub:42'), { viaSiteAccount: false }), false, 'direct sub must be paid to the subscribed streamer');--// A site-routed sub ref paid to a streamer account is not a subscription either.--assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcsub:43'), { viaSiteAccount: false }), false);--assert.deepStrictEqual(credited.subs, []);----// Legitimate paths still work.--assert.strictEqual(await checkout.handleAttributedDonation(null, tip('pcorder:41')), true, 'purchase on the site account');--assert.deepStrictEqual(credited.bucks, [41]);--assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcsub:42'), { viaSiteAccount: false }), true, 'direct sub paid to streamer 3');--assert.strictEqual(await checkout.handleAttributedDonation(1, tip('pcsub:43'), { viaSiteAccount: true }), true, 'site sub on the site account (even when the site account is a connected user)');--assert.deepStrictEqual(credited.subs, [42, 43]);--assert.strictEqual(await checkout.handleAttributedDonation(null, tip('pcdon:3:7')), true, 'site-routed donation on the site account');--assert.deepStrictEqual(credited.cashout, [[3, 500]]);--console.warn = log;--console.log('✅ PowerChat checkout refs count only on the account the checkout routes to');--})().catch((e) => { console.error(e); process.exit(1); });
@@ -185,12 +185,9 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
@@ -185,12 +185,9 @@ OV_CHAT_INTERNAL_URL=http://127.0.0.1:4400
# Voice/video calls are OpenVibe.Chat's (no setting): nginx sends /ws/call and the call routes there, and Live
# Voice/video calls are OpenVibe.Chat's (no setting): nginx sends /ws/call and the call routes there, and Live
# creates/removes stream voice channels in Chat on go-live and stream end (server/streaming/calls-authority.js).
# creates/removes stream voice channels in Chat on go-live and stream end (server/streaming/calls-authority.js).
-# Money authority (roadmap Wave 8, ADR-012). live (default): Live's own Vibes columns, exactly as
-# before. billing: OpenVibe.Billing is the ledger — Live calls it for checkouts, donations, cashouts,
-# recycling, subscriptions, balances and entitlements with its service token (audience
-# openvibe.billing) and never writes its legacy money columns; the provider/PowerChat webhooks on
-# Live answer 410. Unset = live. Cutover procedure: docs/live-cutover.md in OpenVibe.Billing.
-#BILLING_AUTHORITY=billing
+# OpenVibe.Billing is the money ledger (since 2026-10-17; ADR-012): Live calls it for checkouts, donations, cashouts,
+# recycling, subscriptions, balances and entitlements with its service token (audience openvibe.billing). There is no
+# other mode (BILLING_AUTHORITY is no longer read).
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_INTERNAL_URL=http://127.0.0.1:4600
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
@@ -4,7 +4,7 @@
@@ -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.
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 local `comments` table is READ-ONLY too; 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 are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); 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:** `BILLING_AUTHORITY` (read only in [server/monetization/money-authority.js](server/monetization/money-authority.js)) is `live` by default; `billing` makes OpenVibe.Billing the ledger ([billing-client.js](server/monetization/billing-client.js), [billing-actions.js](server/monetization/billing-actions.js)) and Live's `openvibe_bucks_*`/`transactions`/`payment_orders`/`subscriptions` read-only (a database-level tripwire throws). The owner-only `money_writes_frozen` flag (`/api/admin/money`) refuses money actions in both modes. **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 local `comments` table is READ-ONLY too; 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 are dropped at boot by migration `007_drop_chat_tables` (`test/chat-tables-dropped.test.js` guards that nothing under `server/` names them); 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.
## Commands
## Commands
@@ -69,7 +69,7 @@ The media subsystem lives in **OpenVibe.Media**:
@@ -69,7 +69,7 @@ The media subsystem lives in **OpenVibe.Media**:
- Channels and streams: go-live, stream keys, managed streams, heartbeats, channel pages (`/@username`), offline screens, panels and goals.
- Channels and streams: go-live, stream keys, managed streams, heartbeats, channel pages (`/@username`), offline screens, panels and goals.
- The watch experience: the SPA in `public/`, the chat surface and its effects (TTS, sounds, emotes, overlays), anonymous chat identities.
- The watch experience: the SPA in `public/`, the chat surface and its effects (TTS, sounds, emotes, overlays), anonymous chat identities.
- Streamer tools: the dashboard, restreaming (via OpenRestream for managed ingest), song requests (watch party), AI viewers (the context Live builds; the runs are OpenVibe.AI's).
- Streamer tools: the dashboard, restreaming (via OpenRestream for managed ingest), song requests (watch party), AI viewers (the context Live builds; the runs are OpenVibe.AI's).
-- Channel points, Vibes tipping flows (the ledger moves to OpenVibe.Billing when `BILLING_AUTHORITY=billing`), moderation of Live's own surfaces, the Arena (ARENA ALERT: the drama desk on streamer activity, the Lolcow Index and Clout Rankings, beefs from the mic; docs/arena.md) and after-show recaps. Live transcripts come from NVIDIA Parakeet TDT on the server (whisper for other languages).
+- Channel points, Vibes tipping flows (OpenVibe.Billing keeps the ledger since 2026-10-17), moderation of Live's own surfaces, the Arena (ARENA ALERT: the drama desk on streamer activity, the Lolcow Index and Clout Rankings, beefs from the mic; docs/arena.md) and after-show recaps. Live transcripts come from NVIDIA Parakeet TDT on the server (whisper for other languages).
- Live-local state: PostgreSQL `ov_live` (users' Live profiles, streams, channel state, AI state for Media-hosted recordings in `vod_ai_state` / `clip_ai_state`, request analytics).
- Live-local state: PostgreSQL `ov_live` (users' Live profiles, streams, channel state, AI state for Media-hosted recordings in `vod_ai_state` / `clip_ai_state`, request analytics).
## Does not own
## Does not own
@@ -233,10 +233,10 @@ through the SDK inbox (`idempotency_receipts`):
@@ -233,10 +233,10 @@ through the SDK inbox (`idempotency_receipts`):
| `billing.transaction.settled`, `billing.entitlement.changed` | `/internal/billing-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --billing` |
| `billing.transaction.settled`, `billing.entitlement.changed` | `/internal/billing-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --billing` |
| `tips.interaction.ready` | `/internal/tips-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --tips` |
| `tips.interaction.ready` | `/internal/tips-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --tips` |
-Billing's events act only under `BILLING_AUTHORITY=billing` ([server/monetization/billing-events.js](../server/monetization/billing-events.js)):
-"Vibes credited" to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's
-PowerChat overlay when PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the
-subscription notices Live's renewal sweeper sent in `live` mode (could not auto-renew, renewal failed, ended).
+Billing's events ([server/monetization/billing-events.js](../server/monetization/billing-events.js)): "Vibes credited"
+to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's PowerChat overlay when
+PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the subscription notices (could not
+auto-renew, renewal failed, ended).
OpenVibe.Tips posts its own chat lines to OpenVibe.Chat, so a tip it settled (from openvibe.tips, or on a streamer's own
OpenVibe.Tips posts its own chat lines to OpenVibe.Chat, so a tip it settled (from openvibe.tips, or on a streamer's own
PowerChat through Billing) reaches Live only as `tips.interaction.ready` ([server/tips/tips-events.js](../server/tips/tips-events.js)):
PowerChat through Billing) reaches Live only as `tips.interaction.ready` ([server/tips/tips-events.js](../server/tips/tips-events.js)):
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* GET /api/admin/bans - List all bans
* GET /api/admin/bans - List all bans
* GET /api/admin/vpn-queue - VPN approval queue
* GET /api/admin/vpn-queue - VPN approval queue
* PUT /api/admin/vpn-queue/:id - Approve/deny VPN
* PUT /api/admin/vpn-queue/:id - Approve/deny VPN
- * GET /api/admin/money - Money authority (BILLING_AUTHORITY), freeze state, Billing health
+ * GET /api/admin/money - Money authority (OpenVibe.Billing), freeze state, Billing health
* POST /api/admin/money/freeze - Owner: freeze/unfreeze Live money writes { on, reason }
* POST /api/admin/money/freeze - Owner: freeze/unfreeze Live money writes { on, reason }
* POST /api/admin/money/actions/:id/resolve - Owner: re-send a Billing action whose outcome is unknown
* POST /api/admin/money/actions/:id/resolve - Owner: re-send a Billing action whose outcome is unknown
* GET /api/admin/settings - Get all site settings
* GET /api/admin/settings - Get all site settings
@@ -88,16 +88,8 @@ router.get('/stats', async (req, res) => {
@@ -88,16 +88,8 @@ router.get('/stats', async (req, res) => {
total: (await db.get('SELECT COUNT(*) as c FROM streams')).c,
total: (await db.get('SELECT COUNT(*) as c FROM streams')).c,
totalViewers: (await db.get('SELECT COALESCE(SUM(viewer_count), 0)::bigint as c FROM streams WHERE is_live = 1')).c,
totalViewers: (await db.get('SELECT COALESCE(SUM(viewer_count), 0)::bigint as c FROM streams WHERE is_live = 1')).c,
},
},
- // Under BILLING_AUTHORITY=billing these tables are frozen legacy copies: the numbers live
- // in OpenVibe.Billing (reconciliation report), so none are shown as current here.
- openvibeBucks: money.onBilling() ? {
- authority: 'billing', totalCirculating: null, totalTransactions: null, pendingCashouts: null, totalDonated: null,
- } : {
- totalCirculating: (await db.get('SELECT COALESCE(SUM(openvibe_bucks_balance), 0) as c FROM users')).c,
- totalTransactions: (await db.get('SELECT COUNT(*) as c FROM transactions')).c,
- pendingCashouts: (await db.get("SELECT COUNT(*) as c FROM transactions WHERE type = 'cashout' AND status = 'escrow'")).c,
- totalDonated: (await db.get("SELECT COALESCE(SUM(amount), 0)::bigint as c FROM transactions WHERE type = 'donation'")).c,
- },
+ // OpenVibe.Billing keeps the money (its staff console and reconciliation report have the numbers).
+ openvibeBucks: { authority: 'billing', totalCirculating: null, totalTransactions: null, pendingCashouts: null, totalDonated: null },
vods: {
vods: {
total: vodCounts.total,
total: vodCounts.total,
public: vodCounts.public,
public: vodCounts.public,
@@ -157,7 +149,7 @@ router.get('/users', async (req, res) => {
@@ -157,7 +149,7 @@ router.get('/users', async (req, res) => {
const users = await db.all(sql, params);
const users = await db.all(sql, params);
const total = (await db.get(countSql, countParams)).c;
const total = (await db.get(countSql, countParams)).c;
- if (money.onBilling()) for (const u of users) u.openvibe_bucks_balance = null; // legacy column; Billing holds Vibes
+ for (const u of users) u.openvibe_bucks_balance = null; // Live's old column; Billing holds Vibes
res.json({ users, total });
res.json({ users, total });
} catch (err) {
} catch (err) {
@@ -490,11 +482,9 @@ router.put('/vpn-queue/:id', async (req, res) => {
@@ -490,11 +482,9 @@ router.put('/vpn-queue/:id', async (req, res) => {
// ── Money: who holds it, the freeze, and Billing health (roadmap Wave 8) ──────
// ── Money: who holds it, the freeze, and Billing health (roadmap Wave 8) ──────
// Every admin can see the state; only the owner can freeze/unfreeze or resolve an action.
// Every admin can see the state; only the owner can freeze/unfreeze or resolve an action.
router.get('/money', async (req, res) => {
router.get('/money', async (req, res) => {
- const out = { authority: money.authority(), billing_authority_env: process.env.BILLING_AUTHORITY || null, ...await money.freezeState() };
- if (money.onBilling()) {
- try { out.billing = await require('../monetization/billing-actions').adminStatus(); }
- catch (err) { out.billing = { error: err.message }; }
- }
+ const out = { authority: money.authority(), ...await money.freezeState() };
+ try { out.billing = await require('../monetization/billing-actions').adminStatus(); }
+ catch (err) { out.billing = { error: err.message }; }
res.json(out);
res.json(out);
});
});
router.post('/money/freeze', permissions.requireOwner, async (req, res) => {
router.post('/money/freeze', permissions.requireOwner, async (req, res) => {
@@ -504,7 +494,6 @@ router.post('/money/freeze', permissions.requireOwner, async (req, res) => {
@@ -504,7 +494,6 @@ router.post('/money/freeze', permissions.requireOwner, async (req, res) => {
res.json({ authority: money.authority(), ...state });
res.json({ authority: money.authority(), ...state });
});
});
router.post('/money/actions/:id/resolve', permissions.requireOwner, async (req, res) => {
router.post('/money/actions/:id/resolve', permissions.requireOwner, async (req, res) => {
- if (!money.onBilling()) return res.status(409).json({ error: 'Only used when BILLING_AUTHORITY=billing' });
const billingActions = require('../monetization/billing-actions');
const billingActions = require('../monetization/billing-actions');
try {
try {
const r = await billingActions.resolveAction(parseInt(req.params.id, 10));
const r = await billingActions.resolveAction(parseInt(req.params.id, 10));
@@ -145,7 +145,6 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
@@ -145,7 +145,6 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
const bump = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
const bump = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
const cols = await personColumns(d);
const cols = await personColumns(d);
const usersCols = await columnNames(d, 'users');
const usersCols = await columnNames(d, 'users');
- const onBilling = (() => { try { return require('../monetization/money-authority').onBilling(); } catch { return false; } })();
await d.tx(async () => {
await d.tx(async () => {
for (const uid of userIds) {
for (const uid of userIds) {
for (const [t, list] of cols) {
for (const [t, list] of cols) {
@@ -164,8 +163,9 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
@@ -164,8 +163,9 @@ async function eraseUsers(d, userIds, { now = new Date().toISOString() } = {}) {
bump(erased, t, r.changes);
bump(erased, t, r.changes);
}
}
}
}
- // A donation's message is the person's words: cleared while Live keeps the ledger (read-only under Billing).
- if (!onBilling && cols.has('transactions')) {
+ // 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);
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.
// The tombstone: the kept rows point at nobody; the username is free again.
@@ -644,11 +644,8 @@ async function sanitizeUser(user, publicOnly = false) {
@@ -644,11 +644,8 @@ async function sanitizeUser(user, publicOnly = false) {
capabilities: await permissions.getCapabilities(user),
capabilities: await permissions.getCapabilities(user),
};
};
if (!publicOnly) {
if (!publicOnly) {
- // Balances are real money (Vibes) and the user's own business, like the key. No email: the OpenVibe
- // account keeps it (openvibe.network/my) and Live does not read users.email (WS-B task 2).
- // Under BILLING_AUTHORITY=billing the column is a frozen legacy copy (ADR-012 rule 8: never
- // shown as live); the balance comes from /api/funds/balance.
- if (!require('../monetization/money-authority').onBilling()) safe.openvibe_bucks_balance = user.openvibe_bucks_balance;
+ // No email: the OpenVibe account keeps it (openvibe.network/my) and Live does not read users.email (WS-B
+ // task 2). The Vibes balance comes from /api/funds/balance (OpenVibe.Billing); Live's old column is never shown.
// Kept as null for one release: the previous release's pages expect the field in /api/auth/me (test/n-1.test.js).
// Kept as null for one release: the previous release's pages expect the field in /api/auth/me (test/n-1.test.js).
// OpenCoins come from /api/coins/balance (the Network wallet); the frozen column is never read. Goes with the column.
// OpenCoins come from /api/coins/balance (the Network wallet); the frozen column is never read. Goes with the column.
safe.openvibe_coins_balance = null;
safe.openvibe_coins_balance = null;
@@ -134,8 +134,8 @@ contextRouter.get('/users/:id/follows', async (req, res) => {
@@ -134,8 +134,8 @@ contextRouter.get('/users/:id/follows', async (req, res) => {
});
});
// Sub-only chat (OpenVibe.Chat): does this person hold an ACTIVE subscription to this streamer's
// Sub-only chat (OpenVibe.Chat): does this person hold an ACTIVE subscription to this streamer's
-// channel? Live's own rule (db.isActiveSubscriber: status active, current period not over; Billing's
-// entitlement under BILLING_AUTHORITY=billing, read fresh here). Network VIP is not a subscription.
+// channel? OpenVibe.Billing's entitlement, read fresh here (db.isActiveSubscriber answers from its short cache).
+// Network VIP is not a subscription.
// The person by Live user id or by Network subject. Chat caches the answer briefly and treats a
// The person by Live user id or by Network subject. Chat caches the answer briefly and treats a
// failed read as "no" for everyone but the room's moderators.
// failed read as "no" for everyone but the room's moderators.
contextRouter.get('/subscriber', async (req, res) => {
contextRouter.get('/subscriber', async (req, res) => {
@@ -147,9 +147,7 @@ contextRouter.get('/subscriber', async (req, res) => {
@@ -147,9 +147,7 @@ contextRouter.get('/subscriber', async (req, res) => {
}
}
if (!userId || !streamerId) return fail(res, 400, 'user_id (or subject) and streamer_id required');
if (!userId || !streamerId) return fail(res, 400, 'user_id (or subject) and streamer_id required');
try {
try {
- if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {
- await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});
- }
+ await require('../monetization/billing-actions').refreshEntitlement(userId, streamerId).catch(() => {});
res.json({ subscriber: !!await db.isActiveSubscriber(userId, streamerId), user_id: userId, streamer_id: streamerId });
res.json({ subscriber: !!await db.isActiveSubscriber(userId, streamerId), user_id: userId, streamer_id: streamerId });
} catch (err) {
} catch (err) {
fail(res, 500, 'subscription lookup failed');
fail(res, 500, 'subscription lookup failed');
@@ -9,8 +9,6 @@ const path = require('path');
@@ -9,8 +9,6 @@ const path = require('path');
const fs = require('fs');
const fs = require('fs');
const crypto = require('crypto');
const crypto = require('crypto');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
-// BILLING_AUTHORITY tripwire: Live's money columns/tables are only written in `live` mode.
-const { assertLiveLedger } = require('../monetization/money-authority');
const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', '..', 'migrations');
@@ -2191,166 +2189,13 @@ async function getFollowerIds(streamerId) {
@@ -2191,166 +2189,13 @@ async function getFollowerIds(streamerId) {
.map(r => r.follower_id);
.map(r => r.follower_id);
}
}
-// ── Transaction helpers ──────────────────────────────────────
-
-async function createTransaction({ from_user_id, to_user_id, stream_id, amount, type, status, message }) {
- assertLiveLedger('transactions insert');
- return await run(
- `INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status, message)
- VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id`,
- [from_user_id || null, to_user_id || null, stream_id || null, amount, type, status || 'completed', message || null]
- );
-}
-
-async function addVibes(userId, amount) {
- assertLiveLedger('openvibe_bucks_balance credit');
- return await run(`UPDATE users SET openvibe_bucks_balance = openvibe_bucks_balance + ? WHERE id = ?`,
- [amount, userId]);
-}
-
-async function deductVibes(userId, amount) {
- assertLiveLedger('openvibe_bucks_balance debit');
- const user = await getUserById(userId);
- if (!user || user.openvibe_bucks_balance < amount) return false;
- await run(`UPDATE users SET openvibe_bucks_balance = openvibe_bucks_balance - ? WHERE id = ?`,
- [amount, userId]);
- return true;
-}
-
-// Streamer cashout balance (received donations; the only cashout-able balance).
-async function addVibesCashout(userId, amount) {
- assertLiveLedger('openvibe_bucks_cashout_balance credit');
- return await run(`UPDATE users SET openvibe_bucks_cashout_balance = openvibe_bucks_cashout_balance + ? WHERE id = ?`,
- [amount, userId]);
-}
-async function deductVibesCashout(userId, amount) {
- assertLiveLedger('openvibe_bucks_cashout_balance debit');
- const user = await getUserById(userId);
- if (!user || (user.openvibe_bucks_cashout_balance || 0) < amount) return false;
- await run(`UPDATE users SET openvibe_bucks_cashout_balance = openvibe_bucks_cashout_balance - ? WHERE id = ?`,
- [amount, userId]);
- return true;
-}
-
-// ── Payment orders (idempotent purchase tracking) ────────────
-
-async function createPaymentOrder({ user_id, provider, provider_ref = null, kind = 'bucks', amount_cents = 0, currency = 'usd', bucks = 0, streamer_id = null, status = 'pending' }) {
- assertLiveLedger('payment_orders insert');
- const res = await run(
- `INSERT INTO payment_orders (user_id, provider, provider_ref, kind, amount_cents, currency, bucks, streamer_id, status)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
- [user_id, provider, provider_ref, kind, amount_cents, currency, bucks, streamer_id, status]
- );
- return await get('SELECT * FROM payment_orders WHERE id = ?', [res.lastInsertRowid]);
-}
-
-async function getPaymentOrderById(id) {
- return await get('SELECT * FROM payment_orders WHERE id = ?', [id]);
-}
-
-async function getPaymentOrderByRef(provider, ref) {
- if (!ref) return null;
- return await get('SELECT * FROM payment_orders WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);
-}
-
-// PowerChat checkouts still waiting for their donation.completed webhook — the
-// reconciliation sweep's work list (recent only: an intent lives an hour, and an
-// order nobody paid within days is an abandoned cart, not a missed webhook).
-async function getPendingPowerchatOrders(days = 3) {
- return await all(`SELECT * FROM payment_orders
- WHERE provider = 'powerchat' AND status = 'pending'
- AND created_at >= datetime('now', ?)
- ORDER BY id ASC`, [`-${Math.max(1, Math.round(days))} days`]);
-}
-
-async function updatePaymentOrder(id, fields) {
- assertLiveLedger('payment_orders update');
- const allowed = new Set(['provider_ref', 'status', 'amount_cents', 'bucks', 'currency', 'streamer_id']);
- const entries = Object.entries(fields || {}).filter(([k]) => allowed.has(k));
- if (!entries.length) return await getPaymentOrderById(id);
- const sets = entries.map(([k]) => `${k} = ?`);
- sets.push('updated_at = ov_now()');
- await run(`UPDATE payment_orders SET ${sets.join(', ')} WHERE id = ?`, [...entries.map(([, v]) => v), id]);
- return await getPaymentOrderById(id);
-}
-
-// ── Subscription helpers ─────────────────────────────────────
-
-async function upsertSubscription({ subscriber_id, streamer_id, tier = 1, provider = null, provider_ref = null, price_cents = 0, currency = 'usd', status = 'active', current_period_end = null, auto_renew = null }) {
- assertLiveLedger('subscriptions upsert');
- // Reuse an existing (subscriber,streamer) row if present, else insert.
- // auto_renew: null = leave as-is on update (0 on insert); 0/1 = set explicitly.
- const existing = await get('SELECT * FROM subscriptions WHERE subscriber_id = ? AND streamer_id = ?', [subscriber_id, streamer_id]);
- if (existing) {
- await run(`UPDATE subscriptions SET tier=?, provider=?, provider_ref=COALESCE(?, provider_ref), price_cents=?, currency=?,
- status=?, is_active=?, current_period_end=?, auto_renew=COALESCE(?, auto_renew),
- cancel_at_period_end=0, updated_at=ov_now() WHERE id=?`,
- [tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end,
- auto_renew === null ? null : (auto_renew ? 1 : 0), existing.id]);
- return await get('SELECT * FROM subscriptions WHERE id = ?', [existing.id]);
- }
- const res = await run(`INSERT INTO subscriptions (subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, is_active, current_period_end, auto_renew)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
- [subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end, auto_renew ? 1 : 0]);
- return await get('SELECT * FROM subscriptions WHERE id = ?', [res.lastInsertRowid]);
-}
-
-// Active subs whose paid period has lapsed — the renewal sweeper's work list.
-async function getSubscriptionsDueRenewal(limit = 50) {
- return await all(`SELECT * FROM subscriptions
- WHERE status = 'active' AND current_period_end IS NOT NULL
- AND datetime(current_period_end) <= ov_now()
- ORDER BY datetime(current_period_end) ASC LIMIT ?`, [limit]);
-}
-
-async function getSubscriptionByProviderRef(provider, ref) {
- if (!ref) return null;
- return await get('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);
-}
-
-async function getActiveSubscription(subscriberId, streamerId) {
- return await get(`SELECT * FROM subscriptions WHERE subscriber_id = ? AND streamer_id = ? AND status = 'active'
- AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now()) LIMIT 1`,
- [subscriberId, streamerId]);
-}
-
+// ── Subscriptions (OpenVibe.Billing) ─────────────────────────
async function isActiveSubscriber(subscriberId, streamerId) {
async function isActiveSubscriber(subscriberId, streamerId) {
if (!subscriberId || !streamerId) return false;
if (!subscriberId || !streamerId) return false;
- // BILLING_AUTHORITY=billing: subscriber perks follow Billing's entitlements (short-lived cache;
- // legacy subscriptions rows are not consulted).
- if (process.env.BILLING_AUTHORITY && require('../monetization/money-authority').onBilling()) {
- return require('../monetization/billing-actions').isSubscriberCached(subscriberId, streamerId);
- }
- return !!await getActiveSubscription(subscriberId, streamerId);
-}
-
-async function getSubscriptionsByStreamer(streamerId) {
- return await all(`SELECT s.*, u.username AS subscriber_username, u.display_name AS subscriber_display, u.avatar_url AS subscriber_avatar
- FROM subscriptions s LEFT JOIN users u ON s.subscriber_id = u.id
- WHERE s.streamer_id = ? AND s.status = 'active' ORDER BY s.started_at DESC`, [streamerId]);
-}
-
-async function getSubscriptionsBySubscriber(subscriberId) {
- return await all(`SELECT s.*, u.username AS streamer_username, u.display_name AS streamer_display, u.avatar_url AS streamer_avatar
- FROM subscriptions s LEFT JOIN users u ON s.streamer_id = u.id
- WHERE s.subscriber_id = ? ORDER BY s.started_at DESC`, [subscriberId]);
+ // Subscriber perks follow OpenVibe.Billing's entitlements (a short-lived cache, billing-actions.js).
+ return require('../monetization/billing-actions').isSubscriberCached(subscriberId, streamerId);
}
}
-async function getActiveSubscriberCount(streamerId) {
- const r = await get(`SELECT COUNT(*) AS n FROM subscriptions WHERE streamer_id = ? AND status = 'active'
- AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now())`, [streamerId]);
- return r ? r.n : 0;
-}
-
-async function setSubscriptionStatus(id, status, fields = {}) {
- assertLiveLedger('subscriptions status');
- const cpe = fields.current_period_end !== undefined ? fields.current_period_end : null;
- const cape = fields.cancel_at_period_end !== undefined ? (fields.cancel_at_period_end ? 1 : 0) : 0;
- await run(`UPDATE subscriptions SET status=?, is_active=?, cancel_at_period_end=?,
- current_period_end=COALESCE(?, current_period_end), updated_at=ov_now() WHERE id=?`,
- [status, status === 'active' ? 1 : 0, cape, cpe, id]);
- return await get('SELECT * FROM subscriptions WHERE id = ?', [id]);
-}
// ── VODs and clips ───────────────────────────────────────────
// ── VODs and clips ───────────────────────────────────────────
// They live in OpenVibe.Media. Live asks for them through server/media-client.js and
// They live in OpenVibe.Media. Live asks for them through server/media-client.js and
// server/media-proxy/lookups.js; Live's own AI state for them is vod_ai_state / clip_ai_state.
// server/media-proxy/lookups.js; Live's own AI state for them is vod_ai_state / clip_ai_state.
@@ -3705,10 +3550,7 @@ module.exports = {
@@ -3705,10 +3550,7 @@ module.exports = {
getPowerchatConnection, getPowerchatConnectionByUsername, getPowerchatConnectionByPcUserId,
getPowerchatConnection, getPowerchatConnectionByUsername, getPowerchatConnectionByPcUserId,
upsertPowerchatConnection, updatePowerchatTokens, setPowerchatConnectionError,
upsertPowerchatConnection, updatePowerchatTokens, setPowerchatConnectionError,
deletePowerchatConnection, powerchatDeliveryIsNew, cleanupPowerchatDeliveries,
deletePowerchatConnection, powerchatDeliveryIsNew, cleanupPowerchatDeliveries,
- createPaymentOrder, getPaymentOrderById, getPaymentOrderByRef, updatePaymentOrder, getPendingPowerchatOrders,
- upsertSubscription, getSubscriptionByProviderRef, getActiveSubscription, isActiveSubscriber,
- getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,
- getSubscriptionsDueRenewal,
+ isActiveSubscriber,
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
getRestreamDestinationsByManagedStream, getRestreamDestinationsForSlot,
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
recordEasterEggSolve, hasSolvedEasterEgg, countEasterEggSolves,
// Profiles
// Profiles
@@ -3717,8 +3559,6 @@ module.exports = {
@@ -3717,8 +3559,6 @@ module.exports = {
getChannelPoints, addChannelPoints, deductChannelPoints, applyChannelPoints,
getChannelPoints, addChannelPoints, deductChannelPoints, applyChannelPoints,
// Follows
// Follows
getFollowerCount, isFollowing, getFollowerIds,
getFollowerCount, isFollowing, getFollowerIds,
- // Transactions (Vibes)
- createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,
// OpenCoins
// OpenCoins
createCoinTransaction, getCoinTransactions,
createCoinTransaction, getCoinTransactions,
// Coin Rewards
// Coin Rewards
@@ -1419,10 +1419,6 @@ async function start() {
@@ -1419,10 +1419,6 @@ async function start() {
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
// Read /chat/history back so a 202-accepted relay that moderation dropped is visible.
// Read /chat/history back so a 202-accepted relay that moderation dropped is visible.
try { require('./integrations/powerchat-platform').startChatVerifier(); } catch (e) { console.warn('[PowerChat] chat verifier not started:', e.message); }
try { require('./integrations/powerchat-platform').startChatVerifier(); } catch (e) { console.warn('[PowerChat] chat verifier not started:', e.message); }
- // Backfill checkout orders whose donation.completed webhook never arrived (/paid-messages).
- try { require('./integrations/powerchat-reconcile').startReconciler(); } catch (e) { console.warn('[PowerChat] reconciler not started:', e.message); }
- // Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
- try { await require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
// 5-minute live-viewer samples → the home hero's 24h sparkline.
// 5-minute live-viewer samples → the home hero's 24h sparkline.
try { await require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }
try { await require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }
try { require('./home/star-job').start(); } catch (e) { console.warn('[Home] star picker not started:', e.message); }
try { require('./home/star-job').start(); } catch (e) { console.warn('[Home] star picker not started:', e.message); }
@@ -183,54 +183,6 @@ async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents =
@@ -183,54 +183,6 @@ async function mintCheckoutLink(pcUsername, ref, { purpose = null, amountCents =
// ── Link builders ────────────────────────────────────────────
// ── Link builders ────────────────────────────────────────────
// Buy Vibes: always the site account (the site must receive the money it mints
// Buy Vibes: always the site account (the site must receive the money it mints
// against). The checkout is PINNED to the package price and categorized "vibes".
// against). The checkout is PINNED to the package price and categorized "vibes".
-async function buildPurchaseLink(order) {
- const site = await getSiteAccount();
- if (!site) return null;
- const bucks = Number(order.bucks) || 0;
- const link = await mintCheckoutLink(site.username, `pcorder:${order.id}`, {
- purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
- itemName: bucks ? `${bucks.toLocaleString()} Vibes` : 'Vibes',
- });
- return { url: link.url, mode: 'site', minted: link.minted, expiresAt: link.expiresAt };
-}
-
-// Subscription: streamer-direct when possible, site fallback otherwise.
-// The chosen mode (and the subscriber's auto-renew wish) is persisted on the order's
-// provider_ref ("direct" | "site", ":renew" suffix) — webhook fulfillment reads it to
-// decide the streamer share and the sub's auto_renew flag.
-// route: 'direct' (the streamer's own PowerChat — they keep 100%, no fee) or 'site'
-// (OpenVibe's PowerChat account — a small platform fee is added on top of the sub price
-// and the streamer receives their normal share of the BASE price). 'auto' = direct when
-// the streamer has PowerChat connected, else site.
-// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".
-async function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {
- const suffix = autoRenew ? ':renew' : '';
- const streamer = await db.getUserById(streamerUserId);
- const who = streamer ? (streamer.display_name || streamer.username) : 'channel';
- const opts = {
- purpose: 'subscription', amountCents: order.amount_cents, returnTo: true,
- itemName: `${who} — 1 month subscription${autoRenew ? ' (auto-renew)' : ''}`,
- };
- const direct = route !== 'site' ? await _checkoutConn(streamerUserId) : null;
- if (direct) {
- await db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
- const link = await mintCheckoutLink(direct.powerchat_username, `pcsub:${order.id}`, opts);
- return { url: link.url, mode: 'direct', minted: link.minted, expiresAt: link.expiresAt };
- }
- if (route === 'direct') return null; // caller asked for the streamer's page and they have none
- const site = await getSiteAccount();
- if (!site) return null;
- const fee = Math.max(0, Math.round(Number(feeCents) || 0));
- await db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
- const link = await mintCheckoutLink(site.username, `pcsub:${order.id}`, opts);
- return { url: link.url, mode: 'site', feeCents: fee, minted: link.minted, expiresAt: link.expiresAt };
-}
-
-// ── Checkouts for OpenVibe.Billing intents (BILLING_AUTHORITY=billing) ───────
-// Billing owns the order (a payment intent) and prices it; Live only builds the PowerChat link,
-// because Live holds the PowerChat connections that can mint pinned checkouts. The ref is Billing's
-// own checkout_ref ("pcorder:pi_…" / "pcsub:pi_…"): PowerChat echoes it to Billing's webhook, which
-// settles it. Nothing here touches payment_orders.
async function buildBillingPurchaseLink(intent) {
async function buildBillingPurchaseLink(intent) {
const site = await getSiteAccount();
const site = await getSiteAccount();
if (!site || !intent || !intent.checkout_ref) return null;
if (!site || !intent || !intent.checkout_ref) return null;
@@ -295,125 +247,8 @@ async function buildDonateLink(streamerUserId, donorUserId, { goalId = null } =
@@ -295,125 +247,8 @@ async function buildDonateLink(streamerUserId, donorUserId, { goalId = null } =
return null;
return null;
}
}
-// ── Webhook fulfillment ──────────────────────────────────────
-// Called by the webhook processor for donation.completed events whose appExternalRef
-// matches one of our prefixes. Returns true when the event was CONSUMED (the normal
-// donation pipeline for the receiving account must then be skipped), false to fall
-// through to plain donation handling.
-// The checkout reference rides in the tip link and the buyer can edit it, so a reference only
-// counts when the money landed where that kind of checkout sends it: purchases, site-routed
-// donations and site-routed subscriptions on the SITE tips account, a direct subscription on
-// the subscribed streamer's own account. Anything else (a streamer tipping their own PowerChat
-// with `pcdon:<self>`, a viewer tipping themselves with `pcsub:<x>`) is an ordinary tip to the
-// receiving account and falls through to normal handling.
-async function handleAttributedDonation(receivingUserId, data, { viaSiteAccount = receivingUserId == null } = {}) {
- const ref = String(data.appExternalRef || '');
- const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));
- try {
- // Buy Vibes ---------------------------------------------------------
- let m = ref.match(/^pcorder:(\d+)$/);
- if (m && !viaSiteAccount) {
- console.warn(`[PowerChat] ${ref} paid to a streamer account, not the site account — not a purchase`);
- return false;
- }
- if (m) {
- const order = await db.getPaymentOrderById(Number(m[1]));
- if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handle
- if (order.status === 'credited') return true; // at-least-once redelivery
- if (usdCents < 1) return true;
- // Credit what was ACTUALLY paid (intents pin the amount; the canonical
- // fallback link can't) — never the package price on faith.
- const pay = require('../monetization/payments');
- const bucks = await pay.bucksForUsd(usdCents / 100);
- await db.updatePaymentOrder(order.id, { amount_cents: usdCents, bucks, status: 'paid' });
- await pay.fulfillBucksOrder(await db.getPaymentOrderById(order.id));
- _notify(order.user_id, 'Vibes credited 🎉', `Your PowerChat tip of ${_usd(usdCents)} was confirmed — ${bucks.toLocaleString()} Vibes added to your balance.`);
- console.log(`[PowerChat] purchase order ${order.id}: ${_usd(usdCents)} → ${bucks} Vibes for user ${order.user_id}`);
- return true;
- }
- // Subscription ------------------------------------------------------
- m = ref.match(/^pcsub:(\d+)$/);
- if (m) {
- const order = await db.getPaymentOrderById(Number(m[1]));
- if (!order || order.kind !== 'subscription' || !order.streamer_id) return true;
- if (order.status === 'credited') return true;
- // Underpaid (canonical-link fallback can't enforce amounts; minted intents
- // can): treat the tip as a plain
- // donation to the order's streamer rather than silently activating a
- // discounted sub. Direct mode = the receiving account IS the streamer, so
- // falling through does the right thing; site mode routes explicitly.
- const mode = String(order.provider_ref || 'site').split(':')[0];
- // Direct: the tip must land on the subscribed streamer's own account (which may also be
- // the site tips account). Site-routed: it must land on the site tips account.
- const paidWhereExpected = mode === 'direct' ? (receivingUserId != null && Number(receivingUserId) === Number(order.streamer_id)) : viaSiteAccount;
- if (!paidWhereExpected) {
- console.warn(`[PowerChat] ${ref} (${mode}) paid to the wrong account — not a subscription`);
- return false;
- }
- const autoRenew = /:renew$/.test(String(order.provider_ref || '')) ? 1 : 0;
- const feeCents = Number((String(order.provider_ref || '').match(/:fee=(\d+)/) || [])[1] || 0);
- if (usdCents + 1 < (order.amount_cents || 0)) {
- console.warn(`[PowerChat] sub order ${order.id} underpaid (${_usd(usdCents)} < ${_usd(order.amount_cents)}) — treating as a donation`);
- if (mode === 'site') { await _creditSiteRoutedDonation(order.streamer_id, order.user_id, data); return true; }
- return false;
- }
- const pay = require('../monetization/payments');
- await db.updatePaymentOrder(order.id, { status: 'paid' });
- // Streamer-direct: they already hold the cash — no cashout-Vibes share.
- // Site route: the streamer's share is computed on the sub price, not on the fee.
- await pay.fulfillSubscriptionOrder(await db.getPaymentOrderById(order.id), { creditShare: mode !== 'direct', autoRenew, shareBaseCents: Math.max(0, (order.amount_cents || 0) - feeCents) });
- _notify(order.user_id, 'Subscribed! ⭐', 'Your PowerChat tip was confirmed — your channel subscription is active.');
- console.log(`[PowerChat] sub order ${order.id} activated via PowerChat (${order.provider_ref}) for user ${order.user_id}`);
- return true;
- }
- // Site-routed donation ----------------------------------------------
- m = ref.match(/^pcdon:(\d+):(\d+)$/);
- if (m && !viaSiteAccount) {
- console.warn(`[PowerChat] ${ref} paid to a streamer account, not the site account — not a site-routed donation`);
- return false;
- }
- if (m) {
- await _creditSiteRoutedDonation(Number(m[1]), Number(m[2]) || null, data);
- return true;
- }
- } catch (e) {
- console.warn('[PowerChat] attributed-donation fulfillment failed:', e.message);
- return true; // never fall through to crediting the receiving account on our refs
- }
- return false; // not one of our checkout refs (e.g. goal:12) — normal handling
-}
-
-// A tip that landed on the SITE account on behalf of a PowerChat-less streamer:
-// full amount becomes the streamer's cashout-able Vibes (the site holds the cash),
-// plus the normal on-site donation celebration/goal credit.
-async function _creditSiteRoutedDonation(streamerUserId, donorUserId, data) {
- const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));
- if (!streamerUserId || usdCents < 1) return;
- if (!await db.getUserById(streamerUserId)) { console.warn(`[PowerChat] pcdon for unknown streamer ${streamerUserId}`); return; }
- await db.addVibesCashout(streamerUserId, usdCents); // 1¢ = 1 Vibe, same rate as on-site donations
- try {
- await db.createTransaction({
- from_user_id: donorUserId || null, to_user_id: streamerUserId, amount: usdCents,
- type: 'donation', status: 'completed',
- message: `PowerChat tip via site account (${_usd(usdCents)})`,
- });
- } catch { /* ledger best-effort */ }
- // Same celebration pipeline a direct-connected streamer gets from their own webhook.
- try { await require('./powerchat-webhook').creditDonationPipeline(streamerUserId, data, { fromUserId: donorUserId || null }); } catch (e) { console.warn('[PowerChat] pcdon pipeline:', e.message); }
- console.log(`[PowerChat] site-routed donation: ${_usd(usdCents)} → ${usdCents} cashout Vibes for streamer ${streamerUserId}`);
-}
-
-function _usd(cents) { return `$${(Math.round(cents) / 100).toFixed(2)}`; }
-function _notify(userId, title, message) {
- try {
- const { pushNotification } = require('../utils/notify');
- pushNotification({ user_id: userId, type: 'PAYMENT', title, message, url: '/' });
- } catch { /* optional */ }
-}
-
module.exports = {
module.exports = {
isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, checkoutIntentSupport, subscribeRoutes,
isAvailable, getSiteAccount, tipLinkFor, mintCheckoutLink, checkoutIntentSupport, subscribeRoutes,
_test: { resetIntentSupport: () => _intentSupport.clear() },
_test: { resetIntentSupport: () => _intentSupport.clear() },
- buildPurchaseLink, buildSubscribeLink, buildDonateLink, buildBillingPurchaseLink, buildBillingSubscribeLink,
- handleAttributedDonation,
+ buildDonateLink, buildBillingPurchaseLink, buildBillingSubscribeLink,
};
};
deleted file mode 100644
deleted file mode 100644
@@ -1,181 +0,0 @@
@@ -1,181 +0,0 @@
-/**
- * powerchat-reconcile.js — backfill missed donation.completed webhooks from
- * GET /streamers/:username/paid-messages (scope paid_messages:read).
- *
- * The signed webhook is the ONLY authoritative confirmation and stays the primary
- * credit path. But webhooks can be missed for good: the receiver was down past
- * PowerChat's ~24h retry tail, ~20 consecutive failures tripped its circuit
- * breaker, or a 410 disabled the endpoint. Until now a viewer who paid during
- * such a window simply never got their Vibes / subscription.
- *
- * /paid-messages is the same confirmed money as a cursor-paginated feed, and every
- * row echoes `appExternalRef` — the pcorder:/pcsub: ref we minted onto the checkout
- * link — so paid-but-uncredited orders are found by exact match, never guessed.
- *
- * Safe to run repeatedly and concurrently with live webhooks: everything is credited
- * through the same handleAttributedDonation() the webhook uses, which is idempotent
- * on the order's status. Work-list driven: no pending order → no API call at all.
- *
- * Envelope: written against { data: { rows, nextCursor } } (the corrected upstream
- * shape) and defensive on older deployments that left nextCursor outside `data`.
- */
-'use strict';
-
-const db = require('../db/database');
-const oauth = require('./powerchat-oauth');
-const checkout = require('./powerchat-checkout');
-
-const PAGE_LIMIT = 100;
-const MAX_PAGES_PER_HOST = 5; // 500 newest confirmed tips per host per sweep
-const LOOKBACK_DAYS = 3; // intents live 1h; older pending orders are abandoned carts
-const GRACE_MS = 2 * 60 * 60 * 1000; // rows may be a little older than the order (clock skew, slow checkout)
-const SWEEP_MS = 15 * 60 * 1000;
-const FIRST_SWEEP_MS = 2 * 60 * 1000;
-
-function _sqliteTs(s) {
- if (!s) return 0;
- const t = Date.parse(String(s).includes('T') ? String(s) : String(s).replace(' ', 'T') + 'Z');
- return Number.isFinite(t) ? t : 0;
-}
-async function _testAllowed() {
- try { const v = await db.getSetting('powerchat_allow_test_fulfillment'); return v === true || v === 'true' || v === 1 || v === '1'; }
- catch { return false; }
-}
-function _hasScope(conn, scope) {
- return !!(conn && conn.access_token && conn.refresh_token && (!conn.scope || String(conn.scope).split(/\s+/).includes(scope)));
-}
-
-// Which PowerChat account hosted this order's checkout (where the money landed), and
-// which local user "receives" it for the fulfillment pipeline.
-// pcsub direct → the streamer's own PowerChat; site → the site tips account.
-async function _hostFor(order) {
- const mode = String(order.provider_ref || 'site').split(':')[0];
- if (order.kind === 'subscription' && mode === 'direct' && order.streamer_id) {
- const conn = await db.getPowerchatConnection(order.streamer_id);
- return conn && conn.powerchat_username ? { username: conn.powerchat_username, conn, receivingUserId: order.streamer_id, viaSiteAccount: false } : null;
- }
- const site = await checkout.getSiteAccount();
- if (!site) return null;
- const conn = await db.getPowerchatConnectionByUsername(site.username);
- return { username: site.username, conn: conn || null, receivingUserId: conn ? conn.user_id : null, viaSiteAccount: true };
-}
-function _refFor(order) {
- return order.kind === 'subscription' ? `pcsub:${order.id}` : `pcorder:${order.id}`;
-}
-function _page(json) {
- const d = json && json.data !== undefined ? json.data : json;
- const rows = Array.isArray(d) ? d : (d && Array.isArray(d.rows) ? d.rows : []);
- // Corrected shape nests nextCursor in data; older deployments put it beside it.
- const nextCursor = (d && !Array.isArray(d) && d.nextCursor != null) ? d.nextCursor
- : (json && json.nextCursor != null ? json.nextCursor : null);
- return { rows, nextCursor: nextCursor || null };
-}
-
-const _hostWarned = new Map();
-function _warnHost(username, msg) {
- const now = Date.now();
- if ((_hostWarned.get(username) || 0) > now - 3600000) return;
- _hostWarned.set(username, now);
- console.warn(`[PowerChat] reconcile: ${msg} (@${username})`);
-}
-
-/** One sweep. Returns a summary; never throws. */
-async function reconcileOnce() {
- const summary = { orders: 0, hosts: 0, pages: 0, rows: 0, credited: [], underpaid: [], skippedTest: 0, skippedHosts: 0, errors: 0 };
- // BILLING_AUTHORITY=billing: PowerChat money settles in OpenVibe.Billing; Live credits nothing.
- if (require('../monetization/money-authority').authority() !== 'live') return { ...summary, skipped: 'billing_authority' };
- let pending;
- try { pending = await db.getPendingPowerchatOrders(LOOKBACK_DAYS) || []; } catch (e) { summary.errors++; return summary; }
- summary.orders = pending.length;
- if (!pending.length) return summary;
-
- // Group the work list by host account: one paged read per host, not per order.
- const hosts = new Map(); // username → { host, orders: Map(ref → order), oldestAt }
- for (const order of pending) {
- const host = await _hostFor(order);
- if (!host) continue;
- const key = host.username.toLowerCase();
- let h = hosts.get(key);
- if (!h) { h = { host, orders: new Map(), oldestAt: Infinity }; hosts.set(key, h); }
- h.orders.set(_refFor(order), order);
- h.oldestAt = Math.min(h.oldestAt, _sqliteTs(order.created_at) || Date.now());
- }
- const allowTest = await _testAllowed();
-
- for (const [, h] of hosts) {
- const { host, orders } = h;
- if (!_hasScope(host.conn, 'paid_messages:read')) {
- summary.skippedHosts++;
- _warnHost(host.username, `${orders.size} pending checkout(s) can't be reconciled — no connected account with paid_messages:read for the host`);
- continue;
- }
- summary.hosts++;
- let cursor = null;
- for (let page = 0; page < MAX_PAGES_PER_HOST && orders.size; page++) {
- let pg;
- try {
- const json = await oauth.apiRequest(host.conn.user_id, {
- method: 'GET', path: '/paid-messages', username: host.username,
- query: { limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
- });
- pg = _page(json);
- } catch (e) {
- summary.errors++;
- _warnHost(host.username, `paid-messages read failed: ${e.status || ''} ${e.message}`.trim());
- break;
- }
- summary.pages++;
- if (!pg.rows.length) break;
- let pastOldest = false;
- for (const row of pg.rows) {
- summary.rows++;
- if (_sqliteTs(row.occurredAt) && _sqliteTs(row.occurredAt) < h.oldestAt - GRACE_MS) pastOldest = true;
- const ref = row.appExternalRef ? String(row.appExternalRef) : '';
- if (!ref) continue; // someone else's tip — not ours to credit
- const order = orders.get(ref);
- if (!order) continue; // already credited, or not on this sweep's list
- if (row.source === 'developer_app') continue; // our own forwarded tip echo, never money in
- // /paid-messages rows carry NO isTest field, yet include source='manual_test'
- // rows (the no-payment-provider test tip path). PowerChat's own webhook
- // rule is isTest = event.isTest || source === 'manual_test' — mirror it, or
- // a test tip would be reconciled as real money on a production PowerChat.
- const isTest = !!row.isTest || row.source === 'manual_test';
- if (isTest && !allowTest) { summary.skippedTest++; continue; }
- // Same shape the webhook hands to the checkout module (money in integer cents).
- const data = {
- eventId: row.eventId, occurredAt: row.occurredAt, source: row.source,
- donorName: row.donorName, isAnonymous: !!row.isAnonymous, message: row.message,
- amountCents: row.amountCents, currency: row.currency,
- amountUsdCents: row.amountUsdCents != null ? row.amountUsdCents : row.amountCents,
- appExternalRef: ref, isTest, reconciled: true,
- };
- let consumed = false;
- try { consumed = await checkout.handleAttributedDonation(host.receivingUserId, data, { viaSiteAccount: host.viaSiteAccount }); }
- catch (e) { summary.errors++; console.warn(`[PowerChat] reconcile: fulfillment for ${ref} failed: ${e.message}`); }
- orders.delete(ref);
- let after = null;
- try { after = await db.getPaymentOrderById(order.id); } catch { /* */ }
- if (after && after.status === 'credited') {
- summary.credited.push({ ref, eventId: row.eventId, amountUsdCents: data.amountUsdCents, userId: order.user_id });
- console.log(`[PowerChat] reconcile: credited ${ref} from paid-messages (event ${row.eventId || '?'}, $${(Number(data.amountUsdCents || 0) / 100).toFixed(2)}) — its webhook never arrived`);
- } else if (!consumed || (after && after.status !== 'credited')) {
- summary.underpaid.push({ ref, eventId: row.eventId, amountUsdCents: data.amountUsdCents });
- }
- }
- if (!pg.nextCursor || pastOldest) break; // null cursor = last page; older rows can't match
- cursor = pg.nextCursor;
- }
- }
- return summary;
-}
-
-let _timer = null;
-function startReconciler() {
- if (_timer) return;
- if (require('../monetization/money-authority').onBilling()) { console.log('[PowerChat] paid-messages reconciler not started: OpenVibe.Billing settles PowerChat money (BILLING_AUTHORITY=billing)'); return; }
- // Credits money from paginated upstream calls: a slow sweep must never be joined by the next one.
- _timer = require('../utils/jobs').every('powerchat-reconcile', SWEEP_MS, () => reconcileOnce(), { initialDelayMs: FIRST_SWEEP_MS, jitterMs: 30 * 1000 });
- console.log('[PowerChat] paid-messages reconciler started (backfills missed checkout webhooks every 15 min)');
-}
-
-module.exports = { reconcileOnce, startReconciler, _test: { page: _page } };
@@ -333,18 +333,6 @@ router.get('/donate-link', optionalAuth, async (req, res) => {
@@ -333,18 +333,6 @@ router.get('/donate-link', optionalAuth, async (req, res) => {
}
}
});
});
-// ── POST /reconcile — admin: backfill missed checkout webhooks from /paid-messages ─
-// The 15-minute sweeper runs this on its own; the route exists so an operator can
-// run it on demand after an outage (or to see the summary) without waiting.
-router.post('/reconcile', requireAdmin, async (req, res) => {
- try {
- const summary = await require('./powerchat-reconcile').reconcileOnce();
- res.json({ ok: true, ...summary });
- } catch (err) {
- res.status(500).json({ error: 'Reconcile failed' });
- }
-});
-
// ── POST /test-tip — simulate a tip through the LOCAL pipeline (no PowerChat call) ─
// ── POST /test-tip — simulate a tip through the LOCAL pipeline (no PowerChat call) ─
// The most useful test: confirms the streamer's alert sound + chat celebration render.
// The most useful test: confirms the streamer's alert sound + chat celebration render.
router.post('/test-tip', requireAuth, async (req, res) => {
router.post('/test-tip', requireAuth, async (req, res) => {
@@ -437,42 +425,12 @@ router.get('/authorize-url', requireAuth, (req, res) => {
@@ -437,42 +425,12 @@ router.get('/authorize-url', requireAuth, (req, res) => {
res.json({ url: `${String(config.baseUrl).replace(/\/+$/, '')}/api/powerchat/oauth/start` });
res.json({ url: `${String(config.baseUrl).replace(/\/+$/, '')}/api/powerchat/oauth/start` });
});
});
-// ── POST /webhook — signed event receiver ────────────────────────────────────
-// No auth middleware: authenticity is the HMAC signature. Ack fast, process async.
-router.post('/webhook', async (req, res) => {
- // BILLING_AUTHORITY=billing: PowerChat money settles in OpenVibe.Billing, and this receiver
- // must not process anything. 410 names where the webhook lives now (the PowerChat dashboard
- // is re-pointed by hand — docs/live-cutover.md in OpenVibe.Billing).
- if (require('../monetization/money-authority').onBilling()) {
- return res.status(410).json({
- error: 'PowerChat webhooks are received by OpenVibe.Billing now.',
- webhook_url: `${require('../monetization/billing-client').publicUrl()}/webhooks/powerchat`,
- });
- }
- try {
- const raw = req.rawBody || (req.body ? Buffer.from(JSON.stringify(req.body)) : Buffer.alloc(0));
- const check = await webhook.verifySignature(raw, req.headers);
- if (!check.ok) {
- console.warn('[PowerChat] webhook rejected:', check.reason);
- return res.status(401).json({ error: 'invalid signature' });
- }
- const deliveryId = req.headers['x-powerchat-delivery-id'] || null;
- const eventType = req.headers['x-powerchat-event-type'] || (req.body && req.body.type) || null;
-
- // Dedupe at-least-once deliveries.
- if (deliveryId && !await db.powerchatDeliveryIsNew(deliveryId, eventType)) {
- return res.status(200).json({ ok: true, deduped: true });
- }
-
- // Ack immediately; process off the response path.
- res.status(200).json({ ok: true });
- const envelope = req.body && typeof req.body === 'object' ? req.body : (() => { try { return JSON.parse(raw.toString('utf8')); } catch { return null; } })();
- db.getDb().afterCommit(() => {
- if (envelope) webhook.processEvent(envelope).catch((e) => console.warn('[PowerChat] webhook process error:', e.message));
- });
- } catch (err) {
- if (!res.headersSent) res.status(500).json({ error: 'webhook error' });
- }
-});
+// ── POST /webhook — received by OpenVibe.Billing ─────────────────────────────
+// openvibe.live's nginx hands /api/powerchat/webhook to Billing (Live#172; the signature covers the timestamp and the
+// body, not the URL). Should a delivery reach Live anyway, it is told where the webhook lives and nothing is processed.
+router.post('/webhook', (req, res) => res.status(410).json({
+ error: 'PowerChat webhooks are received by OpenVibe.Billing now.',
+ webhook_url: `${require('../monetization/billing-client').publicUrl()}/webhooks/powerchat`,
+}));
module.exports = router;
module.exports = router;
@@ -1,63 +1,19 @@
@@ -1,63 +1,19 @@
/**
/**
- * powerchat-webhook.js — verify + process PowerChat webhook deliveries.
+ * powerchat-webhook.js — the on-stream celebration of a PowerChat tip.
*
*
- * Deliveries are signed and at-least-once. We:
- * 1. Verify the HMAC-SHA256 signature over "<timestamp>.<raw body>" (timing-safe).
- * 2. Reject timestamps older than 15 minutes.
- * 3. Dedupe on X-PowerChat-Delivery-Id.
- * 4. Ack 2xx fast; process async.
- *
- * `donation.completed` is mapped onto OpenVibe.Live's existing donation pipeline: it credits
- * the streamer's active donation goal and fires the same live chat event + alert sound +
- * goal-reached celebration that an on-site Vibes donation does.
+ * PowerChat's webhook is received by OpenVibe.Billing since the cutover of 2026-10-17 (openvibe.live's nginx hands
+ * /api/powerchat/webhook to it). What stays here is what a tip looks like on Live: the chat line, the alert sound, the
+ * donation goal and the channel's tip history (creditDonationPipeline), run by billing-events.js when Billing announces
+ * a PowerChat tip, and the streamer's own test tip (simulateDonation).
*/
*/
'use strict';
'use strict';
const crypto = require('crypto');
const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
-const powerchatOAuth = require('./powerchat-oauth');
const { publicGoal, announceGoal } = require('../monetization/goal-announce');
const { publicGoal, announceGoal } = require('../monetization/goal-announce');
-const MAX_SKEW_MS = 15 * 60 * 1000;
-
-// ── Signature verification ───────────────────────────────────────────────────
-// Returns { ok, reason }. rawBody must be the exact bytes received (Buffer or string).
-async function verifySignature(rawBody, headers) {
- const secret = (await powerchatOAuth.getConfig()).webhookSecret;
- if (!secret) return { ok: false, reason: 'webhook secret not configured' };
-
- const sigHeader = headers['x-powerchat-signature'] || '';
- const tsHeader = headers['x-powerchat-timestamp'] || '';
- if (!sigHeader || !tsHeader) return { ok: false, reason: 'missing signature/timestamp headers' };
-
- // Timestamp is unix ms; reject stale deliveries (replay protection).
- const ts = Number(tsHeader);
- if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > MAX_SKEW_MS) {
- return { ok: false, reason: 'timestamp outside allowed window' };
- }
-
- const bodyBuf = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody || ''), 'utf8');
- const expected = 'sha256=' + crypto.createHmac('sha256', secret)
- .update(String(tsHeader) + '.').update(bodyBuf).digest('hex');
-
- const a = Buffer.from(sigHeader);
- const bexp = Buffer.from(expected);
- if (a.length !== bexp.length) return { ok: false, reason: 'signature mismatch' };
- if (!crypto.timingSafeEqual(a, bexp)) return { ok: false, reason: 'signature mismatch' };
- return { ok: true };
-}
-
// ── Helpers ──────────────────────────────────────────────────────────────────
// ── Helpers ──────────────────────────────────────────────────────────────────
-// Resolve the OpenVibe.Live user who owns the PowerChat account this event is for.
-async function _resolveStreamerUserId(streamer) {
- if (!streamer) return null;
- let conn = null;
- if (streamer.id) conn = await db.getPowerchatConnectionByPcUserId(String(streamer.id));
- if (!conn && streamer.username) conn = await db.getPowerchatConnectionByUsername(streamer.username);
- return conn ? conn.user_id : null;
-}
-
// The viewer's chosen donation goal rides in app_purpose ("goal:12" — the purpose
// The viewer's chosen donation goal rides in app_purpose ("goal:12" — the purpose
// key is the category vocabulary; see the checkout-attribution docs). Older links
// key is the category vocabulary; see the checkout-attribution docs). Older links
// encoded it in app_ref, so that stays as a fallback.
// encoded it in app_ref, so that stays as a fallback.
@@ -70,16 +26,6 @@ function _goalIdFromDonation(data) {
@@ -70,16 +26,6 @@ function _goalIdFromDonation(data) {
return null;
return null;
}
}
-// data.isTest = NO money moved (free test method, no payout provider connected,
-// dashboard test fires). Honoring those in production would grant Vibes/subs for
-// tips that charged nothing — so they're dropped unless the owner explicitly flips
-// powerchat_allow_test_fulfillment on (for dev/sandbox, where a PowerChat with no
-// payout provider delivers EVERYTHING as isTest).
-async function _testFulfillmentAllowed() {
- try { const v = await db.getSetting('powerchat_allow_test_fulfillment'); return v === true || v === 'true' || v === 1 || v === '1'; }
- catch { return false; }
-}
-
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
// A persisted chat line: one Chat ingress call (Chat persists and shows it; `mirror` also puts it
// A persisted chat line: one Chat ingress call (Chat persists and shows it; `mirror` also puts it
// in global chat). Live keeps no chat table.
// in global chat). Live keeps no chat table.
@@ -134,115 +80,6 @@ async function _handleDonation(userId, data, { fromUserId = null } = {}) {
@@ -134,115 +80,6 @@ async function _handleDonation(userId, data, { fromUserId = null } = {}) {
console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);
console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);
}
}
-// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).
-async function _handleSubscription(userId, data) {
- const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);
- try {
- await _chatLine({
- key: data.eventId ? `powerchat:${data.eventId}` : undefined,
- line: {
- stream_id: null, channel_user_id: userId, user_id: null, username: name,
- message: `${name} subscribed (PowerChat)`, message_type: 'donation',
- metadata: { kind: 'donation', amount: 0, username: name, source: 'powerchat-sub' },
- },
- });
- } catch { /* */ }
-}
-
-// A lightweight chat notice for non-money events (follow / host / points redeem).
-async function _handleNotice(userId, message, kind) {
- try {
- await _chatLine({
- line: {
- stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',
- message, message_type: 'system', metadata: { kind: kind || 'powerchat', source: 'powerchat' },
- },
- });
- } catch { /* */ }
-}
-
-// ── Entry point: process a verified, deduped envelope ────────────────────────
-async function processEvent(envelope) {
- if (!envelope || !envelope.type) return;
- const userId = await _resolveStreamerUserId(envelope.streamer);
- const data = envelope.data || {};
- if (!userId) {
- // The site tips account is a bare PowerChat username (no OpenVibe connection
- // row) — its checkout-attributed donations must still be fulfilled. Anything
- // else arriving for it (e.g. someone tipping that account directly, outside a
- // checkout link) has no on-site home and is deliberately ignored.
- const siteName = String(await db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
- const from = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
- if (siteName && from === siteName) {
- if (envelope.type === 'donation.completed' && data.appExternalRef
- && (!data.isTest || await _testFulfillmentAllowed()) && data.source !== 'developer_app') {
- if (data.isTest) console.warn(`[PowerChat] fulfilling TEST checkout ${data.appExternalRef} (powerchat_allow_test_fulfillment is ON — no money moved)`);
- try { await require('./powerchat-checkout').handleAttributedDonation(null, data); } catch (e) { console.warn('[PowerChat] site-account fulfillment:', e.message); }
- }
- return;
- }
- console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer);
- return;
- }
- // data.isTest = no money moved (dashboard test fires, free test method, no payout
- // provider). Never credit those — unless the owner explicitly enabled test
- // fulfillment for dev/sandbox, where every delivery is isTest.
- if (data.isTest && !await _testFulfillmentAllowed()) {
- // Make the skip self-explanatory when it's an attributed checkout — this is the
- // #1 "my tip went through but nothing credited" question: the PowerChat side
- // confirmed a TEST checkout (no payout provider / free test method).
- const ref = data.appExternalRef ? ` (checkout ref ${data.appExternalRef} — no money moved; enable powerchat_allow_test_fulfillment in admin to credit test checkouts in dev)` : '';
- console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)${ref}`);
- return;
- }
- if (data.isTest) console.warn(`[PowerChat] processing TEST ${envelope.type} (powerchat_allow_test_fulfillment is ON — no money moved)`);
- // App-sourced events are OUR OWN pushes (tips/subs/follows/redemptions we forwarded
- // via the platform scopes) echoing back through the webhook — they already happened
- // locally, so replaying them would double-count/duplicate every one.
- if (data.source === 'developer_app') {
- console.log(`[PowerChat] skipping app-sourced ${envelope.type} echo (${data.eventId || 'no id'})`);
- return;
- }
- try {
- switch (envelope.type) {
- case 'donation.completed':
- // The authoritative money event. paid_message.created is a subset of this
- // (a tip that carried a message) — we DON'T credit on it to avoid double-count.
- // Checkout-attributed tips (Vibes purchases, PowerChat-paid subs, and
- // site-routed donations for PowerChat-less streamers) are fulfilled by the
- // checkout module; when it consumes the event, the receiving account's
- // normal donation pipeline must NOT also run.
- if (data.appExternalRef && /^(pcorder|pcsub|pcdon):/.test(String(data.appExternalRef))) {
- const siteName = String(await db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
- const receiving = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
- const viaSiteAccount = !!siteName && receiving === siteName;
- if (await require('./powerchat-checkout').handleAttributedDonation(userId, data, { viaSiteAccount })) break;
- }
- await _handleDonation(userId, data);
- break;
- case 'subscription.created':
- await _handleSubscription(userId, data);
- break;
- case 'follow.created':
- await _handleNotice(userId, `${String(data.followerName || 'Someone').slice(0, 80)} followed on PowerChat`, 'follow');
- break;
- case 'host.received':
- await _handleNotice(userId, `${String(data.hostChannel || 'Someone').slice(0, 80)} hosted with ${data.viewers || 0} viewers (PowerChat)`, 'host');
- break;
- case 'channel_points.redeemed':
- await _handleNotice(userId, `${String(data.redeemerName || 'Someone').slice(0, 80)} redeemed ${String(data.rewardName || 'a reward').slice(0, 80)} (PowerChat)`, 'points');
- break;
- // paid_message.created → display twin of donation.completed (ignored to avoid
- // double-counting). goal.updated/completed reflect PowerChat's own goals;
- // OpenVibe.Live runs its own goals credited by donations above, so we ignore them.
- default:
- break;
- }
- } catch (err) {
- console.warn(`[PowerChat] processEvent(${envelope.type}) failed:`, err.message);
- }
-}
-
// Fire a fake tip through the LIVE pipeline so a streamer can verify their alert sound +
// Fire a fake tip through the LIVE pipeline so a streamer can verify their alert sound +
// chat celebration render, WITHOUT touching PowerChat (no scope needed) and WITHOUT
// chat celebration render, WITHOUT touching PowerChat (no scope needed) and WITHOUT
// permanently crediting a goal. Broadcasts the donation chat event + plays the alert sound;
// permanently crediting a goal. Broadcasts the donation chat event + plays the alert sound;
@@ -285,8 +122,7 @@ async function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper',
@@ -285,8 +122,7 @@ async function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper',
}
}
module.exports = {
module.exports = {
- verifySignature, processEvent, publicGoal, simulateDonation,
- // The full on-site donation celebration (chat event, alert sound, goal credit) for
- // a given streamer — reused by the checkout module for site-routed donations.
+ publicGoal, simulateDonation,
+ // The full on-site donation celebration (chat line, alert sound, goal credit, tip history) for a streamer.
creditDonationPipeline: _handleDonation,
creditDonationPipeline: _handleDonation,
};
};
@@ -138,33 +138,21 @@ class MediaQueue {
@@ -138,33 +138,21 @@ class MediaQueue {
return;
return;
}
}
if (currency === 'vibes') {
if (currency === 'vibes') {
- // Vibes are money (ADR-012): refused while money writes are frozen, in both modes.
+ // Vibes are money (ADR-012): refused while money writes are frozen.
await require('../monetization/money-authority').assertWritable();
await require('../monetization/money-authority').assertWritable();
- if (require('../monetization/money-authority').onBilling()) {
- // A paid interaction through OpenVibe.Billing (the requester's credit → the
- // streamer's payable); the returned charge is linked to the request for refunds.
- const billingActions = require('../monetization/billing-actions');
- try {
- return await billingActions.chargeMedia({ userId, streamerId, streamId, cost, label, requestId });
- } catch (e) {
- const live = billingActions.toLive(e, { insufficient: `Not enough Vibes — this costs ${cost}.` });
- const err = new Error(live ? live.body.error : (e?.message || `Could not charge ${cost} Vibes.`));
- // Billing never answered: the request row stays ('unknown') for the staff money
- // page, which resolves the billing action keyed live:media_charge:<request id>.
- if (e && e.kind === 'unknown') err.outcomeUnknown = true;
- throw err;
- }
- }
- // Vibes have no generic spend: paying a streamer for a request IS a donation
- // to them, so it goes through the same path and shows up in their totals.
+ // A paid interaction through OpenVibe.Billing (the requester's credit → the
+ // streamer's payable); the returned charge is linked to the request for refunds.
+ const billingActions = require('../monetization/billing-actions');
try {
try {
- require('../monetization/vibes').donate(userId, streamerId, streamId || null, cost, label);
+ return await billingActions.chargeMedia({ userId, streamerId, streamId, cost, label, requestId });
} catch (e) {
} catch (e) {
- throw new Error(/insufficient/i.test(e?.message || '')
- ? `Not enough Vibes — this costs ${cost}.`
- : (e?.message || `Could not charge ${cost} Vibes.`));
+ const live = billingActions.toLive(e, { insufficient: `Not enough Vibes — this costs ${cost}.` });
+ const err = new Error(live ? live.body.error : (e?.message || `Could not charge ${cost} Vibes.`));
+ // Billing never answered: the request row stays ('unknown') for the staff money
+ // page, which resolves the billing action keyed live:media_charge:<request id>.
+ if (e && e.kind === 'unknown') err.outcomeUnknown = true;
+ throw err;
}
}
- return;
}
}
// opencoins — network wallet
// opencoins — network wallet
const wallet = require('../monetization/wallet-client');
const wallet = require('../monetization/wallet-client');
@@ -589,24 +577,11 @@ class MediaQueue {
@@ -589,24 +577,11 @@ class MediaQueue {
console.warn(`[MediaQueue] refund of request ${request.id} deferred: money writes are frozen`);
console.warn(`[MediaQueue] refund of request ${request.id} deferred: money writes are frozen`);
return 0;
return 0;
}
}
- if (money.onBilling()) {
- return require('../monetization/billing-actions').refundMedia(request).then(async (n) => {
- if (n > 0) await db.updateMediaRequest(requestId, { refunded: 1 });
- return n;
- });
- }
- // The charge was booked as a donation to the streamer, so unwind both sides — atomically,
- // and only if the streamer still holds the money. Crediting the requester after a failed
- // deduction minted Vibes: pay, move the balance out (recycle or cash out), then refund.
- const unwound = await db.getDb().tx(async () => {
- if (!await db.deductVibesCashout(request.streamer_id, amount)) return false;
- await db.addVibes(request.user_id, amount);
- return true;
+ // Billing reverses the charge linked to the request (and only while the streamer still holds it).
+ return require('../monetization/billing-actions').refundMedia(request).then(async (n) => {
+ if (n > 0) await db.updateMediaRequest(requestId, { refunded: 1 });
+ return n;
});
});
- if (!unwound) {
- console.warn(`[MediaQueue] refund of request ${request.id} refused: streamer ${request.streamer_id} no longer holds ${amount} Vibes`);
- return 0;
- }
} else {
} else {
// Idempotent per request id, so a double-refund is a no-op server-side.
// Idempotent per request id, so a double-refund is a no-op server-side.
require('../monetization/wallet-client')
require('../monetization/wallet-client')
@@ -189,10 +189,8 @@ router.post('/quote', optionalAuth, async (req, res) => {
@@ -189,10 +189,8 @@ router.post('/quote', optionalAuth, async (req, res) => {
let balance = null;
let balance = null;
if (req.user && currency === 'points') balance = await db.getChannelPoints(req.user.id, streamerId);
if (req.user && currency === 'points') balance = await db.getChannelPoints(req.user.id, streamerId);
else if (req.user && currency === 'vibes') {
else if (req.user && currency === 'vibes') {
- if (require('../monetization/money-authority').onBilling()) {
- // Billing holds Vibes; unknown (null) when it cannot answer — never the legacy column.
- try { balance = (await require('../monetization/billing-actions').balance(req.user.id, req.headers)).balance; } catch { balance = null; }
- } else balance = (await db.getUserById(req.user.id))?.openvibe_bucks_balance ?? null;
+ // Billing holds Vibes; unknown (null) when it cannot answer.
+ try { balance = (await require('../monetization/billing-actions').balance(req.user.id, req.headers)).balance; } catch { balance = null; }
}
}
res.json({
res.json({
@@ -1,9 +1,8 @@
@@ -1,9 +1,8 @@
'use strict';
'use strict';
/**
/**
- * Live's money actions when BILLING_AUTHORITY=billing (roadmap Wave 8, ADR-012).
+ * Live's money actions, all on OpenVibe.Billing, the money ledger since the cutover of 2026-10-17 (ADR-012).
*
*
- * Each function is the Billing-backed twin of what vibes.js / payments-routes.js do against Live's
- * own columns, returning the SAME response shapes so the routes and the SPA do not change:
+ * Each function answers in the response shapes Live's routes and the SPA had before the cutover:
*
*
* checkout (Buy Vibes) → POST /intents {kind: purchase} billing.intent.create
* checkout (Buy Vibes) → POST /intents {kind: purchase} billing.intent.create
* PayPal return → POST /intents/:id/capture billing.intent.create
* PayPal return → POST /intents/:id/capture billing.intent.create
@@ -1,8 +1,7 @@
@@ -1,8 +1,7 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Events → Live: what OpenVibe.Billing settled, told to the people it concerns, once Billing is the ledger
- * (BILLING_AUTHORITY=billing). In `live` mode Live's own checkout, webhook and renewal sweeper send all of this, so
- * every event is acknowledged and left alone. Closes items 2 and 5 of Billing's cutover list (docs/live-cutover.md).
+ * OpenVibe.Events → Live: what OpenVibe.Billing (the money ledger) settled, told to the people it concerns: the
+ * notices and celebrations Live's own checkout, webhook and renewal sweeper sent before the cutover of 2026-10-17.
*
*
* POST /internal/billing-events Live's subscription to billing.transaction.settled and billing.entitlement.changed
* POST /internal/billing-events Live's subscription to billing.transaction.settled and billing.entitlement.changed
* (scripts/subscribe-media-events.js --billing), signed with LIVE_EVENTS_SECRET
* (scripts/subscribe-media-events.js --billing), signed with LIVE_EVENTS_SECRET
@@ -21,7 +20,6 @@
@@ -21,7 +20,6 @@
* transaction never notifies.
* transaction never notifies.
*/
*/
const db = require('../db/database');
const db = require('../db/database');
-const money = require('./money-authority');
const CONSUMER = 'live-billing-notices';
const CONSUMER = 'live-billing-notices';
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -115,7 +113,6 @@ async function apply(ev) {
@@ -115,7 +113,6 @@ async function apply(ev) {
if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
if (ev.source !== 'billing') return 'ignored:source';
if (ev.source !== 'billing') return 'ignored:source';
if (ev.event_type !== 'billing.transaction.settled' && ev.event_type !== 'billing.entitlement.changed') return 'ignored:type';
if (ev.event_type !== 'billing.transaction.settled' && ev.event_type !== 'billing.entitlement.changed') return 'ignored:type';
- if (!money.onBilling()) return 'ignored:live-ledger';
let after = null;
let after = null;
const out = await ensureInbox().once(CONSUMER, `billing:${ev.event_id}`, async () => { after = await plan(ev); return after ? 'notify' : 'nothing'; });
const out = await ensureInbox().once(CONSUMER, `billing:${ev.event_id}`, async () => { after = await plan(ev); return after ? 'notify' : 'nothing'; });
if (out.duplicate) return 'duplicate';
if (out.duplicate) return 'duplicate';
deleted file mode 100644
deleted file mode 100644
@@ -1,190 +0,0 @@
@@ -1,190 +0,0 @@
-/**
- * OpenVibe.Live — Community Funds (legacy Vibes engine variant)
- *
- * Kept for the community-funds flows (donations, goals, escrow cashout with
- * admin approval). The active tipping currency engine is vibes.js — this module
- * is not currently mounted; see monetization/routes.js for the live routes.
- */
-const db = require('../db/database');
-const config = require('../config');
-const { assertLiveLedger } = require('./money-authority');
-
-class CommunityFunds {
- /**
- * Purchase Vibes
- * @param {number} userId
- * @param {number} amount - Number of Vibes to purchase
- * @param {string} paypalTxId - PayPal transaction ID
- */
- async purchase(userId, amount, paypalTxId) {
- assertLiveLedger('transactions insert');
- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)
- VALUES (NULL, ?, ?, 'purchase', 'completed', ?) RETURNING id`, [userId, amount, `Purchased ${amount} Vibes`]);
-
- // Update PayPal reference
- if (paypalTxId) {
- await db.run('UPDATE transactions SET paypal_transaction_id = ? WHERE id = ?',
- [paypalTxId, tx.lastInsertRowid]);
- }
-
- await db.addVibes(userId, amount);
- return tx;
- }
-
- /**
- * Donate Vibes to a streamer
- * @param {number} fromUserId - Donor
- * @param {number} toUserId - Streamer
- * @param {number} streamId - Current stream
- * @param {number} amount - Vibes to donate
- * @param {string} message - Donation message
- */
- async donate(fromUserId, toUserId, streamId, amount, message) {
- if (amount <= 0) throw new Error('Amount must be positive');
-
- // Deduct from donor
- if (!await db.deductVibes(fromUserId, amount)) {
- throw new Error('Insufficient Vibes');
- }
-
- // Credit streamer (held in their balance)
- await db.addVibes(toUserId, amount);
-
- // Record transaction
- await db.createTransaction({
- from_user_id: fromUserId,
- to_user_id: toUserId,
- stream_id: streamId,
- amount,
- type: 'donation',
- status: 'completed',
- message: message || null,
- });
-
- // Update donation goals
- await this.updateGoals(toUserId, amount);
-
- return { success: true, amount };
- }
-
- /**
- * Update active donation goals for a user
- */
- async updateGoals(userId, amount) {
- const goals = await db.all(
- 'SELECT * FROM donation_goals WHERE user_id = ? AND is_active = 1 ORDER BY created_at',
- [userId]
- );
-
- for (const goal of goals) {
- const newAmount = Math.min(goal.current_amount + amount, goal.target_amount);
- await db.run('UPDATE donation_goals SET current_amount = ? WHERE id = ?',
- [newAmount, goal.id]);
-
- if (newAmount >= goal.target_amount) {
- await db.run('UPDATE donation_goals SET is_active = 0 WHERE id = ?', [goal.id]);
- }
- }
- }
-
- /**
- * Request cashout (goes to escrow for admin review)
- */
- async requestCashout(userId, amount, paypalEmail) {
- assertLiveLedger('transactions insert');
- if (amount < config.openvibeBucks.minCashoutBucks) {
- throw new Error(`Minimum cashout is ${config.openvibeBucks.minCashoutBucks.toLocaleString()} Vibes`);
- }
-
- if (!await db.deductVibes(userId, amount)) {
- throw new Error('Insufficient Vibes');
- }
-
- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)
- VALUES (?, NULL, ?, 'cashout', 'escrow', ?) RETURNING id`, [userId, amount, `Cashout to PayPal: ${paypalEmail}`]);
-
- return {
- transaction_id: tx.lastInsertRowid,
- amount,
- usd_value: (amount / 100).toFixed(2),
- status: 'escrow',
- hold_days: config.openvibeBucks.escrowDays,
- };
- }
-
- /**
- * Admin: Approve a cashout (release from escrow)
- */
- async approveCashout(transactionId) {
- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',
- [transactionId, 'escrow']);
- if (!tx) throw new Error('Transaction not found or not in escrow');
-
- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['completed', transactionId]);
- return tx;
- }
-
- /**
- * Admin: Deny a cashout (refund to user)
- */
- async denyCashout(transactionId, reason) {
- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',
- [transactionId, 'escrow']);
- if (!tx) throw new Error('Transaction not found or not in escrow');
-
- // Refund the amount
- await db.addVibes(tx.from_user_id, tx.amount);
- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['refunded', transactionId]);
-
- return tx;
- }
-
- /**
- * Get user's transaction history
- */
- async getHistory(userId, limit = 50) {
- return await db.all(`
- SELECT * FROM transactions
- WHERE from_user_id = ? OR to_user_id = ?
- ORDER BY created_at DESC LIMIT ?
- `, [userId, userId, limit]);
- }
-
- /**
- * Get donation leaderboard for a stream
- */
- async getLeaderboard(streamId, limit = 10) {
- return await db.all(`
- SELECT from_user_id, u.username, u.display_name, u.avatar_url,
- SUM(amount)::bigint as total_donated
- FROM transactions t
- JOIN users u ON t.from_user_id = u.id
- WHERE t.stream_id = ? AND t.type = 'donation' AND t.status = 'completed'
- GROUP BY from_user_id, u.id
- ORDER BY total_donated DESC
- LIMIT ?
- `, [streamId, limit]);
- }
-
- /**
- * Get active donation goals for a user
- */
- async getGoals(userId) {
- return await db.all(
- 'SELECT * FROM donation_goals WHERE user_id = ? AND is_active = 1 ORDER BY created_at',
- [userId]
- );
- }
-
- /**
- * Create a donation goal
- */
- async createGoal(userId, title, targetAmount) {
- return await db.run(
- 'INSERT INTO donation_goals (user_id, title, target_amount) VALUES (?, ?, ?)',
- [userId, title, targetAmount]
- );
- }
-}
-
-module.exports = new CommunityFunds();
@@ -1,55 +1,21 @@
@@ -1,55 +1,21 @@
'use strict';
'use strict';
/**
/**
- * Who holds the money, and whether it may move (roadmap Wave 8, ADR-012).
+ * Who holds the money, and whether it may move (ADR-012).
*
*
- * BILLING_AUTHORITY is read here and nowhere else:
- * live (default) Live's own columns and tables, exactly as before the Billing cutover.
- * billing OpenVibe.Billing is the ledger. Live asks it for every money action (billing-client.js,
- * billing-actions.js) and never writes users.openvibe_bucks_balance /
- * openvibe_bucks_cashout_balance, transactions, payment_orders or subscriptions — those
- * become legacy, read-only (the database-level tripwire below enforces it).
- * anything else is a misconfiguration: every Live money write is refused, never guessed.
+ * OpenVibe.Billing is the money ledger since the cutover of 2026-10-17: Live asks it for every money action
+ * (billing-client.js, billing-actions.js) and keeps no balance, order or subscription of its own. BILLING_AUTHORITY is
+ * no longer read: there is no other mode to switch back to.
*
*
- * money_writes_frozen (site setting, owner-only, both modes) is runbook step 3 of the cutover: it
- * refuses every money action a person or an operator starts on Live — checkouts, donations and
- * tips, Vibes-paid media requests and their refunds, cashout requests/approvals/denials, recycling,
- * subscribing, cancelling, and the renewal sweep. Reads keep working. Provider confirmations of
- * checkouts that were started BEFORE the freeze (PowerChat/Stripe/PayPal/CCBill/crypto webhooks,
- * the PayPal return, the PowerChat paid-messages reconciler) still settle in `live` mode: that
- * money has already been taken by the provider, and refusing it would drop a real payment. The
- * runbook waits for them before the final snapshot.
+ * money_writes_frozen (site setting, owner-only) refuses every money action a person or an operator starts on Live:
+ * checkouts, donations and tips, Vibes-paid media requests and their refunds, cashout requests, recycling, subscribing
+ * and cancelling. Reads keep working. Billing has its own freeze for its side.
*/
*/
-const MODES = ['live', 'billing'];
-
-/** 'live' | 'billing' | 'invalid' — read on every call so tests and the admin page see the truth. */
-function authority() {
- const raw = process.env.BILLING_AUTHORITY;
- if (raw === undefined || String(raw).trim() === '') return 'live';
- const v = String(raw).trim().toLowerCase();
- return MODES.includes(v) ? v : 'invalid';
-}
-const onBilling = () => authority() === 'billing';
-
-class LedgerReadOnlyError extends Error {
- constructor(what, mode) {
- super(mode === 'billing'
- ? `Live's money columns are read-only (BILLING_AUTHORITY=billing): ${what} must go through OpenVibe.Billing`
- : `BILLING_AUTHORITY=${JSON.stringify(process.env.BILLING_AUTHORITY)} is not 'live' or 'billing': ${what} refused`);
- this.name = 'LedgerReadOnlyError';
- this.status = 503;
- this.code = 'ledger_read_only';
- }
-}
-
-/** Tripwire for database.js: a Live money column/table write outside `live` mode throws. */
-function assertLiveLedger(what) {
- const mode = authority();
- if (mode !== 'live') throw new LedgerReadOnlyError(what, mode);
-}
+/** Always 'billing' (the admin page and the client config still show it). */
+const authority = () => 'billing';
// ── Freeze (money_writes_frozen) ─────────────────────────────
// ── Freeze (money_writes_frozen) ─────────────────────────────
-const db = () => require('../db/database'); // lazy: database.js requires this module
+const db = () => require('../db/database'); // lazy: no load cycle with database.js
async function isFrozen() {
async function isFrozen() {
try {
try {
@@ -81,10 +47,6 @@ async function setFrozen(on, { reason = null, by = null } = {}) {
@@ -81,10 +47,6 @@ async function setFrozen(on, { reason = null, by = null } = {}) {
* `{ error }` bodies plus a stable `code`.
* `{ error }` bodies plus a stable `code`.
*/
*/
async function writeRefusal() {
async function writeRefusal() {
- const mode = authority();
- if (mode === 'invalid') {
- return { status: 503, body: { error: 'Payments are unavailable: the server\'s billing configuration is invalid.', code: 'billing_misconfigured' } };
- }
if (await isFrozen()) {
if (await isFrozen()) {
const st = await freezeState();
const st = await freezeState();
return { status: 503, body: { error: 'Payments, donations and cashouts are paused for maintenance. Nothing was charged — please try again later.', code: 'money_writes_frozen', reason: st.reason || undefined } };
return { status: 503, body: { error: 'Payments, donations and cashouts are paused for maintenance. Nothing was charged — please try again later.', code: 'money_writes_frozen', reason: st.reason || undefined } };
@@ -110,4 +72,4 @@ async function assertWritable() {
@@ -110,4 +72,4 @@ async function assertWritable() {
}
}
}
}
-module.exports = { authority, onBilling, assertLiveLedger, LedgerReadOnlyError, isFrozen, freezeState, setFrozen, writeRefusal, guardWrite, assertWritable, MODES };
+module.exports = { authority, isFrozen, freezeState, setFrozen, writeRefusal, guardWrite, assertWritable };
@@ -1,36 +1,23 @@
@@ -1,36 +1,23 @@
/**
/**
* OpenVibe.Live — Payments & Subscriptions API (mounted at /api/payments)
* OpenVibe.Live — Payments & Subscriptions API (mounted at /api/payments)
*
*
- * - Buy Vibes via PayPal / Stripe / CCBill / crypto (hosted checkout).
- * - Subscribe to a channel via Stripe (recurring) or Vibes (30 days).
- * - Provider webhooks credit bucks / activate subs idempotently.
+ * OpenVibe.Billing is the money ledger (since the cutover of 2026-10-17). Every action here asks it
+ * (billing-actions.js): buying Vibes (PayPal / Stripe / CCBill / crypto / PowerChat checkouts),
+ * subscribing to a channel (tier 1, at the creator's price), cancelling and reading subscriptions.
+ * Live keeps no money record. money_writes_frozen (money-authority.js) still refuses new money
+ * actions. The old provider webhooks answer 410 with Billing's address: providers deliver there.
*/
*/
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
-const config = require('../config');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
const pay = require('./payments');
const pay = require('./payments');
const openvibeBucks = require('./vibes');
const openvibeBucks = require('./vibes');
-// BILLING_AUTHORITY=billing: checkouts, subscriptions and entitlements go to OpenVibe.Billing and
-// the provider webhooks below answer 410 (they land in Billing). money_writes_frozen refuses new
-// money actions in both modes; provider confirmations of checkouts started before a freeze still
-// settle in `live` mode (see money-authority.js).
const money = require('./money-authority');
const money = require('./money-authority');
const billingActions = require('./billing-actions');
const billingActions = require('./billing-actions');
const billingClient = require('./billing-client');
const billingClient = require('./billing-client');
-const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill', crypto: 'nowpayments' };
-function webhookMovedToBilling(provider) {
- return (req, res, next) => {
- if (!money.onBilling()) return next();
- return res.status(410).json({
- error: 'Payment webhooks are received by OpenVibe.Billing now.',
- webhook_url: `${billingClient.publicUrl()}/webhooks/${BILLING_WEBHOOK[provider]}`,
- });
- };
-}
+const BILLING_WEBHOOK = { stripe: 'stripe', paypal: 'paypal', ccbill: 'ccbill', crypto: 'nowpayments' };
const router = express.Router();
const router = express.Router();
-function base() { return config.baseUrl.replace(/\/+$/, ''); }
// ── Public config (which providers are live + pricing) ───────
// ── Public config (which providers are live + pricing) ───────
router.get('/config', async (req, res) => {
router.get('/config', async (req, res) => {
@@ -43,93 +30,27 @@ router.post('/bucks/checkout', requireAuth, money.guardWrite, async (req, res) =
@@ -43,93 +30,27 @@ router.post('/bucks/checkout', requireAuth, money.guardWrite, async (req, res) =
// PowerChat purchases run on their own enablement (powerchat_enabled + site tips
// PowerChat purchases run on their own enablement (powerchat_enabled + site tips
// account) — the master payments switch only gates the card/PayPal/crypto rails.
// account) — the master payments switch only gates the card/PayPal/crypto rails.
if (!await pay.isEnabled() && provider !== 'powerchat') return res.status(403).json({ error: 'Payments are not enabled' });
if (!await pay.isEnabled() && provider !== 'powerchat') return res.status(403).json({ error: 'Payments are not enabled' });
- // The client picks a Vibes amount (bit-style); the USD price is derived from the
- // volume-discount tiers so bigger buys are cheaper per buck and the platform keeps the spread.
let bucks;
let bucks;
try { bucks = openvibeBucks.normalizeBucks(req.body.bucks); } catch (e) { return res.status(400).json({ error: e.message }); }
try { bucks = openvibeBucks.normalizeBucks(req.body.bucks); } catch (e) { return res.status(400).json({ error: e.message }); }
const minBucks = await pay._num('bucks_min_purchase_bucks', 100);
const minBucks = await pay._num('bucks_min_purchase_bucks', 100);
if (bucks < minBucks) return res.status(400).json({ error: `Minimum purchase is ${minBucks.toLocaleString()} Vibes` });
if (bucks < minBucks) return res.status(400).json({ error: `Minimum purchase is ${minBucks.toLocaleString()} Vibes` });
if (bucks > 1_000_000) return res.status(400).json({ error: 'Amount too large' });
if (bucks > 1_000_000) return res.status(400).json({ error: 'Amount too large' });
-
- if (money.onBilling()) {
- if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });
- try {
- const r = await billingActions.checkout(req, { provider, bucks });
- return res.status(r.status).json(r.body);
- } catch (err) {
- if (billingActions.sendError(res, err)) return;
- console.error('[Payments] billing checkout error:', err.message);
- return res.status(502).json({ error: 'Payment provider error. Try again.' });
- }
- }
-
- const amountUsd = openvibeBucks.priceUsdForBucks(bucks);
- const order = await db.createPaymentOrder({
- user_id: req.user.id, provider, kind: 'bucks',
- amount_cents: Math.round(amountUsd * 100), bucks,
- });
- const name = `${bucks.toLocaleString()} Vibes`;
+ if (!['stripe', 'paypal', 'ccbill', 'crypto', 'powerchat'].includes(provider)) return res.status(400).json({ error: 'Unknown payment provider' });
try {
try {
- if (provider === 'stripe') {
- const r = await pay.stripeCheckout({
- order, name, amountCents: order.amount_cents, kind: 'bucks',
- successUrl: `${base()}/?purchase=success`, cancelUrl: `${base()}/?purchase=cancel`,
- });
- return res.json({ url: r.url });
- }
- if (provider === 'paypal') {
- const r = await pay.paypalCreateOrder({ order, amountUsd, description: name });
- return res.json({ url: r.url });
- }
- if (provider === 'ccbill') {
- return res.json({ url: await pay.ccbillUrl({ order, amountUsd }) });
- }
- if (provider === 'crypto') {
- const r = await pay.cryptoCreateInvoice({ order, amountUsd, description: name });
- return res.json({ url: r.url });
- }
- if (provider === 'powerchat') {
- // Tip the site's PowerChat account; the checkout is PINNED to the package
- // price (a server-minted intent locks the tip page's amount) and the
- // donation.completed webhook credits the buyer once it confirms.
- const link = await require('../integrations/powerchat-checkout').buildPurchaseLink(order);
- if (!link) { await db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
- return res.json({
- url: link.url, powerchat: true, amountUsd, pinned: !!link.minted, expires_at: link.expiresAt || null,
- note: `Complete the $${amountUsd.toFixed(2)} tip on PowerChat — your Vibes are credited automatically once it confirms.`,
- });
- }
- return res.status(400).json({ error: 'Unknown payment provider' });
+ const r = await billingActions.checkout(req, { provider, bucks });
+ return res.status(r.status).json(r.body);
} catch (err) {
} catch (err) {
- console.error('[Payments] checkout error:', err.message);
- await db.updatePaymentOrder(order.id, { status: 'failed' });
+ if (billingActions.sendError(res, err)) return;
+ console.error('[Payments] billing checkout error:', err.message);
return res.status(502).json({ error: 'Payment provider error. Try again.' });
return res.status(502).json({ error: 'Payment provider error. Try again.' });
}
}
});
});
-// PayPal buyer returns here after approving — capture + fulfill.
+// PayPal buyer returns here after approving: Billing's PayPal intents come back with ?token=<PayPal order id>,
+// and Billing captures and settles.
router.get('/paypal/return', async (req, res) => {
router.get('/paypal/return', async (req, res) => {
- if (money.onBilling()) {
- // Billing's PayPal intents come back with ?token=<PayPal order id>; Billing captures + settles.
- try { return res.redirect(await billingActions.paypalReturn(req.query.token) ? '/?purchase=success' : '/?purchase=error'); }
- catch (err) { console.error('[Payments] billing paypal return:', err.detail || err.message); return res.redirect('/?purchase=error'); }
- }
- try {
- const order = await db.getPaymentOrderById(parseInt(req.query.order, 10));
- if (!order || order.provider !== 'paypal' || !order.provider_ref) return res.redirect('/?purchase=error');
- const cap = await pay.paypalCaptureOrder(order.provider_ref);
- const captured = cap.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value;
- const ok = cap.status === 'COMPLETED' && pay.paidAmountCovers(order, captured) !== false;
- if (ok) {
- if (order.kind === 'subscription') await pay.fulfillSubscriptionOrder(order);
- else await pay.fulfillBucksOrder(order);
- return res.redirect('/?purchase=success');
- }
- return res.redirect('/?purchase=error');
- } catch (err) {
- console.error('[Payments] paypal return:', err.message);
- return res.redirect('/?purchase=error');
- }
+ try { return res.redirect(await billingActions.paypalReturn(req.query.token) ? '/?purchase=success' : '/?purchase=error'); }
+ catch (err) { console.error('[Payments] billing paypal return:', err.detail || err.message); return res.redirect('/?purchase=error'); }
});
});
// ── Subscriptions ────────────────────────────────────────────
// ── Subscriptions ────────────────────────────────────────────
@@ -142,101 +63,23 @@ router.post('/subscribe', requireAuth, money.guardWrite, async (req, res) => {
@@ -142,101 +63,23 @@ router.post('/subscribe', requireAuth, money.guardWrite, async (req, res) => {
const streamer = await db.getUserByUsername(String(req.body.streamer || ''));
const streamer = await db.getUserByUsername(String(req.body.streamer || ''));
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });
if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });
- if (money.onBilling()) {
- try {
- const r = await billingActions.subscribe(req, { streamer, provider, autoRenewRaw: req.body.auto_renew });
- return res.status(r.status).json(r.body);
- } catch (err) {
- if (billingActions.sendError(res, err, { insufficientStatus: 402, insufficient: (d) => `Not enough Vibes${d.required ? ` (need ${d.required})` : ''}`, self: 'You cannot subscribe to yourself' })) return;
- console.error('[Payments] billing subscribe error:', err.message);
- return res.status(502).json({ error: 'Payment provider error. Try again.' });
- }
- }
- if (await db.isActiveSubscriber(req.user.id, streamer.id)) return res.status(409).json({ error: 'Already subscribed' });
-
- const priceUsd = await pay._num('sub_price_usd', 4.99);
- const amountCents = Math.round(priceUsd * 100);
-
- // Pay with Vibes — instant 30-day sub; auto-renews from the Vibes balance unless
- // the subscriber opted out.
- if (provider === 'bucks') {
- const autoRenew = req.body.auto_renew === undefined ? 1 : (req.body.auto_renew ? 1 : 0);
- const cost = await pay.bucksForUsd(priceUsd);
- if (!await db.deductVibes(req.user.id, cost)) return res.status(402).json({ error: `Not enough Vibes (need ${cost})` });
- const order = await db.createPaymentOrder({
- user_id: req.user.id, provider: 'bucks', kind: 'subscription',
- amount_cents: amountCents, streamer_id: streamer.id, status: 'paid',
- });
- const sub = await pay.fulfillSubscriptionOrder(order, { autoRenew });
- return res.json({ ok: true, subscription: { streamer: streamer.username, current_period_end: sub.current_period_end, auto_renew: !!autoRenew } });
- }
-
- // Pay with a PowerChat tip — streamer's own tip page when they have PowerChat
- // (they keep the money directly), else the site-wide PowerChat account (the site
- // holds the money and the streamer gets their normal cashout-Vibes share). The
- // donation.completed webhook confirms and activates the sub. Renewals come from
- // the Vibes balance when auto_renew is on (a tip can't be auto-charged).
- if (viaPowerchat) {
- const autoRenew = req.body.auto_renew ? 1 : 0;
- const checkout = require('../integrations/powerchat-checkout');
- const routes = await checkout.subscribeRoutes(streamer.id);
- // 'powerchat' = the streamer's own PowerChat (no fee). 'powerchat_site' = OpenVibe's
- // PowerChat account with the platform routing fee on top. A plain 'powerchat'
- // request for a streamer without PowerChat falls back to the site route (+fee) so
- // older clients keep working — the new UI always asks explicitly.
- const wantSite = provider === 'powerchat_site' || !routes.direct;
- if (wantSite && !routes.site) return res.status(400).json({ error: 'PowerChat payments are not available right now' });
- const feePct = wantSite ? await pay._num('sub_site_route_fee_pct', 10) : 0;
- const feeCents = wantSite ? Math.round(amountCents * feePct / 100) : 0;
- const totalCents = amountCents + feeCents;
- const order = await db.createPaymentOrder({
- user_id: req.user.id, provider: 'powerchat', kind: 'subscription',
- amount_cents: totalCents, streamer_id: streamer.id,
- });
- const link = await checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });
- if (!link) { await db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }
- const totalUsd = totalCents / 100;
- return res.json({
- url: link.url, powerchat: true, route: link.mode, amountUsd: totalUsd, feeUsd: feeCents / 100,
- pinned: !!link.minted, expires_at: link.expiresAt || null,
- note: link.mode === 'direct'
- ? `Tip $${totalUsd.toFixed(2)} on ${streamer.display_name || streamer.username}'s PowerChat — your subscription activates automatically once the tip confirms.`
- : `Tip $${totalUsd.toFixed(2)} on OpenVibe's PowerChat (includes a $${(feeCents / 100).toFixed(2)} platform fee) — your subscription activates automatically once the tip confirms.`,
- });
- }
-
- // Stripe recurring subscription (auto-renew monthly).
- if (provider === 'stripe') {
- const order = await db.createPaymentOrder({
- user_id: req.user.id, provider: 'stripe', kind: 'subscription',
- amount_cents: amountCents, streamer_id: streamer.id,
- });
- try {
- const r = await pay.stripeCheckout({
- order, name: `Subscription to ${streamer.display_name || streamer.username}`,
- amountCents, kind: 'subscription',
- successUrl: `${base()}/@${streamer.username}?sub=success`, cancelUrl: `${base()}/@${streamer.username}?sub=cancel`,
- });
- return res.json({ url: r.url });
- } catch (err) {
- console.error('[Payments] stripe sub:', err.message);
- await db.updatePaymentOrder(order.id, { status: 'failed' });
- return res.status(502).json({ error: 'Stripe error. Try again.' });
- }
+ try {
+ const r = await billingActions.subscribe(req, { streamer, provider, autoRenewRaw: req.body.auto_renew });
+ return res.status(r.status).json(r.body);
+ } catch (err) {
+ if (billingActions.sendError(res, err, { insufficientStatus: 402, insufficient: (d) => `Not enough Vibes${d.required ? ` (need ${d.required})` : ''}`, self: 'You cannot subscribe to yourself' })) return;
+ console.error('[Payments] billing subscribe error:', err.message);
+ return res.status(502).json({ error: 'Payment provider error. Try again.' });
}
}
- return res.status(400).json({ error: 'Subscriptions support Stripe or Vibes' });
});
});
// My subscriptions
// My subscriptions
router.get('/subscriptions/mine', requireAuth, async (req, res) => {
router.get('/subscriptions/mine', requireAuth, async (req, res) => {
- if (money.onBilling()) {
- try { return res.json({ subscriptions: await billingActions.mySubscriptions(req.user.id) }); }
- catch (err) {
- if (billingActions.sendError(res, err, { unavailable: 'Subscriptions unavailable right now — the billing service is not answering.' })) return;
- return res.status(503).json({ error: 'Subscriptions unavailable right now', unavailable: true });
- }
+ try { return res.json({ subscriptions: await billingActions.mySubscriptions(req.user.id) }); }
+ catch (err) {
+ if (billingActions.sendError(res, err, { unavailable: 'Subscriptions unavailable right now — the billing service is not answering.' })) return;
+ return res.status(503).json({ error: 'Subscriptions unavailable right now', unavailable: true });
}
}
- res.json({ subscriptions: await db.getSubscriptionsBySubscriber(req.user.id) });
});
});
// The channel's memberships on OpenVibe.VIP (tiers) and other ways to support it (OpenVibe.Tips): public, the same
// The channel's memberships on OpenVibe.VIP (tiers) and other ways to support it (OpenVibe.Tips): public, the same
@@ -246,19 +89,14 @@ router.get('/channel/:username/elsewhere', async (req, res) => {
@@ -246,19 +89,14 @@ router.get('/channel/:username/elsewhere', async (req, res) => {
res.set('Cache-Control', 'public, max-age=60').json(out);
res.set('Cache-Control', 'public, max-age=60').json(out);
});
});
-// Am I subscribed to this channel? + subscriber count
+// Am I subscribed to this channel? + subscriber count + what Billing charges for tier 1 (the creator's price on VIP,
+// else Billing's default; the site setting only while Billing does not answer).
router.get('/channel/:username', optionalAuth, async (req, res) => {
router.get('/channel/:username', optionalAuth, async (req, res) => {
const streamer = await db.getUserByUsername(req.params.username);
const streamer = await db.getUserByUsername(req.params.username);
if (!streamer) return res.status(404).json({ error: 'Not found' });
if (!streamer) return res.status(404).json({ error: 'Not found' });
let subscribed = false, subscriberCount = null, billingUnavailable = false, billingPriceCents = null;
let subscribed = false, subscriberCount = null, billingUnavailable = false, billingPriceCents = null;
- if (money.onBilling()) {
- try { ({ subscribed, subscriberCount, priceCents: billingPriceCents } = await billingActions.channelState(streamer, req.user || null)); }
- catch { billingUnavailable = true; }
- } else {
- subscribed = req.user ? await db.isActiveSubscriber(req.user.id, streamer.id) : false;
- subscriberCount = await db.getActiveSubscriberCount(streamer.id);
- }
- // Under Billing the price is the creator's tier 1 (VIP tiers), which Billing charges; else the site setting.
+ try { ({ subscribed, subscriberCount, priceCents: billingPriceCents } = await billingActions.channelState(streamer, req.user || null)); }
+ catch { billingUnavailable = true; }
const priceUsd = billingPriceCents ? billingPriceCents / 100 : await pay._num('sub_price_usd', 4.99);
const priceUsd = billingPriceCents ? billingPriceCents / 100 : await pay._num('sub_price_usd', 4.99);
let powerchat = { direct: false, site: false };
let powerchat = { direct: false, site: false };
try { powerchat = await require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }
try { powerchat = await require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }
@@ -270,118 +108,24 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -270,118 +108,24 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
});
});
});
});
-// Cancel a subscription (Stripe: at period end; bucks: immediate)
+// Cancel a subscription (at the period end; Billing cancels at the provider too).
router.post('/subscriptions/:id/cancel', requireAuth, money.guardWrite, async (req, res) => {
router.post('/subscriptions/:id/cancel', requireAuth, money.guardWrite, async (req, res) => {
- if (money.onBilling()) {
- try {
- const r = await billingActions.cancelSubscription(req, { id: req.params.id, streamerId: parseInt(req.body.streamerId, 10) || null });
- return res.status(r.status).json(r.body);
- } catch (err) {
- if (billingActions.sendError(res, err)) return;
- return res.status(502).json({ error: 'Could not cancel right now. Try again.' });
- }
+ try {
+ const r = await billingActions.cancelSubscription(req, { id: req.params.id, streamerId: parseInt(req.body.streamerId, 10) || null });
+ return res.status(r.status).json(r.body);
+ } catch (err) {
+ if (billingActions.sendError(res, err)) return;
+ return res.status(502).json({ error: 'Could not cancel right now. Try again.' });
}
}
- const sub = await db.getActiveSubscription(req.user.id, parseInt(req.body.streamerId, 10) || null) ||
- (await db.getSubscriptionsBySubscriber(req.user.id) || []).find(x => String(x.id) === String(req.params.id));
- if (!sub || sub.subscriber_id !== req.user.id) return res.status(404).json({ error: 'Subscription not found' });
- // Stripe subs auto-renew; mark cancel-at-period-end (provider stops billing via dashboard/API).
- await db.setSubscriptionStatus(sub.id, sub.provider === 'stripe' ? 'active' : 'canceled', { cancel_at_period_end: true, current_period_end: sub.current_period_end });
- res.json({ ok: true });
});
});
-// ════════════════════════ WEBHOOKS ════════════════════════════
-function rawBody(req) { return req.rawBody ? req.rawBody.toString('utf8') : JSON.stringify(req.body || {}); }
-
-// Every webhook: the order must belong to that provider, and when the provider reports what was
-// paid it must cover the order (an order id is passed through the buyer's browser for some
-// providers, so without this a small payment could be pointed at a large order).
-function payable(order, provider, paidUsd) {
- if (!order || order.provider !== provider) return false;
- if (pay.paidAmountCovers(order, paidUsd) === false) {
- console.warn(`[Payments] ${provider} paid ${paidUsd} for order ${order.id} (${order.amount_cents / 100}) — not credited`);
- return false;
- }
- return true;
+// ── Provider webhooks: received by OpenVibe.Billing ──────────
+// A provider dashboard still pointing here learns where to deliver; Live credits nothing.
+for (const provider of ['stripe', 'paypal', 'ccbill', 'crypto']) {
+ router.all(`/webhook/${provider}`, (req, res) => res.status(410).json({
+ error: 'Payment webhooks are received by OpenVibe.Billing now.',
+ webhook_url: `${billingClient.publicUrl()}/webhooks/${BILLING_WEBHOOK[provider]}`,
+ }));
}
}
-// Stripe
-router.post('/webhook/stripe', webhookMovedToBilling('stripe'), async (req, res) => {
- const event = await pay.stripeVerify(rawBody(req), req.headers['stripe-signature']);
- if (!event) return res.status(400).send('bad signature');
- try {
- const obj = event.data && event.data.object;
- const orderId = obj && ((obj.metadata && obj.metadata.order_id) || obj.client_reference_id);
- if (event.type === 'checkout.session.completed' && orderId) {
- const order = await db.getPaymentOrderById(parseInt(orderId, 10));
- const paid = Number.isFinite(obj.amount_total) ? obj.amount_total / 100 : null;
- if (payable(order, 'stripe', paid)) {
- if (order.kind === 'subscription') await pay.fulfillSubscriptionOrder(order, { providerRef: obj.subscription });
- else await pay.fulfillBucksOrder(order);
- }
- } else if (event.type === 'invoice.paid') {
- // Recurring renewal — extend the sub by ~1 month.
- const subRef = obj.subscription;
- const existing = subRef && await db.getSubscriptionByProviderRef('stripe', subRef);
- if (existing) {
- const end = new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
- await db.setSubscriptionStatus(existing.id, 'active', { current_period_end: end });
- }
- } else if (event.type === 'customer.subscription.deleted') {
- const existing = await db.getSubscriptionByProviderRef('stripe', obj.id);
- if (existing) await db.setSubscriptionStatus(existing.id, 'canceled');
- }
- } catch (err) { console.error('[Payments] stripe webhook:', err.message); }
- res.json({ received: true });
-});
-
-// PayPal
-router.post('/webhook/paypal', webhookMovedToBilling('paypal'), async (req, res) => {
- try {
- const ok = await pay.paypalVerify(req.headers, rawBody(req));
- if (!ok) return res.status(400).send('unverified');
- const event = req.body;
- const rsrc = event.resource || {};
- if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') {
- const orderId = rsrc.custom_id || (rsrc.supplementary_data && rsrc.supplementary_data.related_ids && rsrc.supplementary_data.related_ids.order_id);
- const order = orderId && await db.getPaymentOrderById(parseInt(orderId, 10));
- const paid = rsrc.amount && rsrc.amount.currency_code === 'USD' ? rsrc.amount.value : null;
- if (payable(order, 'paypal', paid)) (order.kind === 'subscription' ? await pay.fulfillSubscriptionOrder(order) : await pay.fulfillBucksOrder(order));
- }
- } catch (err) { console.error('[Payments] paypal webhook:', err.message); }
- res.json({ received: true });
-});
-
-// CCBill (FlexForms datalink / webhook). Verified via shared secret in query.
-router.all('/webhook/ccbill', webhookMovedToBilling('ccbill'), async (req, res) => {
- if (!await pay.ccbillVerify(req.query)) return res.status(403).send('forbidden');
- try {
- const p = { ...req.query, ...req.body };
- const orderId = p['X-order'] || p.order;
- const order = orderId && await db.getPaymentOrderById(parseInt(orderId, 10));
- const success = String(p.eventType || p.transactionType || '').toLowerCase().includes('success')
- || p.accountingAmount || p.priceInfo || p.eventType === 'NewSaleSuccess';
- // X-order rides in the buyer-editable form URL (the digest covers only the price), so
- // CCBill's reported price is required here, not optional.
- const paid = [p.billedInitialPrice, p.subscriptionInitialPrice, p.accountingInitialPrice, p.initialPrice, p.accountingAmount]
- .find((v) => v != null && v !== '' && Number.isFinite(Number(v)));
- if (order && success && paid == null) console.warn(`[Payments] ccbill webhook for order ${order.id} carried no price — not credited`);
- else if (success && payable(order, 'ccbill', paid)) (order.kind === 'subscription' ? await pay.fulfillSubscriptionOrder(order) : await pay.fulfillBucksOrder(order));
- } catch (err) { console.error('[Payments] ccbill webhook:', err.message); }
- res.status(200).send('OK');
-});
-
-// Crypto (NOWPayments IPN)
-router.post('/webhook/crypto', webhookMovedToBilling('crypto'), async (req, res) => {
- const body = await pay.cryptoVerify(rawBody(req), req.headers['x-nowpayments-sig']);
- if (!body) return res.status(400).send('bad signature');
- try {
- if (['finished', 'confirmed', 'sending'].includes(String(body.payment_status))) {
- const order = await db.getPaymentOrderById(parseInt(body.order_id, 10));
- const paid = String(body.price_currency || 'usd').toLowerCase() === 'usd' ? body.price_amount : null;
- if (payable(order, 'crypto', paid)) (order.kind === 'subscription' ? await pay.fulfillSubscriptionOrder(order) : await pay.fulfillBucksOrder(order));
- }
- } catch (err) { console.error('[Payments] crypto webhook:', err.message); }
- res.json({ received: true });
-});
-
module.exports = router;
module.exports = router;
@@ -1,39 +1,20 @@
@@ -1,39 +1,20 @@
/**
/**
- * OpenVibe.Live — Payments
+ * OpenVibe.Live — payment settings for the client: which ways to pay are shown, and the prices.
*
*
- * Real-money purchase of Vibes + channel subscriptions across PayPal,
- * Stripe, CCBill and crypto (NOWPayments). All provider credentials live in
- * site_settings (configured in openvibe.network/admin → Payments). The master switch
- * `payments_enabled` gates everything (default OFF) so nothing is live until an
- * admin turns it on.
- *
- * Design:
- * - Every purchase creates a `payment_orders` row (pending) BEFORE redirecting.
- * - Providers redirect the buyer to a hosted checkout; on success the provider
- * calls our webhook, which we verify, then credit bucks / activate the sub
- * idempotently (an order is only credited once).
- * - We never see raw card data — Stripe/CCBill host the card form.
+ * OpenVibe.Billing runs every checkout, webhook, subscription and renewal since the cutover of 2026-10-17
+ * (billing-actions.js); the provider adapters live there. The master switch `payments_enabled` (site_settings,
+ * openvibe.network/admin → Payments) still decides whether Live offers card/PayPal/crypto at all; PowerChat has its
+ * own enablement.
*/
*/
-const crypto = require('crypto');
const db = require('../db/database');
const db = require('../db/database');
-const config = require('../config');
const money = require('./money-authority');
const money = require('./money-authority');
-function baseUrl() { return config.baseUrl.replace(/\/+$/, ''); }
async function s(key) { return (await db.getSetting(key) || '').toString().trim(); }
async function s(key) { return (await db.getSetting(key) || '').toString().trim(); }
async function b(key) { const v = await db.getSetting(key); return v === true || v === 'true' || v === 1 || v === '1'; }
async function b(key) { const v = await db.getSetting(key); return v === true || v === 'true' || v === 1 || v === '1'; }
async function n(key, dflt) { const v = parseFloat(await db.getSetting(key)); return Number.isFinite(v) ? v : dflt; }
async function n(key, dflt) { const v = parseFloat(await db.getSetting(key)); return Number.isFinite(v) ? v : dflt; }
async function isEnabled() { return await b('payments_enabled'); }
async function isEnabled() { return await b('payments_enabled'); }
-/** USD (dollars) → whole Vibes at the VALUE rate (100 bucks = $1). Used for
- * converting real income (sub share, external tips) into bucks — NOT for purchase
- * pricing (buying adds a margin; see vibes.js priceUsdForBucks). */
-async function bucksForUsd(usd) {
- const rate = await n('bucks_per_usd', 100);
- return Math.max(0, Math.round(usd * rate));
-}
-
/** Public provider availability + pricing for the client. */
/** Public provider availability + pricing for the client. */
async function publicConfig() {
async function publicConfig() {
const hb = require('./vibes');
const hb = require('./vibes');
@@ -62,349 +43,14 @@ async function publicConfig() {
@@ -62,349 +43,14 @@ async function publicConfig() {
powerchat: powerchatAvailable,
powerchat: powerchatAvailable,
},
},
stripePublishableKey: await s('stripe_publishable_key'),
stripePublishableKey: await s('stripe_publishable_key'),
- // Who holds the money (BILLING_AUTHORITY) and whether money actions are paused.
+ // Who holds the money (OpenVibe.Billing) and whether money actions are paused.
authority: money.authority(),
authority: money.authority(),
moneyFrozen: await money.isFrozen(),
moneyFrozen: await money.isFrozen(),
};
};
}
}
-// ── HTTP helpers ─────────────────────────────────────────────
-async function httpJson(url, { method = 'GET', headers = {}, body } = {}) {
- const res = await fetch(url, { method, headers, body });
- const text = await res.text();
- let json = null; try { json = text ? JSON.parse(text) : {}; } catch { /* */ }
- if (!res.ok) {
- const msg = (json && (json.message || json.error?.message || json.error || json.error_description)) || text || res.statusText;
- throw new Error(`${method} ${url} -> ${res.status}: ${msg}`);
- }
- return json || {};
-}
-
-// ══════════════════════════════════════════ STRIPE ══════════
-const STRIPE_API = 'https://api.stripe.com/v1';
-async function stripeHeaders() {
- return { Authorization: `Bearer ${await s('stripe_secret_key')}`, 'Content-Type': 'application/x-www-form-urlencoded' };
-}
-/** Create a Stripe Checkout Session. kind: 'bucks' (one-time) | 'subscription'. */
-async function stripeCheckout({ order, name, amountCents, kind, successUrl, cancelUrl }) {
- const p = new URLSearchParams();
- p.set('success_url', successUrl);
- p.set('cancel_url', cancelUrl);
- p.set('client_reference_id', String(order.id));
- p.set('metadata[order_id]', String(order.id));
- p.set('line_items[0][quantity]', '1');
- p.set('line_items[0][price_data][currency]', 'usd');
- p.set('line_items[0][price_data][unit_amount]', String(amountCents));
- p.set('line_items[0][price_data][product_data][name]', name);
- if (kind === 'subscription') {
- p.set('mode', 'subscription');
- p.set('line_items[0][price_data][recurring][interval]', 'month');
- p.set('subscription_data[metadata][order_id]', String(order.id));
- } else {
- p.set('mode', 'payment');
- }
- const session = await httpJson(`${STRIPE_API}/checkout/sessions`, { method: 'POST', headers: await stripeHeaders(), body: p.toString() });
- await db.updatePaymentOrder(order.id, { provider_ref: session.id });
- return { url: session.url, ref: session.id };
-}
-/** Verify a Stripe webhook signature (t=..,v1=..) against the raw body. */
-async function stripeVerify(rawBody, sigHeader) {
- const secret = await s('stripe_webhook_secret');
- if (!secret || !sigHeader) return null;
- const parts = Object.fromEntries(sigHeader.split(',').map(kv => kv.split('=')));
- if (!parts.t || !parts.v1) return null;
- const signed = `${parts.t}.${rawBody}`;
- const expected = crypto.createHmac('sha256', secret).update(signed, 'utf8').digest('hex');
- const a = Buffer.from(expected), c = Buffer.from(parts.v1);
- if (a.length !== c.length || !crypto.timingSafeEqual(a, c)) return null;
- try { return JSON.parse(rawBody); } catch { return null; }
-}
-
-// ══════════════════════════════════════════ PAYPAL ══════════
-async function paypalBase() { return await s('paypal_mode') === 'live' ? 'https://api-m.paypal.com' : 'https://api-m.sandbox.paypal.com'; }
-async function paypalToken() {
- const auth = Buffer.from(`${await s('paypal_client_id')}:${await s('paypal_client_secret')}`).toString('base64');
- const j = await httpJson(`${await paypalBase()}/v1/oauth2/token`, {
- method: 'POST',
- headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/x-www-form-urlencoded' },
- body: 'grant_type=client_credentials',
- });
- return j.access_token;
-}
-async function paypalCreateOrder({ order, amountUsd, description }) {
- const token = await paypalToken();
- const j = await httpJson(`${await paypalBase()}/v2/checkout/orders`, {
- method: 'POST',
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
- body: JSON.stringify({
- intent: 'CAPTURE',
- purchase_units: [{
- custom_id: String(order.id),
- description: description.slice(0, 127),
- amount: { currency_code: 'USD', value: amountUsd.toFixed(2) },
- }],
- application_context: {
- brand_name: 'OpenVibe.Live', user_action: 'PAY_NOW',
- return_url: `${baseUrl()}/api/payments/paypal/return?order=${order.id}`,
- cancel_url: `${baseUrl()}/?purchase=cancel`,
- },
- }),
- });
- await db.updatePaymentOrder(order.id, { provider_ref: j.id });
- const approve = (j.links || []).find(l => l.rel === 'approve');
- return { url: approve ? approve.href : null, ref: j.id };
-}
-async function paypalCaptureOrder(paypalOrderId) {
- const token = await paypalToken();
- return await httpJson(`${await paypalBase()}/v2/checkout/orders/${paypalOrderId}/capture`, {
- method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: '{}',
- });
-}
-/** Verify a PayPal webhook via the verify-webhook-signature API. */
-async function paypalVerify(headers, rawBody) {
- const webhookId = await s('paypal_webhook_id');
- if (!webhookId) return false;
- const token = await paypalToken();
- const j = await httpJson(`${await paypalBase()}/v1/notifications/verify-webhook-signature`, {
- method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
- body: JSON.stringify({
- auth_algo: headers['paypal-auth-algo'],
- cert_url: headers['paypal-cert-url'],
- transmission_id: headers['paypal-transmission-id'],
- transmission_sig: headers['paypal-transmission-sig'],
- transmission_time: headers['paypal-transmission-time'],
- webhook_id: webhookId,
- webhook_event: JSON.parse(rawBody),
- }),
- });
- return j.verification_status === 'SUCCESS';
-}
-
-// ══════════════════════════════════════════ CCBILL ══════════
-/** Build a CCBill FlexForms hosted-payment URL for a one-time charge. */
-async function ccbillUrl({ order, amountUsd }) {
- const formId = await s('ccbill_flexform_id');
- const salt = await s('ccbill_salt');
- const price = amountUsd.toFixed(2);
- const currency = '840'; // USD
- // FlexForms digest: md5(price + period + currencyCode + salt) for a single charge
- const period = '2'; // days is unused for single charge but required; FlexForms uses formDigest
- const digest = crypto.createHash('md5').update(`${price}${period}${currency}${salt}`).digest('hex');
- const qs = new URLSearchParams({
- clientAccnum: await s('ccbill_client_account'),
- clientSubacc: await s('ccbill_subaccount'),
- initialPrice: price,
- initialPeriod: period,
- currencyCode: currency,
- formDigest: digest,
- 'X-order': String(order.id), // custom passthrough (X- prefix survives to the webhook)
- });
- return `https://api.ccbill.com/wap-frontflex/flexforms/${formId}?${qs.toString()}`;
-}
-/** CCBill webhook auth: we require a shared secret in the query (?secret=...). */
-async function ccbillVerify(query) {
- const secret = await s('ccbill_webhook_secret');
- if (!secret) return false;
- // Constant-time: compare SHA-256 digests so neither length nor content leaks through timing.
- const a = crypto.createHash('sha256').update(String(query.secret || '')).digest();
- const b = crypto.createHash('sha256').update(String(secret)).digest();
- return crypto.timingSafeEqual(a, b);
-}
-
-// ══════════════════════════════════════════ CRYPTO (NOWPayments) ══
-async function cryptoCreateInvoice({ order, amountUsd, description }) {
- const j = await httpJson('https://api.nowpayments.io/v1/invoice', {
- method: 'POST',
- headers: { 'x-api-key': await s('crypto_api_key'), 'Content-Type': 'application/json' },
- body: JSON.stringify({
- price_amount: amountUsd, price_currency: 'usd',
- order_id: String(order.id), order_description: description,
- ipn_callback_url: `${baseUrl()}/api/payments/webhook/crypto`,
- success_url: `${baseUrl()}/?purchase=success`, cancel_url: `${baseUrl()}/?purchase=cancel`,
- }),
- });
- await db.updatePaymentOrder(order.id, { provider_ref: String(j.id || j.invoice_id || '') });
- return { url: j.invoice_url, ref: String(j.id || j.invoice_id || '') };
-}
-/** Verify a NOWPayments IPN via HMAC-SHA512 of the sorted JSON body. */
-async function cryptoVerify(rawBody, sigHeader) {
- const secret = await s('crypto_ipn_secret');
- if (!secret || !sigHeader) return null;
- let obj; try { obj = JSON.parse(rawBody); } catch { return null; }
- const sorted = JSON.stringify(sortObject(obj));
- const expected = crypto.createHmac('sha512', secret).update(sorted).digest('hex');
- const a = Buffer.from(expected), c = Buffer.from(String(sigHeader));
- if (a.length !== c.length || !crypto.timingSafeEqual(a, c)) return null;
- return obj;
-}
-function sortObject(o) {
- if (Array.isArray(o)) return o.map(sortObject);
- if (o && typeof o === 'object') {
- return Object.keys(o).sort().reduce((acc, k) => { acc[k] = sortObject(o[k]); return acc; }, {});
- }
- return o;
-}
-
-// ── Fulfillment (idempotent) ─────────────────────────────────
-
-/** The caller's copy of an order can predate an await (the PayPal return captures while the
- * webhook credits); crediting that stale 'pending' copy would pay twice. Read it again. */
-async function _freshOrder(order) {
- return order && order.id ? (await db.getPaymentOrderById(order.id) || order) : order;
-}
-
-/** Does a provider-reported amount (USD) cover the order? Unknown amounts return null. */
-function paidAmountCovers(order, paidUsd) {
- const paid = Number(paidUsd);
- if (paidUsd == null || paidUsd === '' || !Number.isFinite(paid)) return null;
- return Math.round(paid * 100) >= Number(order.amount_cents || 0) - 1;
-}
-
-/** Credit a paid bucks order exactly once. Returns true if newly credited. */
-async function fulfillBucksOrder(order) {
- order = await _freshOrder(order);
- if (!order || order.status === 'credited') return false;
- const bucks = order.bucks || await bucksForUsd(order.amount_cents / 100);
- await db.addVibes(order.user_id, bucks);
- try {
- await db.createTransaction({
- from_user_id: null, to_user_id: order.user_id, amount: bucks, // ledger is in bucks
- type: 'purchase', status: 'completed',
- message: `Purchased ${bucks.toLocaleString()} Vibes via ${order.provider}`,
- });
- } catch { /* ledger is best-effort */ }
- await db.updatePaymentOrder(order.id, { status: 'credited', bucks });
- return true;
-}
-
-/** Activate/extend a subscription from a paid order (idempotent-ish).
- * creditShare=false: the streamer already received the money directly (e.g. a
- * PowerChat tip on their own page) — activate the sub without minting their
- * cashout-Vibes share on top. autoRenew: null leaves the sub's flag untouched. */
-async function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null, creditShare = true, autoRenew = null, shareBaseCents = null } = {}) {
- order = await _freshOrder(order);
- if (!order || !order.streamer_id) return null;
- if (order.status === 'credited') {
- // A replayed webhook must not push the period out again or re-pay the streamer.
- try { const existing = await db.getActiveSubscription(order.user_id, order.streamer_id); if (existing) return existing; } catch { /* */ }
- }
- const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
- // Note BEFORE the upsert whether this subscriber already had a sub row — that makes
- // a renewal a resub for the PowerChat alert below.
- let isResub = false;
- try { isResub = !!await db.getActiveSubscription(order.user_id, order.streamer_id); } catch { /* */ }
- const sub = await db.upsertSubscription({
- subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
- provider: order.provider, provider_ref: providerRef || order.provider_ref,
- price_cents: order.amount_cents, currency: order.currency || 'usd',
- status: 'active', current_period_end: end, auto_renew: autoRenew,
- });
- if (order.status !== 'credited') {
- // Pay the streamer their share as Vibes — this is income they received, so
- // it lands in their cashout balance (the only cashout-able balance).
- const sharePct = await n('sub_streamer_share_pct', 70);
- // shareBaseCents: the sub price EXCLUDING any platform routing fee the subscriber
- // paid on top (site-routed PowerChat subs) — the fee is the site's, not shareable.
- const baseCents = shareBaseCents != null ? shareBaseCents : order.amount_cents;
- const streamerBucks = creditShare ? await bucksForUsd((baseCents / 100) * (sharePct / 100)) : 0;
- if (streamerBucks > 0) await db.addVibesCashout(order.streamer_id, streamerBucks);
- await db.updatePaymentOrder(order.id, { status: 'credited' });
- // Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —
- // alerts + sub-goal/subathon credit. Keyed by the order id so a replayed
- // fulfillment can't double-alert. Only on first credit, never on re-runs.
- try {
- const subscriber = await db.getUserById(order.user_id);
- await require('../integrations/powerchat-platform').forwardSubscription(order.streamer_id, {
- subscriberName: subscriber?.display_name || subscriber?.username || 'Someone',
- externalId: `sub-order:${order.id}`,
- tier: '1',
- isResub,
- });
- } catch { /* non-critical */ }
- }
- return sub;
-}
-
-// ── Subscription renewal sweeper ─────────────────────────────
-// Auto-renew for every method that CAN renew:
-// - Stripe subs renew natively (invoice.paid extends the period). The sweeper only
-// expires them after a 3-day grace window with no renewal — Stripe stopped billing.
-// - Everything else (vibes/paypal/ccbill/crypto/powerchat) is a one-time charge the
-// processor can't repeat, so auto_renew=1 renews from the subscriber's VIBES
-// balance — the universal wallet every method can top up. Insufficient balance
-// (or auto_renew off / cancel-at-period-end) → the sub expires with a notification.
-const STRIPE_GRACE_MS = 3 * 24 * 3600 * 1000;
-async function _sweepRenewals() {
- // OpenVibe.Billing renews its own subscriptions; a freeze pauses renewals until it lifts.
- if (money.authority() !== 'live' || await money.isFrozen()) return;
- // Runs from an hourly timer; an uncaught throw there exits the whole server.
- let due = [];
- try { due = await db.getSubscriptionsDueRenewal(50) || []; }
- catch (e) { console.warn('[Payments] renewal sweep skipped:', e.message); return; }
- for (const sub of due) {
- try {
- const endMs = Date.parse(sub.current_period_end) || 0;
- if (sub.provider === 'stripe') {
- if (Date.now() - endMs < STRIPE_GRACE_MS) continue; // let Stripe's invoice.paid land
- await db.setSubscriptionStatus(sub.id, 'expired');
- await _renewNotify(sub, 'Your card subscription was not renewed by Stripe and has ended.');
- continue;
- }
- if (sub.cancel_at_period_end || !sub.auto_renew) {
- await db.setSubscriptionStatus(sub.id, sub.cancel_at_period_end ? 'canceled' : 'expired');
- if (!sub.cancel_at_period_end) await _renewNotify(sub, 'Your channel subscription has ended. Resubscribe any time!');
- continue;
- }
- const priceCents = sub.price_cents || Math.round(await n('sub_price_usd', 4.99) * 100);
- const cost = await bucksForUsd(priceCents / 100);
- if (!await db.deductVibes(sub.subscriber_id, cost)) {
- await db.setSubscriptionStatus(sub.id, 'expired');
- await _renewNotify(sub, `Your subscription could not auto-renew (${cost.toLocaleString()} Vibes needed). Top up and resubscribe!`);
- continue;
- }
- const order = await db.createPaymentOrder({
- user_id: sub.subscriber_id, provider: 'bucks', kind: 'subscription',
- amount_cents: priceCents, streamer_id: sub.streamer_id, status: 'paid',
- });
- await fulfillSubscriptionOrder(order, { autoRenew: 1 });
- console.log(`[Payments] auto-renewed sub ${sub.id} (user ${sub.subscriber_id} → streamer ${sub.streamer_id}) from Vibes balance`);
- } catch (e) { console.warn(`[Payments] renewal sweep sub ${sub.id}:`, e.message); }
- }
-}
-async function _renewNotify(sub, message) {
- try {
- const { pushNotification } = require('../utils/notify');
- const streamer = await db.getUserById(sub.streamer_id);
- pushNotification({
- user_id: sub.subscriber_id, type: 'PAYMENT', title: 'Subscription update',
- message: `${streamer ? (streamer.display_name || streamer.username) + ': ' : ''}${message}`,
- url: streamer ? `/@${encodeURIComponent(streamer.username)}` : '/',
- });
- } catch { /* optional */ }
-}
-let _renewTimer = null;
-async function startRenewalSweeper() {
- if (_renewTimer) return;
- if (money.onBilling()) { console.log('[Payments] renewal sweeper not started: OpenVibe.Billing renews subscriptions (BILLING_AUTHORITY=billing)'); return; }
- await _sweepRenewals(); // catch up immediately on boot
- _renewTimer = setInterval(() => _sweepRenewals().catch((e) => console.warn('[Payments] renewal sweep:', e.message)), 60 * 60 * 1000);
- if (_renewTimer.unref) _renewTimer.unref();
- console.log('[Payments] subscription renewal sweeper started (hourly)');
-}
-
module.exports = {
module.exports = {
- isEnabled, publicConfig, bucksForUsd, startRenewalSweeper,
- // stripe
- stripeCheckout, stripeVerify,
- // paypal
-paidAmountCovers,
- paypalCreateOrder, paypalCaptureOrder, paypalVerify,
- // ccbill
- ccbillUrl, ccbillVerify,
- // crypto
- cryptoCreateInvoice, cryptoVerify,
- // fulfillment
- fulfillBucksOrder, fulfillSubscriptionOrder,
+ isEnabled, publicConfig,
// settings accessors (for routes)
// settings accessors (for routes)
_get: s, _bool: b, _num: n,
_get: s, _bool: b, _num: n,
};
};
@@ -18,8 +18,8 @@ const db = require('../db/database');
@@ -18,8 +18,8 @@ const db = require('../db/database');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const { publicGoal, announceGoal } = require('./goal-announce');
const { publicGoal, announceGoal } = require('./goal-announce');
const channelTips = require('./channel-tips');
const channelTips = require('./channel-tips');
-// BILLING_AUTHORITY=billing: every money action below goes to OpenVibe.Billing instead of Live's
-// columns (billing-actions.js); money_writes_frozen refuses them in both modes.
+// OpenVibe.Billing is the ledger (since 2026-10-17): every money action below asks it (billing-actions.js);
+// money_writes_frozen refuses them. Goals and the channel's tip history stay Live's.
const money = require('./money-authority');
const money = require('./money-authority');
const billingActions = require('./billing-actions');
const billingActions = require('./billing-actions');
const cashoutsInBilling = (res) => res.status(409).json({
const cashoutsInBilling = (res) => res.status(409).json({
@@ -30,22 +30,10 @@ const cashoutsInBilling = (res) => res.status(409).json({
@@ -30,22 +30,10 @@ const cashoutsInBilling = (res) => res.status(409).json({
const router = express.Router();
const router = express.Router();
// ── Buy Vibes ───────────────────────────────────────────
// ── Buy Vibes ───────────────────────────────────────────
-/**
- * DISABLED — this route credited Vibes with no payment verification whatsoever.
- *
- * It took an `amount` and a `paypal_transaction_id` from the request body, never checked the
- * transaction against PayPal, and called purchase() directly. The comment in its place read
- * "In production, validate PayPal transaction here". Any signed-in account could mint up to the
- * balance ceiling for free and then move it somewhere spendable.
- *
- * Real purchases are credited by the verified payment webhooks (fulfillBucksOrder), which check a
- * signature and an order record. Returning 410 rather than deleting the route so an old client
- * calling it gets a clear answer instead of a 404 that looks like a routing bug.
- */
+// Disabled (it once credited Vibes with no payment check). Purchases are checkouts through OpenVibe.Billing
+// (/api/payments/bucks/checkout); 410 rather than 404 so an old client gets a clear answer.
router.post('/purchase', (req, res) => {
router.post('/purchase', (req, res) => {
- res.status(410).json({
- error: 'Direct purchase is disabled. Vibes are credited by the payment provider webhook.',
- });
+ res.status(410).json({ error: 'Direct purchase is disabled. Buy Vibes with a checkout: OpenVibe.Billing credits them when the payment confirms.' });
});
});
// Serialize a goal for the client (no private fields to leak).
// Serialize a goal for the client (no private fields to leak).
@@ -73,16 +61,14 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -73,16 +61,14 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
if (!s || s.user_id !== streamer_id) return res.status(400).json({ error: 'That stream does not belong to this streamer' });
if (!s || s.user_id !== streamer_id) return res.status(400).json({ error: 'That stream does not belong to this streamer' });
}
}
- const result = money.onBilling()
- ? await billingActions.donate(req, { toUserId: streamer_id, streamId: stream_id, amount, message, goalId: goal_id || null })
- : await openvibeBucks.donate(req.user.id, streamer_id, stream_id, amount, message, goal_id || null);
+ const result = await billingActions.donate(req, { toUserId: streamer_id, streamId: stream_id, amount, message, goalId: goal_id || null });
// A request the browser repeated with the same Idempotency-Key: already celebrated.
// A request the browser repeated with the same Idempotency-Key: already celebrated.
if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });
if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });
- // The channel's tip history (leaderboard, recap, home stats), whichever ledger moved the Vibes.
+ // The channel's tip history (leaderboard, recap, home stats).
await channelTips.record({
await channelTips.record({
toUserId: streamer_id, fromUserId: req.user.id, streamId: stream_id || null, amount: result.amount, source: 'vibes',
toUserId: streamer_id, fromUserId: req.user.id, streamId: stream_id || null, amount: result.amount, source: 'vibes',
- ref: result.transactionId ? `${money.onBilling() ? 'billing-txn' : 'live-txn'}:${result.transactionId}` : `vibes:${require('crypto').randomUUID()}`,
+ ref: result.transactionId ? `billing-txn:${result.transactionId}` : `vibes:${require('crypto').randomUUID()}`,
});
});
const alerts = require('./alerts');
const alerts = require('./alerts');
@@ -124,8 +110,7 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -124,8 +110,7 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
key: result.transactionId ? `goal:${result.transactionId}` : undefined,
key: result.transactionId ? `goal:${result.transactionId}` : undefined,
});
});
- const balance = money.onBilling() ? result.balance : (await db.getUserById(req.user.id)).openvibe_bucks_balance;
- res.json({ success: true, amount: result.amount, balance, goal_reached: !!result.goalReached });
+ res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: !!result.goalReached });
} catch (err) {
} catch (err) {
if (billingActions.sendError(res, err, { insufficient: 'Insufficient Vibes', self: 'You cannot donate to yourself' })) return;
if (billingActions.sendError(res, err, { insufficient: 'Insufficient Vibes', self: 'You cannot donate to yourself' })) return;
res.status(400).json({ error: err.message });
res.status(400).json({ error: err.message });
@@ -140,9 +125,7 @@ router.post('/cashout', requireAuth, money.guardWrite, async (req, res) => {
@@ -140,9 +125,7 @@ router.post('/cashout', requireAuth, money.guardWrite, async (req, res) => {
return res.status(400).json({ error: 'Amount and PayPal email required' });
return res.status(400).json({ error: 'Amount and PayPal email required' });
}
}
- const result = money.onBilling()
- ? await billingActions.requestCashout(req, { amount, paypalEmail: paypal_email })
- : await openvibeBucks.requestCashout(req.user.id, amount, paypal_email);
+ const result = await billingActions.requestCashout(req, { amount, paypalEmail: paypal_email });
res.json(result);
res.json(result);
} catch (err) {
} catch (err) {
if (billingActions.sendError(res, err, { insufficient: 'Insufficient cashout balance — only Vibes sent to you can be cashed out' })) return;
if (billingActions.sendError(res, err, { insufficient: 'Insufficient cashout balance — only Vibes sent to you can be cashed out' })) return;
@@ -152,33 +135,18 @@ router.post('/cashout', requireAuth, money.guardWrite, async (req, res) => {
@@ -152,33 +135,18 @@ router.post('/cashout', requireAuth, money.guardWrite, async (req, res) => {
// ── Get Balance ──────────────────────────────────────────────
// ── Get Balance ──────────────────────────────────────────────
router.get('/balance', requireAuth, async (req, res) => {
router.get('/balance', requireAuth, async (req, res) => {
- if (money.onBilling()) {
- // Billing is the only truth: when it cannot answer, the balance is "unavailable" — never
- // the frozen legacy column.
- try { return res.json(await billingActions.balance(req.user.id, req.headers)); }
- catch (err) {
- if (billingActions.sendError(res, err, { unavailable: 'Vibes balance unavailable right now — the billing service is not answering.' })) return;
- return res.status(503).json({ error: 'Vibes balance unavailable right now', unavailable: true });
- }
+ // Billing is the only truth: when it cannot answer, the balance is "unavailable", never a guess.
+ try { return res.json(await billingActions.balance(req.user.id, req.headers)); }
+ catch (err) {
+ if (billingActions.sendError(res, err, { unavailable: 'Vibes balance unavailable right now — the billing service is not answering.' })) return;
+ return res.status(503).json({ error: 'Vibes balance unavailable right now', unavailable: true });
}
}
- const user = await db.getUserById(req.user.id);
- const bal = Math.round(user.openvibe_bucks_balance || 0);
- const cashout = Math.round(user.openvibe_bucks_cashout_balance || 0);
- res.json({
- // Integer Vibes; USD is the streamer cashout value (100 bucks = $1).
- balance: bal,
- usd_value: openvibeBucks.cashoutUsd(bal).toFixed(2),
- cashout_balance: cashout,
- cashout_usd_value: openvibeBucks.cashoutUsd(cashout).toFixed(2),
- });
});
});
// ── Recycle cashout balance → spendable Vibes ───────────
// ── Recycle cashout balance → spendable Vibes ───────────
router.post('/recycle', requireAuth, money.guardWrite, async (req, res) => {
router.post('/recycle', requireAuth, money.guardWrite, async (req, res) => {
try {
try {
- const result = money.onBilling()
- ? await billingActions.recycle(req, req.body.amount)
- : await openvibeBucks.recycleCashout(req.user.id, req.body.amount);
+ const result = await billingActions.recycle(req, req.body.amount);
res.json(result);
res.json(result);
} catch (err) {
} catch (err) {
if (billingActions.sendError(res, err, { insufficient: 'Insufficient cashout balance' })) return;
if (billingActions.sendError(res, err, { insufficient: 'Insufficient cashout balance' })) return;
@@ -189,15 +157,11 @@ router.post('/recycle', requireAuth, money.guardWrite, async (req, res) => {
@@ -189,15 +157,11 @@ router.post('/recycle', requireAuth, money.guardWrite, async (req, res) => {
// ── Transaction History ──────────────────────────────────────
// ── Transaction History ──────────────────────────────────────
router.get('/history', requireAuth, async (req, res) => {
router.get('/history', requireAuth, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
const limit = Math.min(parseInt(req.query.limit || '50'), 200);
- if (money.onBilling()) {
- try { return res.json({ transactions: await billingActions.history(req.user.id, limit) }); }
- catch (err) {
- if (billingActions.sendError(res, err, { unavailable: 'Vibes history unavailable right now — the billing service is not answering.' })) return;
- return res.status(503).json({ error: 'Vibes history unavailable right now', unavailable: true });
- }
+ try { return res.json({ transactions: await billingActions.history(req.user.id, limit) }); }
+ catch (err) {
+ if (billingActions.sendError(res, err, { unavailable: 'Vibes history unavailable right now — the billing service is not answering.' })) return;
+ return res.status(503).json({ error: 'Vibes history unavailable right now', unavailable: true });
}
}
- const history = await openvibeBucks.getHistory(req.user.id, limit);
- res.json({ transactions: history });
});
});
// ── Stream Donation Leaderboard ──────────────────────────────
// ── Stream Donation Leaderboard ──────────────────────────────
@@ -265,39 +229,10 @@ router.get('/goals/:userId', async (req, res) => {
@@ -265,39 +229,10 @@ router.get('/goals/:userId', async (req, res) => {
res.json({ goals });
res.json({ goals });
});
});
-// ── Admin: Approve Cashout ───────────────────────────────────
-router.post('/cashout/:id/approve', requireOwner, money.guardWrite, async (req, res) => {
- if (money.onBilling()) return cashoutsInBilling(res);
- try {
- await openvibeBucks.approveCashout(req.params.id);
- res.json({ message: 'Cashout approved' });
- } catch (err) {
- res.status(400).json({ error: err.message });
- }
-});
-
-// ── Admin: Deny Cashout ──────────────────────────────────────
-router.post('/cashout/:id/deny', requireOwner, money.guardWrite, async (req, res) => {
- if (money.onBilling()) return cashoutsInBilling(res);
- try {
- await openvibeBucks.denyCashout(req.params.id, req.body.reason);
- res.json({ message: 'Cashout denied, funds refunded' });
- } catch (err) {
- res.status(400).json({ error: err.message });
- }
-});
-
-// ── Admin: Get Pending Cashouts ──────────────────────────────
-router.get('/cashouts/pending', requireOwner, async (req, res) => {
- if (money.onBilling()) return cashoutsInBilling(res);
- const pending = await db.all(`
- SELECT t.*, u.username, u.display_name
- FROM transactions t
- JOIN users u ON t.from_user_id = u.id
- WHERE t.type = 'cashout' AND t.status = 'escrow'
- ORDER BY t.created_at ASC
- `);
- res.json({ cashouts: pending });
-});
+// ── Cashout decisions: OpenVibe.Billing's staff console ──────
+// Old admin clients get a clear answer instead of a 404.
+router.post('/cashout/:id/approve', requireOwner, (req, res) => cashoutsInBilling(res));
+router.post('/cashout/:id/deny', requireOwner, (req, res) => cashoutsInBilling(res));
+router.get('/cashouts/pending', requireOwner, (req, res) => cashoutsInBilling(res));
module.exports = router;
module.exports = router;
@@ -1,18 +1,13 @@
@@ -1,18 +1,13 @@
/**
/**
* OpenVibe.Live — Vibes Engine
* OpenVibe.Live — Vibes Engine
*
*
- * Virtual currency: integer "bit"-style Vibes. 100 Bucks = $1.00 streamer cashout.
- * Viewers buy at a premium with volume discounts (priceUsdForBucks) — the platform margin.
- * Features:
- * - Buy Vibes (PayPal)
- * - Donate to streamers
- * - Donation goals with progress bars
- * - Escrow cashout with admin approval
- * - Subscription tiers
+ * Vibes are integer "bit"-style units: 100 = $1.00 of streamer cashout value, bought at a premium with volume
+ * discounts (priceUsdForBucks). OpenVibe.Billing keeps every balance and moves every Vibe since 2026-10-17
+ * (billing-actions.js); what stays here is the arithmetic, the channel's donation goals and its tip leaderboard
+ * (channel_tips).
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
-const { assertLiveLedger } = require('./money-authority');
// Vibes are integer "bit"-style units: 100 bucks = $1.00 streamer cashout.
// Vibes are integer "bit"-style units: 100 bucks = $1.00 streamer cashout.
const CASHOUT_BUCKS_PER_USD = 100;
const CASHOUT_BUCKS_PER_USD = 100;
@@ -58,77 +53,7 @@ function normalizeText(value, maxLen = 300) {
@@ -58,77 +53,7 @@ function normalizeText(value, maxLen = 300) {
return text;
return text;
}
}
-function validatePaypalEmail(value) {
- const email = String(value || '').trim();
- if (!email) throw new Error('PayPal email required');
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
- throw new Error('Invalid PayPal email');
- }
- return email;
-}
-
class Vibes {
class Vibes {
- /**
- * Purchase Vibes
- * @param {number} userId
- * @param {number} amount - Number of Vibes to purchase
- * @param {string} paypalTxId - PayPal transaction ID
- */
- async purchase(userId, amount, paypalTxId) {
- amount = normalizeBucks(amount);
- assertLiveLedger('transactions insert');
- const txId = normalizeText(paypalTxId, 128);
- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)
- VALUES (NULL, ?, ?, 'purchase', 'completed', ?) RETURNING id`, [userId, amount, `Purchased ${amount} Vibes`]);
-
- // Update PayPal reference
- if (txId) {
- await db.run('UPDATE transactions SET paypal_transaction_id = ? WHERE id = ?',
- [txId, tx.lastInsertRowid]);
- }
-
- await db.addVibes(userId, amount);
- return tx;
- }
-
- /**
- * Donate Vibes to a streamer
- * @param {number} fromUserId - Donor
- * @param {number} toUserId - Streamer
- * @param {number} streamId - Current stream
- * @param {number} amount - Vibes to donate
- * @param {string} message - Donation message
- */
- async donate(fromUserId, toUserId, streamId, amount, message, goalId = null) {
- amount = normalizeBucks(amount);
- assertLiveLedger('transactions insert');
- message = normalizeText(message, 300);
-
- // Deduct from donor
- if (!await db.deductVibes(fromUserId, amount)) {
- throw new Error('Insufficient Vibes');
- }
-
- // Credit the streamer's CASHOUT balance (received bucks are the only cashout-able
- // ones; their spendable balance is for bucks they bought).
- await db.addVibesCashout(toUserId, amount);
-
- // Record transaction
- const txn = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status, message)
- VALUES (?, ?, ?, ?, 'donation', 'completed', ?) RETURNING id`, [fromUserId, toUserId, streamId || null, amount, message || null]);
-
- // Apply toward a donation goal (the donor's pick, else the sole active goal).
- const goalResult = await this.applyDonationToGoal(toUserId, amount, goalId);
-
- return {
- success: true,
- amount,
- transactionId: txn && txn.lastInsertRowid ? Number(txn.lastInsertRowid) : null,
- goal: goalResult ? goalResult.goal : null, // the goal that advanced (for the widget)
- goalReached: goalResult && goalResult.reached ? goalResult.goal : null,
- };
- }
-
/**
/**
* Route a donation toward a single goal: the donor's chosen goal if valid+active,
* Route a donation toward a single goal: the donor's chosen goal if valid+active,
* otherwise the streamer's sole active goal (if exactly one). Returns
* otherwise the streamer's sole active goal (if exactly one). Returns
@@ -149,105 +74,6 @@ class Vibes {
@@ -149,105 +74,6 @@ class Vibes {
return await db.addToDonationGoal(target.id, amount);
return await db.addToDonationGoal(target.id, amount);
}
}
- /**
- * Request cashout (goes to escrow for admin review)
- */
- async requestCashout(userId, amount, paypalEmail) {
- amount = normalizeBucks(amount);
- assertLiveLedger('transactions insert');
- paypalEmail = validatePaypalEmail(paypalEmail);
- const minBucks = config.openvibeBucks.minCashoutBucks;
- if (amount < minBucks) {
- throw new Error(`Minimum cashout is ${minBucks.toLocaleString()} Vibes ($${cashoutUsd(minBucks).toFixed(2)})`);
- }
-
- // Only the cashout balance (received donations) can be cashed out.
- if (!await db.deductVibesCashout(userId, amount)) {
- throw new Error('Insufficient cashout balance — only Vibes sent to you can be cashed out');
- }
-
- const tx = await db.run(`INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, message)
- VALUES (?, NULL, ?, 'cashout', 'escrow', ?) RETURNING id`, [userId, amount, `Cashout to PayPal: ${paypalEmail}`]);
-
- return {
- transaction_id: tx.lastInsertRowid,
- amount,
- usd_value: cashoutUsd(amount).toFixed(2),
- status: 'escrow',
- hold_days: config.openvibeBucks.escrowDays,
- };
- }
-
- /**
- * Admin: Approve a cashout (release from escrow)
- */
- async approveCashout(transactionId) {
- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',
- [transactionId, 'escrow']);
- if (!tx) throw new Error('Transaction not found or not in escrow');
-
- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['completed', transactionId]);
- return tx;
- }
-
- /**
- * Admin: Deny a cashout (refund to user)
- */
- async denyCashout(transactionId, reason) {
- const tx = await db.get('SELECT * FROM transactions WHERE id = ? AND status = ?',
- [transactionId, 'escrow']);
- if (!tx) throw new Error('Transaction not found or not in escrow');
-
- // Refund back to the cashout balance it came from.
- await db.addVibesCashout(tx.from_user_id, tx.amount);
- await db.run('UPDATE transactions SET status = ? WHERE id = ?', ['refunded', transactionId]);
-
- return tx;
- }
-
- /**
- * Recycle: move Vibes from the streamer's cashout balance into their spendable
- * balance, so they can re-donate / give back to the community instead of cashing out.
- */
- async recycleCashout(userId, amount) {
- amount = normalizeBucks(amount);
- if (!await db.deductVibesCashout(userId, amount)) {
- throw new Error('Insufficient cashout balance');
- }
- await db.addVibes(userId, amount);
- await db.createTransaction({
- from_user_id: userId,
- to_user_id: userId,
- amount,
- type: 'recycle',
- status: 'completed',
- message: 'Moved cashout balance to spendable Vibes',
- });
- const user = await db.getUserById(userId);
- return {
- success: true,
- amount,
- balance: user.openvibe_bucks_balance,
- cashout_balance: user.openvibe_bucks_cashout_balance,
- };
- }
-
- /**
- * Get user's transaction history
- */
- async getHistory(userId, limit = 50) {
- return await db.all(`
- SELECT t.*,
- fu.username AS from_username, fu.display_name AS from_display,
- tu.username AS to_username, tu.display_name AS to_display
- FROM transactions t
- LEFT JOIN users fu ON t.from_user_id = fu.id
- LEFT JOIN users tu ON t.to_user_id = tu.id
- WHERE t.from_user_id = ? OR t.to_user_id = ?
- ORDER BY t.created_at DESC LIMIT ?
- `, [userId, userId, limit]);
- }
-
/**
/**
* A stream's tip leaderboard from channel_tips (on-site Vibes, PowerChat and OpenVibe.Tips alike). A supporter
* A stream's tip leaderboard from channel_tips (on-site Vibes, PowerChat and OpenVibe.Tips alike). A supporter
* without a Live account has from_user_id and username null and their name as display_name.
* without a Live account has from_user_id and username null and their name as display_name.
@@ -170,26 +170,14 @@ async function check(name, fn) {
@@ -170,26 +170,14 @@ async function check(name, fn) {
assert.strictEqual(r.status, 404, r.text);
assert.strictEqual(r.status, 404, r.text);
});
});
- await check('payments: a CCBill sale cannot be pointed at a larger order, and replays credit once', async () => {
+ 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');
await db.setSetting('ccbill_webhook_secret', 'sek');
- const pay = require('../server/monetization/payments');
- const big = await db.createPaymentOrder({ user_id: 5, provider: 'ccbill', kind: 'bucks', amount_cents: 10000, bucks: 10000 });
- const other = await db.createPaymentOrder({ user_id: 5, provider: 'paypal', kind: 'bucks', amount_cents: 100, bucks: 100 });
- const bal = async () => (await db.getUserById(5)).openvibe_bucks_balance;
- const start = await bal();
- const hook = (q) => call('POST', `/api/payments/webhook/ccbill?secret=sek&${new URLSearchParams(q)}`, null, {});
- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess', billedInitialPrice: '1.00' });
- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess' });
- await hook({ 'X-order': other.id, eventType: 'NewSaleSuccess', billedInitialPrice: '1.00' });
- assert.strictEqual(await bal(), start, 'underpaid, unpriced or wrong-provider sale credited');
- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess', billedInitialPrice: '100.00' });
- await hook({ 'X-order': big.id, eventType: 'NewSaleSuccess', billedInitialPrice: '100.00' });
- assert.strictEqual(await bal(), start + 10000);
- // A stale copy read before an await (PayPal return vs. webhook) must not credit again.
- const stale = await db.getPaymentOrderById(other.id);
- assert.strictEqual(await pay.fulfillBucksOrder(await db.getPaymentOrderById(other.id)), true);
- assert.strictEqual(await pay.fulfillBucksOrder(stale), false);
- assert.strictEqual(await bal(), start + 10100);
+ 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', () => {
await check('API tokens: scopes gate writes; money, staff and credential routes refuse tokens', () => {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * BILLING_AUTHORITY=billing (roadmap Wave 8, ADR-012): every Live money action goes to
+ * 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
* 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
* 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
* columns and tables (users.openvibe_bucks_*, transactions, payment_orders, subscriptions) are
@@ -15,7 +15,6 @@ const http = require('http');
@@ -15,7 +15,6 @@ const http = require('http');
const { startNetwork, startBilling } = require('./billing-stub');
const { startNetwork, startBilling } = require('./billing-stub');
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-process.env.BILLING_AUTHORITY = 'billing';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_BILLING_TIMEOUT_MS = '1500';
process.env.OV_BILLING_TIMEOUT_MS = '1500';
@@ -104,7 +103,7 @@ async function check(name, fn) {
@@ -104,7 +103,7 @@ async function check(name, fn) {
billing.fund(SID.ann, 3000);
billing.fund(SID.ann, 3000);
- await check('the switch is read in one place and says billing', async () => {
+ await check('Billing is the authority, and the client config says so', async () => {
const money = require('../server/monetization/money-authority');
const money = require('../server/monetization/money-authority');
assert.strictEqual(money.authority(), 'billing');
assert.strictEqual(money.authority(), 'billing');
const cfg = await call('GET', '/api/payments/config');
const cfg = await call('GET', '/api/payments/config');
@@ -369,8 +368,6 @@ async function check(name, fn) {
@@ -369,8 +368,6 @@ async function check(name, fn) {
const r = await call('POST', `/api/payments/webhook/${p}`, null, {});
const r = await call('POST', `/api/payments/webhook/${p}`, null, {});
assert.strictEqual(r.status, 410, p); assert.strictEqual(r.json.webhook_url, `https://billing.openvibe.network/webhooks/${name}`);
assert.strictEqual(r.status, 410, p); assert.strictEqual(r.json.webhook_url, `https://billing.openvibe.network/webhooks/${name}`);
}
}
- const rec = await require('../server/integrations/powerchat-reconcile').reconcileOnce();
- assert.strictEqual(rec.skipped, 'billing_authority');
});
});
await check('a missing Network grant fails clearly (misconfigured), nothing moves', async () => {
await check('a missing Network grant fails clearly (misconfigured), nothing moves', async () => {
@@ -416,12 +413,10 @@ async function check(name, fn) {
@@ -416,12 +413,10 @@ async function check(name, fn) {
assert.strictEqual(st.json.authority, "billing"); assert.strictEqual(st.json.billing.reachable, false, JSON.stringify(st.json));
assert.strictEqual(st.json.authority, "billing"); assert.strictEqual(st.json.billing.reachable, false, JSON.stringify(st.json));
});
});
- await check('the tripwire: Live\'s money columns cannot be written under billing', async () => {
- await assert.rejects(db.addVibes(2, 5), /read-only/);
- await assert.rejects(db.addVibesCashout(3, 5), /read-only/);
- await assert.rejects(db.createPaymentOrder({ user_id: 2, provider: 'powerchat' }), /read-only/);
- await assert.rejects(db.upsertSubscription({ subscriber_id: 2, streamer_id: 3 }), /read-only/);
- await assert.rejects(db.createTransaction({ to_user_id: 2, amount: 1, type: 'purchase' }), /read-only/);
+ await check('Live has no way left to write its old money columns and tables', async () => {
+ for (const fn of ['addVibes', 'deductVibes', 'addVibesCashout', 'deductVibesCashout', 'createPaymentOrder', 'updatePaymentOrder', 'upsertSubscription', 'setSubscriptionStatus', 'createTransaction']) {
+ assert.strictEqual(db[fn], undefined, `database.js still has ${fn}`);
+ }
});
});
await check('legacy balances are not shown as live (admin stats, admin user list)', async () => {
await check('legacy balances are not shown as live (admin stats, admin user list)', async () => {
@@ -11,7 +11,6 @@ const assert = require('assert');
@@ -11,7 +11,6 @@ const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.BILLING_AUTHORITY;
process.env.LIVE_EVENTS_SECRET = crypto.randomBytes(32).toString('hex');
process.env.LIVE_EVENTS_SECRET = crypto.randomBytes(32).toString('hex');
const quiet = console.log;
const quiet = console.log;
console.log = () => {};
console.log = () => {};
@@ -62,14 +61,9 @@ const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7H
@@ -62,14 +61,9 @@ const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7H
subscription: { id: `sub_${ulid()}`, status: 'expired', auto_renew: 1, cancel_at_period_end: 0, provider: null }, reason, ...extra,
subscription: { id: `sub_${ulid()}`, status: 'expired', auto_renew: 1, cancel_at_period_end: 0, provider: null }, reason, ...extra,
});
});
- // Live is the ledger: its own checkout and sweeper notify, so Billing's events change nothing.
const purchase = settled('purchase', { metadata: { bits: 500, paid_cents: 500 } });
const purchase = settled('purchase', { metadata: { bits: 500, paid_cents: 500 } });
- assert.strictEqual(await post(purchase), 204);
- assert.strictEqual(await events.apply(purchase), 'ignored:live-ledger');
- assert.deepStrictEqual(sent, []);
-
- process.env.BILLING_AUTHORITY = 'billing';
assert.strictEqual(await post(purchase, 'x'.repeat(64)), 401, 'a delivery signed with another secret is refused');
assert.strictEqual(await post(purchase, 'x'.repeat(64)), 401, 'a delivery signed with another secret is refused');
+ assert.deepStrictEqual(sent, []);
assert.strictEqual(await post(purchase), 204);
assert.strictEqual(await post(purchase), 204);
assert.deepStrictEqual(sent.map((n) => [n.user_id, n.title, n.message]), [[2, 'Vibes credited 🎉', '500 Vibes were added to your balance ($5.00).']]);
assert.deepStrictEqual(sent.map((n) => [n.user_id, n.title, n.message]), [[2, 'Vibes credited 🎉', '500 Vibes were added to your balance ($5.00).']]);
assert.strictEqual(await post(purchase), 204);
assert.strictEqual(await post(purchase), 204);
deleted file mode 100644
deleted file mode 100644
@@ -1,133 +0,0 @@
@@ -1,133 +0,0 @@
-'use strict';
-/**
- * BILLING_AUTHORITY unset (= live, the default): the Billing client changes nothing. The money
- * routes move Live's own columns exactly as before, OpenVibe.Billing is never called (a stand-in
- * is running and counts calls), the Live webhooks still receive, and the billing_actions journal
- * stays empty. The rest of the suite (authorization, powerchat-*, migrations …) covers the
- * existing money behaviour unchanged.
- */
-const assert = require('assert');
-const http = require('http');
-const { startNetwork, startBilling } = require('./billing-stub');
-
-process.env.NODE_ENV = 'test';
-delete process.env.BILLING_AUTHORITY;
-process.env.OV_OAUTH_CLIENT_ID = 'live';
-process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
-const quiet = console.log;
-console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
-console.warn = () => {};
-
-let failures = 0;
-async function check(name, fn) {
- try { await fn(); quiet(' ✓', name); }
- catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
-}
-
-(async () => {
- const network = await startNetwork();
- const billing = await startBilling({ network });
- process.env.OV_NETWORK_INTERNAL_URL = network.url;
- process.env.OV_BILLING_INTERNAL_URL = billing.url;
-
- const db = require('../server/db/database');
- 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);
- await addUser(1, 'owner', 'admin', { is_owner: 1 });
- await addUser(2, 'ann', 'user', { bucks: 1000 });
- await addUser(3, 'bob', 'streamer');
- // Subjects exist, so nothing but the switch keeps these actions on Live's columns.
- await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1'), (3, 'network', '103', 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2')").run();
- const cols = async (id) => { const u = await db.getUserById(id); return { b: u.openvibe_bucks_balance, c: u.openvibe_bucks_cashout_balance }; };
-
- 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; };
- auth.requireAuth = (req, res, next) => { signIn(req).then((u) => u ? next() : res.status(401).json({ error: 'Authentication required' })).catch(next); };
- auth.optionalAuth = (req, res, next) => { signIn(req).then(() => next()).catch(next); };
- const express = require('express');
- const app = express();
- app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
- app.use((req, res, next) => { signIn(req).then(() => next()).catch(next); });
- app.use('/api/funds', require('../server/monetization/routes'));
- app.use('/api/payments', require('../server/monetization/payments-routes'));
- app.use('/api/admin', require('../server/admin/routes'));
- app.use('/api/powerchat', require('../server/integrations/powerchat-routes'));
- const server = http.createServer(app);
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
- const call = async (method, p, user, body) => {
- const res = await fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'content-type': 'application/json', ...(user ? { 'x-test-user': String(user) } : {}) }, body: body ? JSON.stringify(body) : undefined });
- const text = await res.text();
- let json = null; try { json = JSON.parse(text); } catch { /* */ }
- return { status: res.status, json, text };
- };
-
- await check('the switch defaults to live', async () => {
- assert.strictEqual(require('../server/monetization/money-authority').authority(), 'live');
- assert.strictEqual((await call('GET', '/api/payments/config')).json.authority, 'live');
- });
-
- await check('donate moves Live\'s columns and writes the transactions row, as before', async () => {
- const r = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 150, message: 'gg' });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.json.balance, 850);
- assert.deepStrictEqual(await cols(2), { b: 850, c: 0 });
- assert.deepStrictEqual(await cols(3), { b: 0, c: 150 });
- const t = await raw.prepare("SELECT * FROM transactions WHERE type = 'donation'").get();
- assert.deepStrictEqual([t.from_user_id, t.to_user_id, t.amount, t.status], [2, 3, 150, 'completed']);
- const bad = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 99999 });
- assert.strictEqual(bad.status, 400); assert.strictEqual(bad.json.error, 'Insufficient Vibes');
- });
-
- await check('cashout request, owner approval and the balance read use Live\'s columns', async () => {
- await raw.prepare('UPDATE users SET openvibe_bucks_cashout_balance = 1000 WHERE id = 3').run();
- const r = await call('POST', '/api/funds/cashout', 3, { amount: 600, paypal_email: '[email protected]' });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.json.status, 'escrow'); assert.strictEqual(r.json.usd_value, '6.00');
- assert.deepStrictEqual(await cols(3), { b: 0, c: 400 });
- const pending = await call('GET', '/api/funds/cashouts/pending', 1);
- assert.strictEqual(pending.json.cashouts.length, 1);
- assert.strictEqual((await call('POST', `/api/funds/cashout/${r.json.transaction_id}/approve`, 1)).status, 200);
- const bal = await call('GET', '/api/funds/balance', 3);
- assert.deepStrictEqual({ b: bal.json.balance, c: bal.json.cashout_balance }, { b: 0, c: 400 });
- });
-
- await check('recycle behaves exactly as before (including the pre-existing transactions CHECK defect)', async () => {
- const r = await call('POST', '/api/funds/recycle', 3, { amount: 100 });
- // Economic inventory defect 7: 'recycle' is not in the transactions type CHECK, so the ledger
- // insert throws after both balances moved. Unchanged under `live`; Billing fixes it.
- assert.strictEqual(r.status, 400, r.text);
- assert.deepStrictEqual(await cols(3), { b: 100, c: 300 });
- });
-
- await check('subscribe with Vibes, the channel state and cancel use Live\'s subscriptions table', async () => {
- const r = await call('POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'bucks' });
- assert.strictEqual(r.status, 200, r.text);
- assert.deepStrictEqual(await cols(2), { b: 351, c: 0 });
- assert.deepStrictEqual(await cols(3), { b: 100, c: 649 });
- const ch = await call('GET', '/api/payments/channel/bob', 2);
- assert.deepStrictEqual([ch.json.subscribed, ch.json.subscriberCount], [true, 1]);
- assert.strictEqual(await db.isActiveSubscriber(2, 3), true);
- const sub = await raw.prepare('SELECT * FROM subscriptions').get();
- assert.strictEqual((await call('POST', `/api/payments/subscriptions/${sub.id}/cancel`, 2, {})).status, 200);
- assert.strictEqual((await raw.prepare('SELECT status FROM subscriptions WHERE id = ?').get(sub.id)).status, 'canceled');
- });
-
- await check('the PowerChat receiver still runs on Live (verifies, never 410)', async () => {
- const r = await call('POST', '/api/powerchat/webhook', null, { type: 'donation.completed', data: {} });
- assert.strictEqual(r.status, 401, r.text);
- });
-
- await check('OpenVibe.Billing was never called and no Billing journal exists', async () => {
- assert.strictEqual(billing.calls.length, 0);
- assert.strictEqual((await raw.prepare('SELECT COUNT(*) AS n FROM billing_actions').get()).n, 0, 'the migrated journal stays empty in live mode');
- });
-
- server.close();
- await billing.close();
- await network.close();
- quiet(failures ? `\n${failures} failed` : '\nall passed');
- process.exit(failures ? 1 : 0);
-})().catch((e) => { quiet(e); process.exit(1); });
@@ -12,7 +12,6 @@ const http = require('http');
@@ -12,7 +12,6 @@ const http = require('http');
const path = require('path');
const path = require('path');
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.BILLING_AUTHORITY;
const quiet = console.log;
const quiet = console.log;
console.log = () => {};
console.log = () => {};
console.warn = () => {};
console.warn = () => {};
@@ -33,6 +32,8 @@ console.warn = () => {};
@@ -33,6 +32,8 @@ console.warn = () => {};
delivery.message = async () => 1;
delivery.message = async () => 1;
delivery.event = async () => {};
delivery.event = async () => {};
+ // OpenVibe.Billing moves the Vibes (a stand-in here): the route records the tip under Billing's transaction.
+ require('../server/monetization/billing-actions').donate = async (req, { amount }) => ({ amount, transactionId: 'txn_01DONATION', balance: 1850, goal: null, goalReached: null });
const auth = require('../server/auth/auth');
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; };
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; };
auth.requireAuth = (req, res, next) => { signIn(req).then((u) => (u ? next() : res.status(401).json({ error: 'Authentication required' }))).catch(next); };
auth.requireAuth = (req, res, next) => { signIn(req).then((u) => (u ? next() : res.status(401).json({ error: 'Authentication required' }))).catch(next); };
@@ -49,11 +50,11 @@ console.warn = () => {};
@@ -49,11 +50,11 @@ console.warn = () => {};
const tip = (ref) => raw.prepare('SELECT to_user_id, from_user_id, username, stream_id, amount, source FROM channel_tips WHERE ref = ?').get(ref);
const tip = (ref) => raw.prepare('SELECT to_user_id, from_user_id, username, stream_id, amount, source FROM channel_tips WHERE ref = ?').get(ref);
const count = async () => Number((await raw.prepare('SELECT COUNT(*) AS n FROM channel_tips').get()).n);
const count = async () => Number((await raw.prepare('SELECT COUNT(*) AS n FROM channel_tips').get()).n);
- // An on-site Vibes donation (Live is the ledger here): one ledger row, one tip named after it.
+ // An on-site Vibes donation (Billing moved it): one tip named after Billing's transaction.
const d = await donate(2, { streamer_id: 3, stream_id: streamId, amount: 150, message: 'gg' });
const d = await donate(2, { streamer_id: 3, stream_id: streamId, amount: 150, message: 'gg' });
assert.strictEqual(d.status, 200, JSON.stringify(d.json));
assert.strictEqual(d.status, 200, JSON.stringify(d.json));
- const txn = await raw.prepare("SELECT id FROM transactions WHERE type = 'donation' ORDER BY id DESC LIMIT 1").get();
- assert.deepStrictEqual(await tip(`live-txn:${txn.id}`), { to_user_id: 3, from_user_id: 2, username: null, stream_id: streamId, amount: 150, source: 'vibes' });
+ assert.strictEqual(d.json.balance, 1850, "the donor's balance is Billing's");
+ assert.deepStrictEqual(await tip('billing-txn:txn_01DONATION'), { to_user_id: 3, from_user_id: 2, username: null, stream_id: streamId, amount: 150, source: 'vibes' });
// A tip on bob's own PowerChat: no Live account, so the name is kept; the same event twice is one tip.
// A tip on bob's own PowerChat: no Live account, so the name is kept; the same event twice is one tip.
const powerchat = require('../server/integrations/powerchat-webhook');
const powerchat = require('../server/integrations/powerchat-webhook');
@@ -95,7 +96,7 @@ console.warn = () => {};
@@ -95,7 +96,7 @@ console.warn = () => {};
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.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:${failed}`), undefined);
assert.strictEqual(await tip(`live-txn:${bought}`), undefined);
assert.strictEqual(await tip(`live-txn:${bought}`), undefined);
- assert.strictEqual(await count(), 5, 'the donation the route already recorded is not copied again');
+ assert.strictEqual(await count(), 5, 'one copy of each completed donation');
server.close();
server.close();
quiet('channel tips: all checks passed');
quiet('channel tips: all checks passed');
@@ -406,21 +406,25 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -406,21 +406,25 @@ const waitFor = async (pred, what, ms = 3000) => {
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: streamer, sources: { ffz: true, bttv: false } } })).status, 200);
assert.strictEqual((await call('POST', '/internal/chat-effects/channel-emote-sources', { token: WRITE, body: { user_id: streamer, sources: { ffz: true, bttv: false } } })).status, 200);
assert.strictEqual((await db.getChannelByUserId(streamer)).emote_sources, JSON.stringify({ ffz: true, bttv: false }));
assert.strictEqual((await db.getChannelByUserId(streamer)).emote_sources, JSON.stringify({ ffz: true, bttv: false }));
- // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel:
- // by user id or Network subject; a lapsed period or a cancelled row is not one.
+ // 8a. Sub-only chat asks whether someone holds an ACTIVE subscription to the streamer's channel, by user id or
+ // Network subject. OpenVibe.Billing answers (billing-actions.js; a stand-in here): a lapsed or cancelled one is not.
+ const ba = require('../server/monetization/billing-actions');
+ const held = new Set();
+ const refreshed = [];
+ ba.refreshEntitlement = async (u, st) => { refreshed.push(`${u}:${st}`); };
+ ba.isSubscriberCached = (u, st) => held.has(`${u}:${st}`);
const subOf = (q) => call('GET', `/internal/chat-context/subscriber?${q}`, { token: READ });
const subOf = (q) => call('GET', `/internal/chat-context/subscriber?${q}`, { token: READ });
assert.strictEqual((await call('GET', `/internal/chat-context/subscriber?user_id=${viewer}&streamer_id=${streamer}`, { token: WRITE })).status, 403, 'a read capability');
assert.strictEqual((await call('GET', `/internal/chat-context/subscriber?user_id=${viewer}&streamer_id=${streamer}`, { token: WRITE })).status, 403, 'a read capability');
assert.strictEqual((await subOf(`streamer_id=${streamer}`)).status, 400);
assert.strictEqual((await subOf(`streamer_id=${streamer}`)).status, 400);
assert.deepStrictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body, { subscriber: false, user_id: viewer, streamer_id: streamer });
assert.deepStrictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body, { subscriber: false, user_id: viewer, streamer_id: streamer });
- await db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() + 86400e3).toISOString() });
+ held.add(`${viewer}:${streamer}`);
assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, true);
assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, true);
+ assert.ok(refreshed.includes(`${viewer}:${streamer}`), 'read fresh from Billing');
assert.strictEqual((await subOf(`subject=usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, true, 'by Network subject');
assert.strictEqual((await subOf(`subject=usr_01J9ZZZZZZZZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, true, 'by Network subject');
assert.strictEqual((await subOf(`subject=usr_01J9UNKNOWNZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, false, 'an unknown subject');
assert.strictEqual((await subOf(`subject=usr_01J9UNKNOWNZZZZZZZZZZZZZZZ&streamer_id=${streamer}`)).body.subscriber, false, 'an unknown subject');
assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${mod}`)).body.subscriber, false, 'another channel');
assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${mod}`)).body.subscriber, false, 'another channel');
- await db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'active', current_period_end: new Date(Date.now() - 60e3).toISOString() });
- assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'the paid period is over');
- await db.upsertSubscription({ subscriber_id: viewer, streamer_id: streamer, status: 'canceled', current_period_end: new Date(Date.now() + 86400e3).toISOString() });
- assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'not active');
+ held.delete(`${viewer}:${streamer}`);
+ assert.strictEqual((await subOf(`user_id=${viewer}&streamer_id=${streamer}`)).body.subscriber, false, 'Billing says it is over');
// Alert sounds moved to Chat with channel_moderation_settings (roadmap T3): Chat resolves the
// Alert sounds moved to Chat with channel_moderation_settings (roadmap T3): Chat resolves the
// sound from its own row and plays it on an `alert` event (test/chat-ingress.test.js), so Live's effect is gone.
// sound from its own row and plays it on an `alert` event (test/chat-ingress.test.js), so Live's effect is gone.
@@ -1,19 +1,14 @@
@@ -1,19 +1,14 @@
'use strict';
'use strict';
/**
/**
- * money_writes_frozen — step 3 of the Live → Billing cutover runbook. In BOTH modes it refuses
- * every money action a person or an operator starts on Live (checkout, donation, cashout request,
- * approve/deny, recycle, subscribe, cancel, Vibes media request and refund, renewal sweep) while
+ * money_writes_frozen: refuses every money action a person or an operator starts on Live (checkout, donation, cashout
+ * request, recycle, subscribe, cancel, Vibes media request and refund) before OpenVibe.Billing is ever called, while
* reads keep working. Only the owner can set it; every admin can see it at /api/admin/money.
* reads keep working. Only the owner can set it; every admin can see it at /api/admin/money.
- * Provider confirmations of checkouts started before the freeze still settle in `live` mode (the
- * provider already took that money). A BILLING_AUTHORITY that is neither live nor billing refuses
- * every money write too.
*/
*/
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
const { startNetwork, startBilling } = require('./billing-stub');
const { startNetwork, startBilling } = require('./billing-stub');
process.env.NODE_ENV = 'test';
process.env.NODE_ENV = 'test';
-delete process.env.BILLING_AUTHORITY;
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_ID = 'live';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
process.env.OV_OAUTH_CLIENT_SECRET = 'live-secret';
const quiet = console.log;
const quiet = console.log;
@@ -49,13 +44,6 @@ async function check(name, fn) {
@@ -49,13 +44,6 @@ async function check(name, fn) {
await db.setSetting('powerchat_enabled', 'true');
await db.setSetting('powerchat_enabled', 'true');
await db.setSetting('powerchat_client_id', 'pca_test');
await db.setSetting('powerchat_client_id', 'pca_test');
await db.setSetting('powerchat_site_tip_username', 'sitepc');
await db.setSetting('powerchat_site_tip_username', 'sitepc');
- const snapshot = async () => ({
- users: await raw.prepare('SELECT id, openvibe_bucks_balance AS b, openvibe_bucks_cashout_balance AS c FROM users ORDER BY id').all(),
- tx: (await raw.prepare('SELECT COUNT(*) AS n FROM transactions').get()).n,
- orders: (await raw.prepare('SELECT COUNT(*) AS n FROM payment_orders').get()).n,
- subs: await raw.prepare('SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS u, COALESCE(string_agg(status::text, \',\'), \'\') AS s FROM subscriptions').get(),
- });
-
const auth = require('../server/auth/auth');
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; };
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; };
auth.requireAuth = (req, res, next) => { signIn(req).then((u) => u ? next() : res.status(401).json({ error: 'Authentication required' })).catch(next); };
auth.requireAuth = (req, res, next) => { signIn(req).then((u) => u ? next() : res.status(401).json({ error: 'Authentication required' })).catch(next); };
@@ -83,8 +71,6 @@ async function check(name, fn) {
@@ -83,8 +71,6 @@ async function check(name, fn) {
['POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 }],
['POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 }],
['POST', '/api/funds/cashout', 3, { amount: 600, paypal_email: '[email protected]' }],
['POST', '/api/funds/cashout', 3, { amount: 600, paypal_email: '[email protected]' }],
['POST', '/api/funds/recycle', 3, { amount: 10 }],
['POST', '/api/funds/recycle', 3, { amount: 10 }],
- ['POST', '/api/funds/cashout/1/approve', 1, {}],
- ['POST', '/api/funds/cashout/1/deny', 1, {}],
['POST', '/api/payments/bucks/checkout', 2, { provider: 'powerchat', bucks: 500 }],
['POST', '/api/payments/bucks/checkout', 2, { provider: 'powerchat', bucks: 500 }],
['POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'bucks' }],
['POST', '/api/payments/subscribe', 2, { streamer: 'bob', provider: 'bucks' }],
['POST', '/api/payments/subscriptions/1/cancel', 2, {}],
['POST', '/api/payments/subscriptions/1/cancel', 2, {}],
@@ -103,79 +89,43 @@ async function check(name, fn) {
@@ -103,79 +89,43 @@ async function check(name, fn) {
assert.strictEqual((await call('POST', '/api/admin/money/freeze', 2, { on: true })).status, 403);
assert.strictEqual((await call('POST', '/api/admin/money/freeze', 2, { on: true })).status, 403);
assert.strictEqual((await call('PUT', '/api/admin/settings/money_writes_frozen', 4, { value: 'true' })).status, 403, 'not through the generic settings either');
assert.strictEqual((await call('PUT', '/api/admin/settings/money_writes_frozen', 4, { value: 'true' })).status, 403, 'not through the generic settings either');
assert.strictEqual(await money.isFrozen(), false);
assert.strictEqual(await money.isFrozen(), false);
- const r = await call('POST', '/api/admin/money/freeze', 1, { on: true, reason: 'Billing cutover step 3' });
+ const r = await call('POST', '/api/admin/money/freeze', 1, { on: true, reason: 'maintenance' });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.frozen, true);
assert.strictEqual(r.json.frozen, true);
const seen = await call('GET', '/api/admin/money', 4);
const seen = await call('GET', '/api/admin/money', 4);
- assert.deepStrictEqual([seen.json.authority, seen.json.frozen, seen.json.reason, seen.json.by], ['live', true, 'Billing cutover step 3', 'owner']);
+ assert.deepStrictEqual([seen.json.authority, seen.json.frozen, seen.json.reason, seen.json.by], ['billing', true, 'maintenance', 'owner']);
assert.strictEqual((await call('GET', '/api/admin/money', 2)).status, 403, 'not for non-admins');
assert.strictEqual((await call('GET', '/api/admin/money', 2)).status, 403, 'not for non-admins');
assert.strictEqual((await call('GET', '/api/payments/config')).json.moneyFrozen, true);
assert.strictEqual((await call('GET', '/api/payments/config')).json.moneyFrozen, true);
});
});
- await check('live mode, frozen: every money write is refused and nothing moves', async () => {
- const before = await snapshot();
- // A due auto-renewing subscription: the sweep must not renew or expire it while frozen.
- await raw.prepare("INSERT INTO subscriptions (subscriber_id, streamer_id, tier, provider, price_cents, status, is_active, current_period_end, auto_renew) VALUES (2, 3, 1, 'bucks', 499, 'active', 1, datetime('now', '-1 hour'), 1)").run();
- const withSub = await snapshot();
- await expectAllRefused('live');
- await require('../server/monetization/payments').startRenewalSweeper(); // sweeps once immediately
- assert.deepStrictEqual(await snapshot(), withSub, 'no column, ledger, order or subscription change');
- assert.notDeepStrictEqual(before, withSub);
+ await check('frozen: every money write is refused before Billing is ever called', async () => {
+ billing.fund(SID_ANN, 1000);
+ const n = billing.calls.length;
+ await expectAllRefused('frozen');
+ assert.strictEqual(billing.calls.length, n, 'zero Billing calls while frozen');
});
});
- await check('live mode, frozen: a Vibes media refund is deferred (not lost, not marked refunded)', async () => {
+ await check('frozen: a Vibes media refund is deferred (not lost, not marked refunded)', async () => {
const id = Number((await db.run("INSERT INTO media_requests (streamer_id, user_id, username, input, canonical_url, provider, title, duration_seconds, cost, queue_position, currency) VALUES (3, 2, 'ann', 'x', 'https://x.test/1', 'youtube', 't', 60, 10, 1, 'vibes') RETURNING id")).lastInsertRowid);
const id = Number((await db.run("INSERT INTO media_requests (streamer_id, user_id, username, input, canonical_url, provider, title, duration_seconds, cost, queue_position, currency) VALUES (3, 2, 'ann', 'x', 'https://x.test/1', 'youtube', 't', 60, 10, 1, 'vibes') RETURNING id")).lastInsertRowid);
assert.strictEqual(await mediaQueue.refund(id), 0);
assert.strictEqual(await mediaQueue.refund(id), 0);
assert.strictEqual((await db.getMediaRequestById(id)).refunded, 0);
assert.strictEqual((await db.getMediaRequestById(id)).refunded, 0);
});
});
- await check('live mode, frozen: reads work', async () => {
+ await check('frozen: reads are served (from Billing)', async () => {
const b = await call('GET', '/api/funds/balance', 2);
const b = await call('GET', '/api/funds/balance', 2);
- assert.strictEqual(b.status, 200); assert.strictEqual(b.json.balance, 2000);
+ assert.strictEqual(b.status, 200, b.text);
assert.strictEqual((await call('GET', '/api/funds/history', 2)).status, 200);
assert.strictEqual((await call('GET', '/api/funds/history', 2)).status, 200);
assert.strictEqual((await call('GET', '/api/payments/channel/bob', 2)).status, 200);
assert.strictEqual((await call('GET', '/api/payments/channel/bob', 2)).status, 200);
});
});
- await check('live mode, frozen: a checkout started before the freeze still settles when the provider confirms it', async () => {
- await money.setFrozen(false);
- const order = await db.createPaymentOrder({ user_id: 2, provider: 'powerchat', kind: 'bucks', amount_cents: 500, bucks: 500 });
- await money.setFrozen(true, { reason: 'x', by: 'test' });
- const consumed = await require('../server/integrations/powerchat-checkout').handleAttributedDonation(null, { appExternalRef: `pcorder:${order.id}`, amountUsdCents: 500 });
- assert.strictEqual(consumed, true);
- assert.strictEqual((await db.getPaymentOrderById(order.id)).status, 'credited');
- assert.strictEqual((await db.getUserById(2)).openvibe_bucks_balance, 2500);
- });
-
- await check('unfreeze: money moves again', async () => {
+ await check('unfreeze: money moves again, through Billing', async () => {
const r = await call('POST', '/api/admin/money/freeze', 1, { on: false });
const r = await call('POST', '/api/admin/money/freeze', 1, { on: false });
assert.strictEqual(r.json.frozen, false);
assert.strictEqual(r.json.frozen, false);
const d = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });
const d = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });
assert.strictEqual(d.status, 200, d.text);
assert.strictEqual(d.status, 200, d.text);
- });
-
- await check('billing mode, frozen: refused before Billing is ever called; unfrozen: Billing is called', async () => {
- process.env.BILLING_AUTHORITY = 'billing';
- billing.fund(SID_ANN, 1000);
- await money.setFrozen(true, { reason: 'cutover', by: 'test' });
- const n = billing.calls.length;
- await expectAllRefused('billing');
- assert.strictEqual(billing.calls.length, n, 'zero Billing calls while frozen');
- assert.strictEqual((await call('GET', '/api/funds/balance', 2)).status, 200, 'reads still served (from Billing)');
- await money.setFrozen(false);
- const d = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });
- assert.strictEqual(d.status, 200, d.text);
assert.strictEqual(billing.calls[billing.calls.length - 1].path, '/transfers');
assert.strictEqual(billing.calls[billing.calls.length - 1].path, '/transfers');
});
});
- await check('a BILLING_AUTHORITY typo refuses money writes instead of guessing', async () => {
- process.env.BILLING_AUTHORITY = 'bilIing';
- assert.strictEqual(money.authority(), 'invalid');
- const r = await call('POST', '/api/funds/donate', 2, { streamer_id: 3, amount: 10 });
- assert.strictEqual(r.status, 503); assert.strictEqual(r.json.code, 'billing_misconfigured');
- await assert.rejects(db.addVibes(2, 1), /not 'live' or 'billing'/);
- delete process.env.BILLING_AUTHORITY;
- });
-
server.close();
server.close();
await billing.close();
await billing.close();
await network.close();
await network.close();
@@ -31,6 +31,10 @@ const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|
@@ -31,6 +31,10 @@ const CHAT_PATHS = /^\/(api\/(chat|dm|tts|emotes|channels)(\/|$)|api\/sounds(\/|
// now answer 404, and every one of them was already wrapped to fail quietly (the controls panel, the dashboard's
// now answer 404, and every one of them was already wrapped to fail quietly (the controls panel, the dashboard's
// controls and ONVIF cards, the broadcast profile pickers), so they are not this release's to answer either.
// controls and ONVIF cards, the broadcast profile pickers), so they are not this release's to answer either.
const RETIRED_PATHS = /^\/api\/(controls|onvif)(\/|$)/;
const RETIRED_PATHS = /^\/api\/(controls|onvif)(\/|$)/;
+// Vibes balances and history are OpenVibe.Billing's (the money ledger since 2026-10-17). The harness runs in drill mode,
+// where every other service looks down, so this release answers them 503 "unavailable"; the recorded N-1 answered from
+// Live's old columns. In production both releases read them from Billing.
+const BILLING_READS = /^\/api\/funds\/(balance|history)$/;
function freePort() {
function freePort() {
return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
return new Promise((resolve) => { const s = net.createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); }); });
@@ -80,7 +84,7 @@ module.exports = {
@@ -80,7 +84,7 @@ module.exports = {
{ name: 'navigator.sendBeacon', method: 'POST' },
{ name: 'navigator.sendBeacon', method: 'POST' },
],
],
strip: ['API', 'location.origin', 'window.location.origin'],
strip: ['API', 'location.origin', 'window.location.origin'],
- keep: (pathname) => !CHAT_PATHS.test(pathname) && !RETIRED_PATHS.test(pathname),
+ keep: (pathname) => !CHAT_PATHS.test(pathname) && !RETIRED_PATHS.test(pathname) && !BILLING_READS.test(pathname),
/** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */
/** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */
crawl: ['/', '/@n1star'],
crawl: ['/', '/@n1star'],
origins: ['https://openvibe.live'],
origins: ['https://openvibe.live'],
@@ -110,16 +110,16 @@ const mint = (opts) => checkout.mintCheckoutLink('alex', 'pcsub:42', { purpose:
@@ -110,16 +110,16 @@ const mint = (opts) => checkout.mintCheckoutLink('alex', 'pcsub:42', { purpose:
r = await mint({ amountCents: 10 });
r = await mint({ amountCents: 10 });
assert.ok(!('amount_cents' in api.calls.at(-1).query));
assert.ok(!('amount_cents' in api.calls.at(-1).query));
- // ── builders: subscription persists route + renew flag and returns expiry ──
+ // ── builders for OpenVibe.Billing's intents: the checkout_ref rides as the ref, the label names what is bought ──
api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK5', expiresAt: '2026-08-27T12:00:00.000Z' } } });
api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK5', expiresAt: '2026-08-27T12:00:00.000Z' } } });
- const sub = await checkout.buildSubscribeLink(orders.get(42), 1, { autoRenew: 1, route: 'direct' });
+ const sub = await checkout.buildBillingSubscribeLink({ checkout_ref: 'pcsub:pi_SUB', amount_cents: 499, route: 'direct', auto_renew: 1 }, 1);
assert.strictEqual(sub.mode, 'direct'); assert.strictEqual(sub.minted, true); assert.strictEqual(sub.expiresAt, '2026-08-27T12:00:00.000Z');
assert.strictEqual(sub.mode, 'direct'); assert.strictEqual(sub.minted, true); assert.strictEqual(sub.expiresAt, '2026-08-27T12:00:00.000Z');
- assert.strictEqual(orders.get(42).provider_ref, 'direct:renew');
- assert.strictEqual(api.calls.at(-1).query.ref, 'pcsub:42');
+ assert.strictEqual(api.calls.at(-1).query.ref, 'pcsub:pi_SUB');
assert.strictEqual(api.calls.at(-1).query.item_name, 'goosely — 1 month subscription (auto-renew)');
assert.strictEqual(api.calls.at(-1).query.item_name, 'goosely — 1 month subscription (auto-renew)');
api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK6' } } });
api.script.push({ body: { data: { url: 'https://tunnel.example/alex/tip?app_intent=TOK6' } } });
- const buy = await checkout.buildPurchaseLink(orders.get(41));
+ const buy = await checkout.buildBillingPurchaseLink({ checkout_ref: 'pcorder:pi_BUY', bits: 500, amount_cents: 500 });
assert.strictEqual(buy.mode, 'site'); assert.strictEqual(buy.minted, true);
assert.strictEqual(buy.mode, 'site'); assert.strictEqual(buy.minted, true);
+ assert.strictEqual(api.calls.at(-1).query.ref, 'pcorder:pi_BUY');
assert.strictEqual(api.calls.at(-1).query.item_name, '500 Vibes');
assert.strictEqual(api.calls.at(-1).query.item_name, '500 Vibes');
assert.strictEqual(api.calls.at(-1).query.purpose, 'vibes');
assert.strictEqual(api.calls.at(-1).query.purpose, 'vibes');
deleted file mode 100644
deleted file mode 100644
@@ -1,121 +0,0 @@
@@ -1,121 +0,0 @@
-// /paid-messages reconciliation: finds paid-but-uncredited checkouts by appExternalRef,
-// credits through the same idempotent fulfillment the webhook uses, pages on
-// nextCursor (both envelope shapes), skips echoes / test rows / hosts without the
-// scope, and makes no API call when nothing is pending.
-const assert = require('assert');
-const path = require('path');
-function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }
-
-const settings = { powerchat_site_tip_username: 'alex', powerchat_allow_test_fulfillment: 'false' };
-const orders = new Map();
-const seed = () => {
- orders.clear();
- orders.set(41, { id: 41, user_id: 7, provider: 'powerchat', kind: 'bucks', amount_cents: 500, bucks: 500, status: 'pending', created_at: '2026-08-27 09:00:00' });
- orders.set(42, { id: 42, user_id: 8, provider: 'powerchat', kind: 'subscription', amount_cents: 500, streamer_id: 1, provider_ref: 'direct:renew', status: 'pending', created_at: '2026-08-27 09:05:00' });
- orders.set(43, { id: 43, user_id: 9, provider: 'powerchat', kind: 'subscription', amount_cents: 500, streamer_id: 3, provider_ref: 'direct', status: 'pending', created_at: '2026-08-27 09:06:00' });
-};
-const conns = {
- 1: { user_id: 1, powerchat_username: 'alex', access_token: 'a', refresh_token: 'r', scope: 'checkout:attribute paid_messages:read' },
- 3: { user_id: 3, powerchat_username: 'other', access_token: 'a', refresh_token: 'r', scope: 'checkout:attribute' }, // no paid_messages:read
-};
-const notes = [];
-stub(path.join(__dirname, '../server/db/database'), {
- getSetting: (k) => settings[k] ?? null,
- getPendingPowerchatOrders: () => [...orders.values()].filter((o) => o.status === 'pending'),
- getPowerchatConnection: (id) => conns[id] || null,
- getPowerchatConnectionByUsername: (u) => Object.values(conns).find((c) => c.powerchat_username === String(u).toLowerCase()) || null,
- getPaymentOrderById: (id) => orders.get(id) || null,
- updatePaymentOrder: (id, f) => Object.assign(orders.get(id), f),
- getUserById: (id) => ({ id, username: 'u' + id, display_name: 'U' + id }),
-});
-stub(path.join(__dirname, '../server/config'), { baseUrl: 'https://openvibe.live' });
-stub(path.join(__dirname, '../server/monetization/payments'), {
- bucksForUsd: (usd) => Math.round(usd * 100),
- fulfillBucksOrder: (o) => { orders.get(o.id).status = 'credited'; },
- fulfillSubscriptionOrder: (o, opts) => { orders.get(o.id).status = 'credited'; orders.get(o.id)._opts = opts; },
-});
-stub(path.join(__dirname, '../server/utils/notify'), { pushNotification: (n) => notes.push(n) });
-const api = { calls: [], pages: [] };
-stub(path.join(__dirname, '../server/integrations/powerchat-oauth'), {
- getConfig: () => ({ enabled: true, clientId: 'pca', baseUrl: 'https://pc.example', apiBase: 'https://pc.example/api/dev/v1' }),
- redirectUri: () => 'https://openvibe.live/cb',
- apiRequest: async (userId, req) => { api.calls.push({ userId, ...req }); const p = api.pages.shift(); if (!p) throw new Error('unscripted'); if (p.throw) throw Object.assign(new Error('x'), { status: p.throw }); return p; },
-});
-const reconcile = require('../server/integrations/powerchat-reconcile');
-
-(async () => {
- // ── nothing pending → nothing read ──
- let sum = await reconcile.reconcileOnce();
- assert.strictEqual(sum.orders, 0); assert.strictEqual(api.calls.length, 0);
-
- // ── envelope parsing (corrected + legacy shapes) ──
- assert.deepStrictEqual(reconcile._test.page({ data: { rows: [{ a: 1 }], nextCursor: 'c1' } }), { rows: [{ a: 1 }], nextCursor: 'c1' });
- assert.deepStrictEqual(reconcile._test.page({ data: { rows: [] }, nextCursor: 'c2' }), { rows: [], nextCursor: 'c2' }, 'older deployments left nextCursor outside data');
- assert.deepStrictEqual(reconcile._test.page({ data: [{ b: 2 }] }), { rows: [{ b: 2 }], nextCursor: null });
- assert.deepStrictEqual(reconcile._test.page(null), { rows: [], nextCursor: null });
-
- // ── the main sweep: 3 pending orders, two hosts ──
- seed();
- api.calls.length = 0;
- // Host alex: page 1 (legacy cursor placement) → page 2 (corrected).
- api.pages.push({ data: { rows: [
- { eventId: 'e-other', occurredAt: '2026-08-27T09:30:00Z', source: 'powerchat', amountCents: 300, amountUsdCents: 300, appExternalRef: null },
- { eventId: 'e-echo', occurredAt: '2026-08-27T09:20:00Z', source: 'developer_app', amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcorder:41' },
- { eventId: 'e-test', occurredAt: '2026-08-27T09:19:00Z', source: 'powerchat', isTest: true, amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcorder:41' },
- // Real feed rows have NO isTest field; manual_test is PowerChat's no-provider test tip → must be treated as test.
- { eventId: 'e-manual', occurredAt: '2026-08-27T09:18:00Z', source: 'manual_test', amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcorder:41' },
- { eventId: 'e-41', occurredAt: '2026-08-27T09:10:00Z', source: 'powerchat', donorName: 'Pat', amountCents: 500, currency: 'USD', amountUsdCents: 500, appExternalRef: 'pcorder:41' },
- ] }, nextCursor: 'cur-2' });
- api.pages.push({ data: { rows: [
- { eventId: 'e-42', occurredAt: '2026-08-27T09:08:00Z', source: 'powerchat', donorName: 'Sam', amountCents: 500, amountUsdCents: 500, appExternalRef: 'pcsub:42' },
- ], nextCursor: 'cur-3' } });
- sum = await reconcile.reconcileOnce();
- assert.strictEqual(sum.orders, 3);
- assert.strictEqual(sum.hosts, 1, 'alex hosts both 41 (site) and 42 (direct, streamer 1 = alex)');
- assert.strictEqual(sum.skippedHosts, 1, "'other' has no paid_messages:read → skipped, no call");
- assert.strictEqual(sum.pages, 2, 'stopped after page 2: no orders left for that host despite a cursor');
- assert.strictEqual(sum.skippedTest, 2, 'explicit isTest AND source=manual_test are both skipped');
- assert.deepStrictEqual(sum.credited.map((c) => c.ref).sort(), ['pcorder:41', 'pcsub:42']);
- assert.deepStrictEqual(sum.underpaid, []);
- assert.strictEqual(orders.get(41).status, 'credited');
- assert.strictEqual(orders.get(42).status, 'credited');
- assert.strictEqual(orders.get(42)._opts.autoRenew, 1, 'renew flag read off provider_ref as the webhook path does');
- assert.strictEqual(orders.get(42)._opts.creditShare, false, 'direct: streamer already holds the cash');
- assert.strictEqual(orders.get(43).status, 'pending');
- const reads = api.calls.filter((c) => c.path === '/paid-messages');
- assert.strictEqual(reads.length, 2);
- assert.strictEqual(reads[0].userId, 1); assert.strictEqual(reads[0].username, 'alex');
- assert.deepStrictEqual(reads[0].query, { limit: 100 });
- assert.deepStrictEqual(reads[1].query, { limit: 100, cursor: 'cur-2' }, 'legacy top-level nextCursor was honoured');
- assert.strictEqual(notes.length, 2, 'buyers are notified exactly like a webhook credit');
-
- // ── idempotent: run again → credited orders are no longer pending → only 43 remains, host skipped ──
- api.calls.length = 0;
- sum = await reconcile.reconcileOnce();
- assert.strictEqual(sum.orders, 1); assert.strictEqual(api.calls.length, 0); assert.strictEqual(sum.credited.length, 0);
-
- // ── underpaid sub (direct route): not credited, reported, order stays pending ──
- // (a Vibes pack credits whatever was actually paid, so only subs have this guard)
- seed(); orders.delete(41); orders.delete(43);
- api.calls.length = 0; api.pages.length = 0;
- api.pages.push({ data: { rows: [{ eventId: 'e-low', occurredAt: '2026-08-27T09:10:00Z', source: 'powerchat', amountCents: 100, amountUsdCents: 100, appExternalRef: 'pcsub:42' }], nextCursor: null } });
- sum = await reconcile.reconcileOnce();
- assert.strictEqual(sum.credited.length, 0);
- assert.deepStrictEqual(sum.underpaid.map((u) => u.ref), ['pcsub:42']);
- assert.strictEqual(orders.get(42).status, 'pending');
-
- // ── a paging stop: rows older than the oldest pending order (minus grace) end the walk ──
- seed(); orders.delete(42); orders.delete(43);
- api.calls.length = 0; api.pages.length = 0;
- api.pages.push({ data: { rows: [{ eventId: 'e-ancient', occurredAt: '2026-08-20T00:00:00Z', source: 'powerchat', amountCents: 1, amountUsdCents: 1, appExternalRef: null }], nextCursor: 'more' } });
- sum = await reconcile.reconcileOnce();
- assert.strictEqual(sum.pages, 1, 'did not follow the cursor into rows that predate every pending order');
-
- // ── read failure is contained: summary.errors, nothing credited, no throw ──
- seed(); orders.delete(42); orders.delete(43);
- api.pages.length = 0; api.pages.push({ throw: 429 });
- sum = await reconcile.reconcileOnce();
- assert.strictEqual(sum.errors, 1); assert.strictEqual(orders.get(41).status, 'pending');
-
- console.log('✅ powerchat reconcile tests passed');
-})().catch((e) => { console.error('❌', e); process.exit(1); });
deleted file mode 100644
deleted file mode 100644
@@ -1,65 +0,0 @@
@@ -1,65 +0,0 @@
-'use strict';
-// A PowerChat checkout reference (pcorder/pcsub/pcdon) rides in the tip link, so the buyer can edit it.
-// It must only count when the money landed where that checkout sends it: the SITE tips account for
-// purchases, site-routed donations and site-routed subscriptions; the subscribed streamer's own
-// account for a direct subscription. A streamer tipping their own PowerChat with `pcdon:<self>`, or a
-// viewer tipping their own account with `pcsub:<x>`, must fall through to ordinary tip handling.
-const assert = require('assert');
-const path = require('path');
-function stub(modPath, exportsObj) { const full = require.resolve(modPath); require.cache[full] = { id: full, filename: full, loaded: true, exports: exportsObj }; }
-
-const orders = new Map();
-const credited = { cashout: [], bucks: [], subs: [] };
-const seed = () => {
- orders.clear();
- orders.set(41, { id: 41, kind: 'bucks', amount_cents: 500, bucks: 500, user_id: 7, status: 'pending' });
- orders.set(42, { id: 42, kind: 'subscription', amount_cents: 500, user_id: 8, streamer_id: 3, provider_ref: 'direct', status: 'pending' });
- orders.set(43, { id: 43, kind: 'subscription', amount_cents: 500, user_id: 9, streamer_id: 3, provider_ref: 'site', status: 'pending' });
- credited.cashout.length = 0; credited.bucks.length = 0; credited.subs.length = 0;
-};
-stub(path.join(__dirname, '../server/db/database'), {
- getSetting: (k) => (k === 'powerchat_site_tip_username' ? 'siteacct' : null),
- getPaymentOrderById: (id) => orders.get(id) || null,
- updatePaymentOrder: (id, f) => Object.assign(orders.get(id), f),
- getUserById: (id) => ({ id, username: 'u' + id }),
- addVibesCashout: (uid, cents) => credited.cashout.push([uid, cents]),
- createTransaction: () => {},
- getPowerchatConnectionByUsername: () => null,
- getPowerchatConnection: () => null,
-});
-stub(path.join(__dirname, '../server/monetization/payments'), {
- bucksForUsd: (usd) => Math.round(usd * 100),
- fulfillBucksOrder: (o) => { credited.bucks.push(o.id); orders.get(o.id).status = 'credited'; },
- fulfillSubscriptionOrder: (o) => { credited.subs.push(o.id); orders.get(o.id).status = 'credited'; },
-});
-stub(path.join(__dirname, '../server/integrations/powerchat-webhook'), { creditDonationPipeline: () => {} });
-stub(path.join(__dirname, '../server/utils/notify'), { pushNotification: () => {} });
-const checkout = require('../server/integrations/powerchat-checkout');
-const log = console.warn; console.warn = () => {};
-const tip = (ref, cents = 500) => ({ appExternalRef: ref, amountUsdCents: cents });
-
- (async () => {
-seed();
-// Exploit: streamer 3 tips their OWN PowerChat with a site-routed donation ref naming themselves.
-assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcdon:3:0'), { viaSiteAccount: false }), false, 'pcdon on a streamer account is an ordinary tip');
-assert.deepStrictEqual(credited.cashout, [], 'no cashout Vibes minted');
-// Exploit: a purchase ref on a streamer account credits no Vibes.
-assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcorder:41'), { viaSiteAccount: false }), false);
-assert.deepStrictEqual(credited.bucks, []); assert.strictEqual(orders.get(41).status, 'pending');
-// Exploit: viewer 9 tips their own account (user 9) with a direct sub ref for streamer 3.
-assert.strictEqual(await checkout.handleAttributedDonation(9, tip('pcsub:42'), { viaSiteAccount: false }), false, 'direct sub must be paid to the subscribed streamer');
-// A site-routed sub ref paid to a streamer account is not a subscription either.
-assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcsub:43'), { viaSiteAccount: false }), false);
-assert.deepStrictEqual(credited.subs, []);
-
-// Legitimate paths still work.
-assert.strictEqual(await checkout.handleAttributedDonation(null, tip('pcorder:41')), true, 'purchase on the site account');
-assert.deepStrictEqual(credited.bucks, [41]);
-assert.strictEqual(await checkout.handleAttributedDonation(3, tip('pcsub:42'), { viaSiteAccount: false }), true, 'direct sub paid to streamer 3');
-assert.strictEqual(await checkout.handleAttributedDonation(1, tip('pcsub:43'), { viaSiteAccount: true }), true, 'site sub on the site account (even when the site account is a connected user)');
-assert.deepStrictEqual(credited.subs, [42, 43]);
-assert.strictEqual(await checkout.handleAttributedDonation(null, tip('pcdon:3:7')), true, 'site-routed donation on the site account');
-assert.deepStrictEqual(credited.cashout, [[3, 500]]);
-console.warn = log;
-console.log('✅ PowerChat checkout refs count only on the account the checkout routes to');
-})().catch((e) => { console.error(e); process.exit(1); });