openvibers/openvibe.live Commits

Billing is the only money mode: remove Live's old ledger code (#182)

OP OpenVibers committed 27 days ago · 35d02c6b67 · 35 files, +209 −2435

* Billing is the only money mode: Live's old ledger code goes (the BILLING_AUTHORITY switch, Live-side checkouts, webhooks, fulfilment, renewal sweeper, PowerChat reconciler and ref crediting, cashout approvals, community funds, the money write helpers); provider and PowerChat webhooks answer 410 with Billing's address

* N-1 harness: Vibes balance and history are Billing's reads (drill mode answers them unavailable; production reads Billing in both releases)

Changes

ddiff --git a/.env.example b/.env.example
iindex 12f4cd8e..dce9c356 100644
---- a/.env.example
++++ b/.env.example
@@@ -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:4600
OV_BILLING_PUBLIC_URL=https://billing.openvibe.network
ddiff --git a/AGENTS.md b/AGENTS.md
iindex 4c81518f..d81fd8df 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -4,7 +4,7 @@
Self-hosted live streaming platform — Node.js/Express monolith, vanilla JS SPA frontend, PostgreSQL (`openvibe-sdk/db`; embedded PGlite in development and tests; on PostgreSQL since 2026-10-08). Part of the **OpenVibe network** with SSO via openvibe.network. See [README.md](README.md) for features and [docs/architecture.md](docs/architecture.md) for system design.
--**Network context:** SSO/OAuth2 + the OpenCoins wallet come from **OpenVibe.Network** (`server/monetization/wallet-client.js`). The media subsystem (VODs/clips/pastes/thumbnails/files) lives in **OpenVibe.Media** — Live talks to it via [server/media-client.js](server/media-client.js), the thin proxy routers in [server/media-proxy/](server/media-proxy/), and the Media-backed recorder [server/streaming/recorder.js](server/streaming/recorder.js). Media completion events arrive over OpenVibe.Events at `POST /internal/media-events` (the only path; the direct webhook was retired 2026-10-10). The legacy local `vods`/`clips`/`pastes`/`paste_likes`/`paste_comments` tables are FROZEN: never read or write them (`test/frozen-tables.test.js` enforces it; ask Media or Community through `server/media-proxy/lookups.js`, `media-client.js`, `pastes-client.js`); VOD/clip comments are OpenVibe.Community threads ([server/comments-client.js](server/comments-client.js)) and the 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
ddiff --git a/README.md b/README.md
iindex 594c24a7..67680c7d 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 0cc9a7d5..59b44045 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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 own
PowerChat through Billing) reaches Live only as `tips.interaction.ready` ([server/tips/tips-events.js](../server/tips/tips-events.js)):
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 7c54d76a..f67e462d 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.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 Vibes
res.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));
ddiff --git a/server/auth/account-data.js b/server/auth/account-data.js
iindex 6ed3b134..710eef7a 100644
---- a/server/auth/account-data.js
++++ b/server/auth/account-data.js
@@@ -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.
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex f64b58a1..7271b18e 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -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;
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex b5637670..563650da 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -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');
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 2c57cbba..c3061103 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
// Follows
getFollowerCount, isFollowing, getFollowerIds,
-- // Transactions (Vibes)
-- createTransaction, addVibes, deductVibes, addVibesCashout, deductVibesCashout,
// OpenCoins
createCoinTransaction, getCoinTransactions,
// Coin Rewards
ddiff --git a/server/index.js b/server/index.js
iindex dbd05823..af5fdfc2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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); }
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex a80ea0ab..5588392e 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -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,
};
ddiff --git a/server/integrations/powerchat-reconcile.js b/server/integrations/powerchat-reconcile.js
ddeleted file mode 100644
iindex 7da55b60..00000000
---- a/server/integrations/powerchat-reconcile.js
++++ /dev/null
@@@ -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 } };
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex 58f90649..4031907c 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -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;
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex faaf8935..cfb9b7d2 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -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,
};
ddiff --git a/server/media/media-queue.js b/server/media/media-queue.js
iindex 86632d80..2dcc3939 100644
---- a/server/media/media-queue.js
++++ b/server/media/media-queue.js
@@@ -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 wallet
const 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')
ddiff --git a/server/media/routes.js b/server/media/routes.js
iindex 6365c19d..58a434bc 100644
---- a/server/media/routes.js
++++ b/server/media/routes.js
@@@ -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({
ddiff --git a/server/monetization/billing-actions.js b/server/monetization/billing-actions.js
iindex 5da1ec8a..0e24a630 100644
---- a/server/monetization/billing-actions.js
++++ b/server/monetization/billing-actions.js
@@@ -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
ddiff --git a/server/monetization/billing-events.js b/server/monetization/billing-events.js
iindex 8a46f1eb..32013522 100644
---- a/server/monetization/billing-events.js
++++ b/server/monetization/billing-events.js
@@@ -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';
ddiff --git a/server/monetization/community-funds.js b/server/monetization/community-funds.js
ddeleted file mode 100644
iindex 043b7fb1..00000000
---- a/server/monetization/community-funds.js
++++ /dev/null
@@@ -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();
ddiff --git a/server/monetization/money-authority.js b/server/monetization/money-authority.js
iindex 00136946..77a4bb77 100644
---- a/server/monetization/money-authority.js
++++ b/server/monetization/money-authority.js
@@@ -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.js
async 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 };
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex 9632c6d5..19f533b1 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -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 subscriptions
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
@@@ -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;
ddiff --git a/server/monetization/payments.js b/server/monetization/payments.js
iindex 8f5439c6..3597cf9d 100644
---- a/server/monetization/payments.js
++++ b/server/monetization/payments.js
@@@ -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,
};
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex 229bdbef..2d987d50 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -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;
ddiff --git a/server/monetization/vibes.js b/server/monetization/vibes.js
iindex 92398f07..e91ba406 100644
---- a/server/monetization/vibes.js
++++ b/server/monetization/vibes.js
@@@ -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.
ddiff --git a/test/authorization.test.js b/test/authorization.test.js
iindex 2da4f315..c282ccbe 100644
---- a/test/authorization.test.js
++++ b/test/authorization.test.js
@@@ -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', () => {
ddiff --git a/test/billing-authority.test.js b/test/billing-authority.test.js
iindex e86030a9..1dabf586 100644
---- a/test/billing-authority.test.js
++++ b/test/billing-authority.test.js
@@@ -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 () => {
ddiff --git a/test/billing-events.test.js b/test/billing-events.test.js
iindex 03203442..6d4575ed 100644
---- a/test/billing-events.test.js
++++ b/test/billing-events.test.js
@@@ -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_01JAB2C3D4E5F6G7H
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 } });
-- 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);
ddiff --git a/test/billing-switch-off.test.js b/test/billing-switch-off.test.js
ddeleted file mode 100644
iindex b9622625..00000000
---- a/test/billing-switch-off.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/test/channel-tips.test.js b/test/channel-tips.test.js
iindex 4f58e6c8..aed93d53 100644
---- a/test/channel-tips.test.js
++++ b/test/channel-tips.test.js
@@@ -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');
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 67d82e88..8715c1cb 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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.
ddiff --git a/test/money-freeze.test.js b/test/money-freeze.test.js
iindex 073bacd1..893df6e3 100644
---- a/test/money-freeze.test.js
++++ b/test/money-freeze.test.js
@@@ -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();
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 3194c512..01504a82 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -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'],
ddiff --git a/test/powerchat-checkout-intent.test.js b/test/powerchat-checkout-intent.test.js
iindex 603d975b..2ec5cf74 100644
---- a/test/powerchat-checkout-intent.test.js
++++ b/test/powerchat-checkout-intent.test.js
@@@ -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');
ddiff --git a/test/powerchat-reconcile.test.js b/test/powerchat-reconcile.test.js
ddeleted file mode 100644
iindex 4a42b54f..00000000
---- a/test/powerchat-reconcile.test.js
++++ /dev/null
@@@ -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); });
ddiff --git a/test/powerchat-ref-authority.test.js b/test/powerchat-ref-authority.test.js
ddeleted file mode 100644
iindex 95da64ed..00000000
---- a/test/powerchat-ref-authority.test.js
++++ /dev/null
@@@ -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); });