openvibers/openvibe.live Commits
Channel tip history (channel_tips): every tip a channel received, on-site Vibes, PowerChat or OpenVibe.Tips, backfilled from the ledger; the stream leaderboard, recaps, VOD pages and the home's tips and supporters read it, so they keep counting after the Billing cutover and count PowerChat and Tips tips (#170)
Changes
nnew file mode 100644@@@ -0,0 +1,33 @@++-- phase: expand++-- Every tip a channel received, whatever carried it, as Live shows it (server/monetization/channel-tips.js): Vibes++-- donated on the site, tips on the streamer's own PowerChat, and tips OpenVibe.Tips announces after Billing settled++-- them. The stream leaderboard, after-stream recaps, VOD pages and the home's "Vibes tipped" and supporters read this,++-- not the money ledger: under BILLING_AUTHORITY=billing Live's `transactions` stops at the cutover and Billing is the++-- ledger, and PowerChat and Tips tips were never in it. Display rows only; no balance is derived from them.++--++-- from_user_id is the supporter's Live account when they have one; `username` is the name shown for a supporter who++-- has none (PowerChat, Tips), NULL otherwise (the account's current name is read instead). amount is in Vibes, NULL++-- when the supporter hid it (Tips privacy.hide_amount). ref is the tip's own id, so recording it twice keeps one row:++-- live-txn:<transactions.id>, billing-txn:<Billing transaction id>, powerchat:<event id>, tips:<delivery key>.++CREATE TABLE IF NOT EXISTS channel_tips (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ to_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,++ from_user_id bigint REFERENCES users(id) ON DELETE SET NULL,++ username text COLLATE "C",++ stream_id bigint REFERENCES streams(id) ON DELETE SET NULL,++ amount bigint CHECK (amount IS NULL OR amount >= 0),++ source text COLLATE "C" NOT NULL CHECK (source IN ('vibes', 'powerchat', 'tips')),++ ref text COLLATE "C" NOT NULL UNIQUE,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++CREATE INDEX IF NOT EXISTS idx_channel_tips_recent ON channel_tips (created_at);++CREATE INDEX IF NOT EXISTS idx_channel_tips_channel ON channel_tips (to_user_id, created_at);++CREATE INDEX IF NOT EXISTS idx_channel_tips_stream ON channel_tips (stream_id) WHERE stream_id IS NOT NULL;++++-- The donations Live's ledger already holds (on-site Vibes and site-routed PowerChat tips).++INSERT INTO channel_tips (to_user_id, from_user_id, stream_id, amount, source, ref, created_at)++SELECT t.to_user_id, t.from_user_id, t.stream_id, t.amount, 'vibes', 'live-txn:' || t.id, COALESCE(t.created_at, ov_now())++ FROM transactions t++ WHERE t.type = 'donation' AND t.to_user_id IS NOT NULL AND t.amount >= 0++ AND COALESCE(t.status, 'completed') NOT IN ('failed', 'refunded', 'pending')++ON CONFLICT (ref) DO NOTHING;@@@ -1319,12 +1319,12 @@ async function getHomePulse() {WHERE g.is_active = 1 AND g.target_amount > 0ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),latestTip: await safe(async () => await get(`SELECT t.amount, t.created_at,-- fu.username AS from_username, fu.display_name AS from_display,++ fu.username AS from_username, COALESCE(fu.display_name, t.username) AS from_display,tu.username AS to_username, tu.display_name AS to_display-- FROM transactions t++ FROM channel_tips tLEFT JOIN users fu ON fu.id = t.from_user_idJOIN users tu ON tu.id = t.to_user_id-- WHERE t.type = 'donation' ORDER BY t.id DESC LIMIT 1`), null),++ WHERE t.amount IS NOT NULL ORDER BY t.id DESC LIMIT 1`), null),newestFollow: await safe(async () => await get(`SELECT f.created_at,fu.username AS follower_username, fu.display_name AS follower_display,su.username AS streamer_username, su.display_name AS streamer_display@@@ -1333,10 +1333,11 @@ async function getHomePulse() {JOIN users su ON su.id = f.streamer_idORDER BY f.id DESC LIMIT 1`), null),// This week's leaders.-- topSupporters: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(t.amount)::float8 AS total-- FROM transactions t JOIN users u ON u.id = t.from_user_id-- WHERE t.type = 'donation' AND t.created_at >= datetime('now', '-7 days')-- GROUP BY u.id ORDER BY total DESC LIMIT 3`), []),++ topSupporters: await safe(async () => await all(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name,++ MAX(u.avatar_url) AS avatar_url, SUM(t.amount)::float8 AS total++ FROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_id++ WHERE t.amount IS NOT NULL AND t.created_at >= datetime('now', '-7 days')++ GROUP BY ${TIP_SUPPORTER_T} ORDER BY total DESC LIMIT 3`), []),topEarners: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS totalFROM coin_transactions c JOIN users u ON u.id = c.user_idWHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')@@@ -1369,6 +1370,9 @@ async function vibesStatsSince() {// One registry entry per metric: the table, its timestamp column, what to aggregate and// an optional WHERE. `days` buckets are computed in SQL (the day of datetime(ts)); missing days are// filled with 0 so charts never skip a day.++// One supporter in channel_tips, with or without a Live account (PowerChat and OpenVibe.Tips supporters have a name only).++const TIP_SUPPORTER = "COALESCE(from_user_id::text, 'n:' || lower(COALESCE(username, '')))";++const TIP_SUPPORTER_T = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";const HOME_SERIES = {users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },@@@ -1382,8 +1386,9 @@ const HOME_SERIES = {// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched)::float8, 0) / 60.0' },aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },-- vibes: { table: 'transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: "type = 'donation'", vibesReset: true },-- supporters: { table: 'transactions', ts: 'created_at', agg: 'COUNT(DISTINCT from_user_id)', where: "type = 'donation' AND from_user_id IS NOT NULL", vibesReset: true },++ // Tips from channel_tips (every rail: on-site Vibes, PowerChat, OpenVibe.Tips), not the money ledger.++ vibes: { table: 'channel_tips', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', vibesReset: true },++ supporters: { table: 'channel_tips', ts: 'created_at', agg: `COUNT(DISTINCT ${TIP_SUPPORTER})`, vibesReset: true },vibesBought: { table: 'payment_orders', ts: 'updated_at', agg: 'COALESCE(SUM(bucks)::float8, 0)', where: "kind = 'bucks' AND status = 'credited'" },subs: { table: 'subscriptions', ts: 'created_at', agg: 'COUNT(*)' },points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },@@@ -1523,8 +1528,8 @@ async function _computeHomeStats() {viewersNow: await c(`SELECT COALESCE(SUM(viewer_count)::float8, 0) AS count FROM streams WHERE is_live = 1`),// Community time actually spent watching (watch-time heartbeats → hours).hoursWatched: Math.round(await c(`SELECT COALESCE(SUM(minutes_watched)::float8, 0) AS count FROM watch_time`) / 60),-- // Vibes tipped between people (donation ledger; bit-style, 100 = $1).-- vibesTipped: await c(`SELECT COALESCE(SUM(amount)::float8, 0) AS count FROM transactions WHERE type = 'donation' AND created_at >= ?`, [vibesSince]),++ // Vibes tipped to channels (channel_tips: on-site, PowerChat and OpenVibe.Tips; bit-style, 100 = $1).++ vibesTipped: await c(`SELECT COALESCE(SUM(amount)::float8, 0) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),// Live channel subscriptions.activeSubs: await c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now())`),// Channel points earned by viewers across every channel (watch/chat/follow bonuses).@@@ -1533,8 +1538,8 @@ async function _computeHomeStats() {pointsSpent: await c(`SELECT COALESCE(-SUM(amount)::float8, 0) AS count FROM coin_transactions WHERE amount < 0`),// Reward redemptions that stuck (not rejected / refunded).redemptions: await c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),-- // Distinct people who have tipped Vibes to someone.-- supporters: await c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= ?`, [vibesSince]),++ // Distinct people who have tipped someone.++ supporters: await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),// Vibes bought with real money (credited purchase orders, any provider).vibesBought: await c(`SELECT COALESCE(SUM(bucks)::float8, 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),// Donation goals: currently running + ever reached.@@@ -1602,10 +1607,10 @@ async function _computeHomeStats() {goals: await winCount('donation_goals', 'created_at'),// Distinct people who tipped in each window — a headcount, like streamers.supporters: await (async () => {-- const q = async (a, b) => await c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= datetime('now', ?)${b ? " AND created_at < datetime('now', ?)" : ''}`, b ? [a, b] : [a]);++ const q = async (a, b) => await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= datetime('now', ?)${b ? " AND created_at < datetime('now', ?)" : ''}`, b ? [a, b] : [a]);return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: await q('-14 days', '-7 days') };})(),-- vibes: await winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),++ vibes: await winSum('channel_tips', 'amount', 'created_at', `created_at >= '${vibesSince}'`),points: await winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),pointsSpent: await winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),redemptions: await winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),@@@ -399,7 +399,7 @@ async function _creditSiteRoutedDonation(streamerUserId, donorUserId, data) {});} catch { /* ledger best-effort */ }// Same celebration pipeline a direct-connected streamer gets from their own webhook.-- try { await require('./powerchat-webhook').creditDonationPipeline(streamerUserId, data); } catch (e) { console.warn('[PowerChat] pcdon pipeline:', e.message); }++ 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}`);}@@@ -87,7 +87,7 @@ async function _chatLine({ line, mirror = false, key }) {return await delivery.message({ ...line, mirror, key });}--async function _handleDonation(userId, data) {++async function _handleDonation(userId, data, { fromUserId = null } = {}) {const alerts = require('../monetization/alerts');const openvibeBucks = require('../monetization/vibes');@@@ -109,6 +109,10 @@ async function _handleDonation(userId, data) {// 1) Donation chat event — persisted to channel history (and mirrored to global chat).const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;++ // The channel's tip history (leaderboard, recap, home stats); site-routed tips name the donor's account.++ await require('../monetization/channel-tips').record({++ toUserId: userId, fromUserId, username: donor, streamId, amount, source: 'powerchat', ref: eventKey || `powerchat:${crypto.randomUUID()}`,++ });try {await _chatLine({mirror: true, key: eventKey,@@@ -444,7 +444,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {sound_commands: Number(chatStats.sounds) || 0,mic_moments: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id])).n, 0),follows_gained: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end])).n, 0),-- tips: await safe(async () => await db.get("SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM transactions WHERE type = 'donation' AND (stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?))", [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),++ tips: await safe(async () => await db.get('SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM channel_tips WHERE stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?)', [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),top_chatters: topChatters,};}nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++/**++ * channel_tips: every tip a channel received, as Live shows it (migrations/0009_channel_tips.sql). Recorded by the++ * three paths that announce a tip (POST /api/funds/donate, integrations/powerchat-webhook.js, tips/delivery-routes.js)++ * and read by the stream leaderboard, the after-stream recap, VOD pages and the home stats. Display rows, never money:++ * Live's `transactions` or OpenVibe.Billing is the ledger.++ */++const db = require('../db/database');++++const SOURCES = new Set(['vibes', 'powerchat', 'tips']);++++/**++ * Record one tip. ref is the tip's own id (a repeat keeps one row). amount null = hidden by the supporter. Never++ * throws: a display row that failed to save must not fail the tip that was already announced.++ */++async function record({ toUserId, fromUserId = null, username = null, streamId = null, amount = null, source, ref }) {++ if (!toUserId || !SOURCES.has(source) || !ref) return false;++ const amt = amount == null ? null : Math.max(0, Math.round(Number(amount) || 0));++ try {++ const r = await db.run(`INSERT INTO channel_tips (to_user_id, from_user_id, username, stream_id, amount, source, ref)++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (ref) DO NOTHING`,++ [Number(toUserId), fromUserId ? Number(fromUserId) : null, fromUserId ? null : (username ? String(username).slice(0, 80) : null),++ streamId ? Number(streamId) : null, amt, source, String(ref).slice(0, 200)]);++ return r.changes > 0;++ } catch (e) {++ console.warn('[channel-tips] not recorded:', e.message);++ return false;++ }++}++++/** SQL for one supporter, with or without a Live account (alias `t` for channel_tips). */++const SUPPORTER_KEY = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";++++module.exports = { record, SUPPORTER_KEY };@@@ -17,6 +17,7 @@ const openvibeBucks = require('./vibes');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.const money = require('./money-authority');@@@ -78,6 +79,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {// 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.++ 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()}`,++ });++const alerts = require('./alerts');const donorUser = await db.getUserById(req.user.id);const donor = donorUser?.display_name || donorUser?.username || 'Someone';@@@ -249,16 +249,19 @@ class Vibes {}/**-- * Get donation leaderboard for a stream++ * 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.*/async getLeaderboard(streamId, limit = 10) {++ const { SUPPORTER_KEY } = require('./channel-tips');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++ SELECT MAX(t.from_user_id) AS from_user_id, MAX(u.username) AS username,++ MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name, MAX(u.avatar_url) AS avatar_url,++ SUM(t.amount)::bigint AS total_donated++ FROM channel_tips t++ LEFT JOIN users u ON t.from_user_id = u.id++ WHERE t.stream_id = ? AND t.amount IS NOT NULL++ GROUP BY ${SUPPORTER_KEY}ORDER BY total_donated DESCLIMIT ?`, [streamId, limit]);@@@ -53,9 +53,12 @@ async function gather(streamId) {const micLines = await safe(async () => await db.all(`SELECT text, quality, kind, aimed_at, sec, vod_id FROM arena_mic_moments WHERE stream_id = ? ORDER BY quality DESC, said_at ASC LIMIT 4`, [streamId]), []);// Money + love.-- const tips = await safe(async () => await db.get(`SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS total FROM transactions WHERE type = 'donation' AND COALESCE(status, 'completed') NOT IN ('failed', 'refunded', 'pending') AND ((stream_id = ?) OR (to_user_id = ? AND created_at BETWEEN ? AND ?))`, [streamId, stream.user_id, start, end]), { n: 0, total: 0 });-- const topTipper = await safe(async () => await db.get(`SELECT u.username, u.display_name, SUM(t.amount)::bigint AS total FROM transactions t JOIN users u ON u.id = t.from_user_id-- WHERE t.type = 'donation' AND ((t.stream_id = ?) OR (t.to_user_id = ? AND t.created_at BETWEEN ? AND ?)) GROUP BY t.from_user_id, u.id ORDER BY total DESC LIMIT 1`, [streamId, stream.user_id, start, end]), null);++ // Tips from channel_tips: on-site Vibes, PowerChat and OpenVibe.Tips (a hidden amount counts as a tip, adds nothing).++ const { SUPPORTER_KEY } = require('../monetization/channel-tips');++ const tips = await safe(async () => await db.get(`SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS total FROM channel_tips WHERE (stream_id = ?) OR (to_user_id = ? AND created_at BETWEEN ? AND ?)`, [streamId, stream.user_id, start, end]), { n: 0, total: 0 });++ const topTipper = await safe(async () => await db.get(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name, SUM(t.amount)::bigint AS total++ FROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_id++ WHERE t.amount IS NOT NULL AND ((t.stream_id = ?) OR (t.to_user_id = ? AND t.created_at BETWEEN ? AND ?)) GROUP BY ${SUPPORTER_KEY} ORDER BY total DESC LIMIT 1`, [streamId, stream.user_id, start, end]), null);const follows = await safe(async () => (await db.get(`SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?`, [stream.user_id, start, end])).n, 0);const followersNow = await safe(async () => await db.getFollowerCount(stream.user_id), null);@@@ -119,6 +119,10 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim};// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });++ if (!b.test) {++ const supporterId = await channelUserId(b.supporter && b.supporter.subject);++ await require('../monetization/channel-tips').record({ toUserId: userId, fromUserId: supporterId, username: name, streamId, amount, source: 'tips', ref: `tips:${key}` });++ }await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');// A settled tip in Vibes counts toward the channel's donation goal, like an on-site donation (a test, or a// tip whose amount the supporter hid, does not). The claim above makes this once per Idempotency-Key.@@@ -131,6 +131,10 @@ async function check(name, fn) {assert.strictEqual(billing.payable[SID.bob], 150);const row = await raw.prepare("SELECT * FROM billing_actions WHERE idempotency_key = ?").get(c[0].key);assert.strictEqual(row.status, 'done'); assert.match(row.billing_ref, /^txn_/);++ // The channel's tip history keeps counting after the cutover (leaderboard, recap, home stats), named after++ // Billing's transaction; Live's ledger stays untouched.++ assert.deepStrictEqual(await raw.prepare('SELECT to_user_id, from_user_id, stream_id, amount, source FROM channel_tips WHERE ref = ?').get(`billing-txn:${row.billing_ref}`),++ { to_user_id: 3, from_user_id: 2, stream_id: streamB, amount: 150, source: 'vibes' });// The chat celebration is OpenVibe.Chat's now: Live sends it the line through the delivery// seam and keeps no copy (test/tips-delivery.test.js covers the payload).});nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * channel_tips (migrations/0009_channel_tips.sql, server/monetization/channel-tips.js): every tip a channel received,++ * whatever carried it. The stream leaderboard, the home's pulse and stats read it instead of Live's `transactions`, which++ * stops at the Billing cutover and never held PowerChat or OpenVibe.Tips tips.++ *++ * node --import ./test/helpers/pg-preload.mjs test/channel-tips.test.js++ */++const assert = require('assert');++const fs = require('fs');++const http = require('http');++const path = require('path');++++process.env.NODE_ENV = 'test';++delete process.env.BILLING_AUTHORITY;++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++ const addUser = async (id, username, bucks = 0) => await raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, openvibe_bucks_balance) OVERRIDING SYSTEM VALUE++ VALUES (?, ?, ?, ?, 'x', 'user', ?)`).run(id, username, username[0].toUpperCase() + username.slice(1), `${username}@x`, bucks);++ await addUser(2, 'ann', 2000);++ await addUser(3, 'bob');++ const streamId = Number((await db.run("INSERT INTO streams (user_id, title, protocol, is_live) VALUES (3, 'bob live', 'webrtc', 1) RETURNING id")).lastInsertRowid);++++ // Chat shows the lines (Live keeps no chat table): record what is sent instead.++ const delivery = require('../server/chat/chat-delivery');++ delivery.message = async () => 1;++ delivery.event = async () => {};++++ 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); };++ const express = require('express');++ const app = express();++ app.use(express.json());++ app.use('/api/funds', require('../server/monetization/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const donate = async (user, body) => {++ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/funds/donate`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-test-user': String(user) }, body: JSON.stringify(body) });++ return { status: res.status, json: await res.json() };++ };++ 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.++ 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' });++++ // 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');++ await powerchat.creditDonationPipeline(3, { eventId: 'evt_9', amountUsdCents: 300, donorName: 'pcfan', message: '' });++ await powerchat.creditDonationPipeline(3, { eventId: 'evt_9', amountUsdCents: 300, donorName: 'pcfan', message: '' });++ assert.deepStrictEqual(await tip('powerchat:evt_9'), { to_user_id: 3, from_user_id: null, username: 'pcfan', stream_id: streamId, amount: 300, source: 'powerchat' });++ // A PowerChat tip routed through the site account names the donor's Live account.++ await powerchat.creditDonationPipeline(3, { eventId: 'evt_10', amountUsdCents: 400, donorName: 'Ann', message: '' }, { fromUserId: 2 });++ assert.deepStrictEqual(await tip('powerchat:evt_10'), { to_user_id: 3, from_user_id: 2, username: null, stream_id: streamId, amount: 400, source: 'powerchat' });++ // A supporter who hid the amount (OpenVibe.Tips privacy.hide_amount) tipped, but adds nothing to the sums.++ const channelTips = require('../server/monetization/channel-tips');++ assert.strictEqual(await channelTips.record({ toUserId: 3, username: 'shy', streamId, amount: null, source: 'tips', ref: 'tips:tint_s:chat_line' }), true);++ assert.strictEqual(await channelTips.record({ toUserId: 3, username: 'shy', streamId, amount: null, source: 'tips', ref: 'tips:tint_s:chat_line' }), false, 'a repeat keeps one row');++ assert.strictEqual(await channelTips.record({ toUserId: 3, amount: 5, source: 'nope', ref: 'x:1' }), false, 'an unknown source is refused');++ assert.strictEqual(await count(), 4);++++ // The stream's leaderboard: Ann's on-site and PowerChat tips together, then the PowerChat supporter (no profile link).++ const board = await require('../server/monetization/vibes').getLeaderboard(streamId);++ assert.deepStrictEqual(board.map((r) => [r.username, r.display_name, Number(r.total_donated)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);++++ // The home pulse and stats count every rail.++ const pulse = await db.getHomePulse();++ assert.strictEqual(pulse.latestTip.from_username, 'ann', 'the newest tip with an amount');++ assert.strictEqual(Number(pulse.latestTip.amount), 400);++ assert.deepStrictEqual(pulse.topSupporters.map((r) => [r.username, r.display_name, Number(r.total)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);++ const stats = await db.getHomeStats();++ assert.strictEqual(stats.vibesTipped, 850);++ assert.strictEqual(stats.supporters, 3, 'ann, pcfan and the supporter who hid the amount');++ assert.strictEqual((await db.getHomeStatSeries('supporters', 30)).total, 3);++++ // The migration's backfill: completed donations in the ledger become tips once; failed ones and purchases do not.++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0009_channel_tips.sql'), 'utf8');++ const backfill = sql.slice(sql.indexOf('INSERT INTO channel_tips'));++ const ok = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (2, 3, 70, 'donation', 'completed') RETURNING id")).lastInsertRowid);++ const failed = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (2, 3, 80, 'donation', 'failed') RETURNING id")).lastInsertRowid);++ const bought = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (NULL, 2, 500, 'purchase', 'completed') RETURNING id")).lastInsertRowid);++ await raw.prepare(backfill).run();++ await raw.prepare(backfill).run();++ assert.deepStrictEqual(await tip(`live-txn:${ok}`), { to_user_id: 3, from_user_id: 2, username: null, stream_id: null, amount: 70, source: 'vibes' });++ assert.strictEqual(await tip(`live-txn:${failed}`), undefined);++ assert.strictEqual(await tip(`live-txn:${bought}`), undefined);++ assert.strictEqual(await count(), 5, 'the donation the route already recorded is not copied again');++++ server.close();++ quiet('channel tips: all checks passed');++ process.exit(0);++})().catch((err) => { quiet(err); process.exit(1); });@@@ -58,9 +58,10 @@ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM viewer_samples WHEREconsole.log('✅ reading series are bucketed, null-filled and retained for 400 days');// ── vibes: test money before the reset never counts ──--await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 500, 'donation', 'completed', datetime('now', '-10 days'))", [a, b]);--await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 200, 'donation', 'completed', datetime('now', '-2 days'))", [b, a]);--await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 50, 'donation', 'completed', datetime('now', '-1 hours'))", [b, a]);++// The counters read channel_tips (every tip a channel received, on any rail), not the money ledger.++await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source, ref, created_at) VALUES (?, ?, 500, 'vibes', 'live-txn:1', datetime('now', '-10 days'))", [a, b]);++await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source, ref, created_at) VALUES (?, ?, 200, 'vibes', 'live-txn:2', datetime('now', '-2 days'))", [b, a]);++await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source, ref, created_at) VALUES (?, ?, 50, 'vibes', 'live-txn:3', datetime('now', '-1 hours'))", [b, a]);let stats = await db.getHomeStats();assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');@@@ -18,7 +18,8 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().repchatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];await db.run('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)', [fan2, host, ts(30)]);await db.run('INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)', [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);-- await db.run('INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status) VALUES (?,?,?,?,?,?)', [fan, host, sid, 5, 'donation', 'completed']);++ await require('../server/monetization/channel-tips').record({ toUserId: host, fromUserId: fan, streamId: sid, amount: 5, source: 'vibes', ref: 'live-txn:1' });++ await require('../server/monetization/channel-tips').record({ toUserId: host, username: 'pcfan', streamId: sid, amount: 3, source: 'powerchat', ref: 'powerchat:evt_1' });await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);const pend = (await recap.pending()).map(r => r.id);assert.deepStrictEqual(pend, [sid], 'only the long-enough, settled stream is pending');@@@ -31,7 +32,8 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().repassert.strictEqual(r.chat.top[0].username, 'fan1', 'host excluded from top chatters, fan1 leads');assert.strictEqual(r.chat.top[0].n, 9);assert.strictEqual(r.love.follows, 1);-- assert.strictEqual(r.love.tips, 1); assert.strictEqual(r.love.tips_total, 5);++ assert.strictEqual(r.love.tips, 2); assert.strictEqual(r.love.tips_total, 8, 'on-site and PowerChat tips both count');++ assert.deepStrictEqual(r.love.top_tipper, { username: 'fan1', display_name: 'Fan1', total: 5 });assert.strictEqual(r.mic.length, 1);assert.strictEqual(r.viewers.curve.length, 12);assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');@@@ -81,6 +81,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issassert.strictEqual(await goalNow(), 250, 'the tip counts toward the goal');assert.deepStrictEqual(goalUpdates(), [250], 'and the goal widget hears it');assert.deepStrictEqual(a.json.ref.goal, { id: goalId, reached: false });++ assert.deepStrictEqual(await d.prepare("SELECT to_user_id, from_user_id, username, amount, source FROM channel_tips WHERE ref = 'tips:tint_1:chat_line'").get(),++ { to_user_id: 501, from_user_id: null, username: 'Viewer', amount: 250, source: 'tips' }, "the channel's tip history has it");// A retry of the same delivery changes nothing.const b = await post(job);@@@ -101,6 +103,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issassert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');assert.strictEqual(sent.length, 1, 'and delivers nothing again');assert.strictEqual(await goalNow(), 250, 'nor counts toward the goal again');++ assert.strictEqual((await d.prepare("SELECT COUNT(*) AS n FROM channel_tips WHERE ref = 'tips:tint_1:chat_line'").get()).n, 1);// A retry while the first attempt is still running is told to come back; a claim left by a// crash (older than two minutes) is taken over.@@@ -146,6 +149,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss// A simulation (test: true) shows the line but leaves the goal alone.assert.strictEqual((await post({ ...job, test: true, interaction: { ...job.interaction, id: 'tint_t' } }, { key: 'tint_t:chat_line' })).status, 200);assert.strictEqual(await goalNow(), 500);++ assert.strictEqual((await d.prepare("SELECT COUNT(*) AS n FROM channel_tips WHERE ref = 'tips:tint_t:chat_line'").get()).n, 0, 'nor the tip history');// Reaching the goal: the widget shows it full, a saved "Goal reached" line in Vibes, the goal sound.const before = sent.length;
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+-- phase: expand
+-- Every tip a channel received, whatever carried it, as Live shows it (server/monetization/channel-tips.js): Vibes
+-- donated on the site, tips on the streamer's own PowerChat, and tips OpenVibe.Tips announces after Billing settled
+-- them. The stream leaderboard, after-stream recaps, VOD pages and the home's "Vibes tipped" and supporters read this,
+-- not the money ledger: under BILLING_AUTHORITY=billing Live's `transactions` stops at the cutover and Billing is the
+-- ledger, and PowerChat and Tips tips were never in it. Display rows only; no balance is derived from them.
+--
+-- from_user_id is the supporter's Live account when they have one; `username` is the name shown for a supporter who
+-- has none (PowerChat, Tips), NULL otherwise (the account's current name is read instead). amount is in Vibes, NULL
+-- when the supporter hid it (Tips privacy.hide_amount). ref is the tip's own id, so recording it twice keeps one row:
+-- live-txn:<transactions.id>, billing-txn:<Billing transaction id>, powerchat:<event id>, tips:<delivery key>.
+CREATE TABLE IF NOT EXISTS channel_tips (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ to_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ from_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
+ username text COLLATE "C",
+ stream_id bigint REFERENCES streams(id) ON DELETE SET NULL,
+ amount bigint CHECK (amount IS NULL OR amount >= 0),
+ source text COLLATE "C" NOT NULL CHECK (source IN ('vibes', 'powerchat', 'tips')),
+ ref text COLLATE "C" NOT NULL UNIQUE,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+CREATE INDEX IF NOT EXISTS idx_channel_tips_recent ON channel_tips (created_at);
+CREATE INDEX IF NOT EXISTS idx_channel_tips_channel ON channel_tips (to_user_id, created_at);
+CREATE INDEX IF NOT EXISTS idx_channel_tips_stream ON channel_tips (stream_id) WHERE stream_id IS NOT NULL;
+
+-- The donations Live's ledger already holds (on-site Vibes and site-routed PowerChat tips).
+INSERT INTO channel_tips (to_user_id, from_user_id, stream_id, amount, source, ref, created_at)
+SELECT t.to_user_id, t.from_user_id, t.stream_id, t.amount, 'vibes', 'live-txn:' || t.id, COALESCE(t.created_at, ov_now())
+ FROM transactions t
+ WHERE t.type = 'donation' AND t.to_user_id IS NOT NULL AND t.amount >= 0
+ AND COALESCE(t.status, 'completed') NOT IN ('failed', 'refunded', 'pending')
+ON CONFLICT (ref) DO NOTHING;
@@ -1319,12 +1319,12 @@ async function getHomePulse() {
@@ -1319,12 +1319,12 @@ async function getHomePulse() {
WHERE g.is_active = 1 AND g.target_amount > 0
WHERE g.is_active = 1 AND g.target_amount > 0
ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),
ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),
latestTip: await safe(async () => await get(`SELECT t.amount, t.created_at,
latestTip: await safe(async () => await get(`SELECT t.amount, t.created_at,
- fu.username AS from_username, fu.display_name AS from_display,
+ fu.username AS from_username, COALESCE(fu.display_name, t.username) AS from_display,
tu.username AS to_username, tu.display_name AS to_display
tu.username AS to_username, tu.display_name AS to_display
- FROM transactions t
+ FROM channel_tips t
LEFT JOIN users fu ON fu.id = t.from_user_id
LEFT JOIN users fu ON fu.id = t.from_user_id
JOIN users tu ON tu.id = t.to_user_id
JOIN users tu ON tu.id = t.to_user_id
- WHERE t.type = 'donation' ORDER BY t.id DESC LIMIT 1`), null),
+ WHERE t.amount IS NOT NULL ORDER BY t.id DESC LIMIT 1`), null),
newestFollow: await safe(async () => await get(`SELECT f.created_at,
newestFollow: await safe(async () => await get(`SELECT f.created_at,
fu.username AS follower_username, fu.display_name AS follower_display,
fu.username AS follower_username, fu.display_name AS follower_display,
su.username AS streamer_username, su.display_name AS streamer_display
su.username AS streamer_username, su.display_name AS streamer_display
@@ -1333,10 +1333,11 @@ async function getHomePulse() {
@@ -1333,10 +1333,11 @@ async function getHomePulse() {
JOIN users su ON su.id = f.streamer_id
JOIN users su ON su.id = f.streamer_id
ORDER BY f.id DESC LIMIT 1`), null),
ORDER BY f.id DESC LIMIT 1`), null),
// This week's leaders.
// This week's leaders.
- topSupporters: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(t.amount)::float8 AS total
- FROM transactions t JOIN users u ON u.id = t.from_user_id
- WHERE t.type = 'donation' AND t.created_at >= datetime('now', '-7 days')
- GROUP BY u.id ORDER BY total DESC LIMIT 3`), []),
+ topSupporters: await safe(async () => await all(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name,
+ MAX(u.avatar_url) AS avatar_url, SUM(t.amount)::float8 AS total
+ FROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_id
+ WHERE t.amount IS NOT NULL AND t.created_at >= datetime('now', '-7 days')
+ GROUP BY ${TIP_SUPPORTER_T} ORDER BY total DESC LIMIT 3`), []),
topEarners: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS total
topEarners: await safe(async () => await all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount)::float8 AS total
FROM coin_transactions c JOIN users u ON u.id = c.user_id
FROM coin_transactions c JOIN users u ON u.id = c.user_id
WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')
WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')
@@ -1369,6 +1370,9 @@ async function vibesStatsSince() {
@@ -1369,6 +1370,9 @@ async function vibesStatsSince() {
// One registry entry per metric: the table, its timestamp column, what to aggregate and
// One registry entry per metric: the table, its timestamp column, what to aggregate and
// an optional WHERE. `days` buckets are computed in SQL (the day of datetime(ts)); missing days are
// an optional WHERE. `days` buckets are computed in SQL (the day of datetime(ts)); missing days are
// filled with 0 so charts never skip a day.
// filled with 0 so charts never skip a day.
+// One supporter in channel_tips, with or without a Live account (PowerChat and OpenVibe.Tips supporters have a name only).
+const TIP_SUPPORTER = "COALESCE(from_user_id::text, 'n:' || lower(COALESCE(username, '')))";
+const TIP_SUPPORTER_T = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";
const HOME_SERIES = {
const HOME_SERIES = {
users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },
users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },
anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
@@ -1382,8 +1386,9 @@ const HOME_SERIES = {
@@ -1382,8 +1386,9 @@ const HOME_SERIES = {
// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched)::float8, 0) / 60.0' },
hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched)::float8, 0) / 60.0' },
aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
- vibes: { table: 'transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: "type = 'donation'", vibesReset: true },
- supporters: { table: 'transactions', ts: 'created_at', agg: 'COUNT(DISTINCT from_user_id)', where: "type = 'donation' AND from_user_id IS NOT NULL", vibesReset: true },
+ // Tips from channel_tips (every rail: on-site Vibes, PowerChat, OpenVibe.Tips), not the money ledger.
+ vibes: { table: 'channel_tips', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', vibesReset: true },
+ supporters: { table: 'channel_tips', ts: 'created_at', agg: `COUNT(DISTINCT ${TIP_SUPPORTER})`, vibesReset: true },
vibesBought: { table: 'payment_orders', ts: 'updated_at', agg: 'COALESCE(SUM(bucks)::float8, 0)', where: "kind = 'bucks' AND status = 'credited'" },
vibesBought: { table: 'payment_orders', ts: 'updated_at', agg: 'COALESCE(SUM(bucks)::float8, 0)', where: "kind = 'bucks' AND status = 'credited'" },
subs: { table: 'subscriptions', ts: 'created_at', agg: 'COUNT(*)' },
subs: { table: 'subscriptions', ts: 'created_at', agg: 'COUNT(*)' },
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },
@@ -1523,8 +1528,8 @@ async function _computeHomeStats() {
@@ -1523,8 +1528,8 @@ async function _computeHomeStats() {
viewersNow: await c(`SELECT COALESCE(SUM(viewer_count)::float8, 0) AS count FROM streams WHERE is_live = 1`),
viewersNow: await c(`SELECT COALESCE(SUM(viewer_count)::float8, 0) AS count FROM streams WHERE is_live = 1`),
// Community time actually spent watching (watch-time heartbeats → hours).
// Community time actually spent watching (watch-time heartbeats → hours).
hoursWatched: Math.round(await c(`SELECT COALESCE(SUM(minutes_watched)::float8, 0) AS count FROM watch_time`) / 60),
hoursWatched: Math.round(await c(`SELECT COALESCE(SUM(minutes_watched)::float8, 0) AS count FROM watch_time`) / 60),
- // Vibes tipped between people (donation ledger; bit-style, 100 = $1).
- vibesTipped: await c(`SELECT COALESCE(SUM(amount)::float8, 0) AS count FROM transactions WHERE type = 'donation' AND created_at >= ?`, [vibesSince]),
+ // Vibes tipped to channels (channel_tips: on-site, PowerChat and OpenVibe.Tips; bit-style, 100 = $1).
+ vibesTipped: await c(`SELECT COALESCE(SUM(amount)::float8, 0) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),
// Live channel subscriptions.
// Live channel subscriptions.
activeSubs: await c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now())`),
activeSubs: await c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > ov_now())`),
// Channel points earned by viewers across every channel (watch/chat/follow bonuses).
// Channel points earned by viewers across every channel (watch/chat/follow bonuses).
@@ -1533,8 +1538,8 @@ async function _computeHomeStats() {
@@ -1533,8 +1538,8 @@ async function _computeHomeStats() {
pointsSpent: await c(`SELECT COALESCE(-SUM(amount)::float8, 0) AS count FROM coin_transactions WHERE amount < 0`),
pointsSpent: await c(`SELECT COALESCE(-SUM(amount)::float8, 0) AS count FROM coin_transactions WHERE amount < 0`),
// Reward redemptions that stuck (not rejected / refunded).
// Reward redemptions that stuck (not rejected / refunded).
redemptions: await c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),
redemptions: await c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),
- // Distinct people who have tipped Vibes to someone.
- supporters: await c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= ?`, [vibesSince]),
+ // Distinct people who have tipped someone.
+ supporters: await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),
// Vibes bought with real money (credited purchase orders, any provider).
// Vibes bought with real money (credited purchase orders, any provider).
vibesBought: await c(`SELECT COALESCE(SUM(bucks)::float8, 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),
vibesBought: await c(`SELECT COALESCE(SUM(bucks)::float8, 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),
// Donation goals: currently running + ever reached.
// Donation goals: currently running + ever reached.
@@ -1602,10 +1607,10 @@ async function _computeHomeStats() {
@@ -1602,10 +1607,10 @@ async function _computeHomeStats() {
goals: await winCount('donation_goals', 'created_at'),
goals: await winCount('donation_goals', 'created_at'),
// Distinct people who tipped in each window — a headcount, like streamers.
// Distinct people who tipped in each window — a headcount, like streamers.
supporters: await (async () => {
supporters: await (async () => {
- const q = async (a, b) => await c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= datetime('now', ?)${b ? " AND created_at < datetime('now', ?)" : ''}`, b ? [a, b] : [a]);
+ const q = async (a, b) => await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= datetime('now', ?)${b ? " AND created_at < datetime('now', ?)" : ''}`, b ? [a, b] : [a]);
return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: await q('-14 days', '-7 days') };
return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: await q('-14 days', '-7 days') };
})(),
})(),
- vibes: await winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),
+ vibes: await winSum('channel_tips', 'amount', 'created_at', `created_at >= '${vibesSince}'`),
points: await winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
points: await winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
pointsSpent: await winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
pointsSpent: await winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
redemptions: await winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),
redemptions: await winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),
@@ -399,7 +399,7 @@ async function _creditSiteRoutedDonation(streamerUserId, donorUserId, data) {
@@ -399,7 +399,7 @@ async function _creditSiteRoutedDonation(streamerUserId, donorUserId, data) {
});
});
} catch { /* ledger best-effort */ }
} catch { /* ledger best-effort */ }
// Same celebration pipeline a direct-connected streamer gets from their own webhook.
// Same celebration pipeline a direct-connected streamer gets from their own webhook.
- try { await require('./powerchat-webhook').creditDonationPipeline(streamerUserId, data); } catch (e) { console.warn('[PowerChat] pcdon pipeline:', e.message); }
+ 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}`);
console.log(`[PowerChat] site-routed donation: ${_usd(usdCents)} → ${usdCents} cashout Vibes for streamer ${streamerUserId}`);
}
}
@@ -87,7 +87,7 @@ async function _chatLine({ line, mirror = false, key }) {
@@ -87,7 +87,7 @@ async function _chatLine({ line, mirror = false, key }) {
return await delivery.message({ ...line, mirror, key });
return await delivery.message({ ...line, mirror, key });
}
}
-async function _handleDonation(userId, data) {
+async function _handleDonation(userId, data, { fromUserId = null } = {}) {
const alerts = require('../monetization/alerts');
const alerts = require('../monetization/alerts');
const openvibeBucks = require('../monetization/vibes');
const openvibeBucks = require('../monetization/vibes');
@@ -109,6 +109,10 @@ async function _handleDonation(userId, data) {
@@ -109,6 +109,10 @@ async function _handleDonation(userId, data) {
// 1) Donation chat event — persisted to channel history (and mirrored to global chat).
// 1) Donation chat event — persisted to channel history (and mirrored to global chat).
const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;
const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;
+ // The channel's tip history (leaderboard, recap, home stats); site-routed tips name the donor's account.
+ await require('../monetization/channel-tips').record({
+ toUserId: userId, fromUserId, username: donor, streamId, amount, source: 'powerchat', ref: eventKey || `powerchat:${crypto.randomUUID()}`,
+ });
try {
try {
await _chatLine({
await _chatLine({
mirror: true, key: eventKey,
mirror: true, key: eventKey,
@@ -444,7 +444,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
@@ -444,7 +444,7 @@ router.get('/:id/context', optionalAuth, async (req, res) => {
sound_commands: Number(chatStats.sounds) || 0,
sound_commands: Number(chatStats.sounds) || 0,
mic_moments: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id])).n, 0),
mic_moments: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM arena_mic_moments WHERE stream_id = ?', [stream.id])).n, 0),
follows_gained: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end])).n, 0),
follows_gained: await safe(async () => (await db.get('SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?', [stream.user_id, start, end])).n, 0),
- tips: await safe(async () => await db.get("SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM transactions WHERE type = 'donation' AND (stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?))", [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),
+ tips: await safe(async () => await db.get('SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS t FROM channel_tips WHERE stream_id = ? OR (to_user_id = ? AND created_at BETWEEN ? AND ?)', [stream.id, stream.user_id, start, end]), { n: 0, t: 0 }),
top_chatters: topChatters,
top_chatters: topChatters,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+/**
+ * channel_tips: every tip a channel received, as Live shows it (migrations/0009_channel_tips.sql). Recorded by the
+ * three paths that announce a tip (POST /api/funds/donate, integrations/powerchat-webhook.js, tips/delivery-routes.js)
+ * and read by the stream leaderboard, the after-stream recap, VOD pages and the home stats. Display rows, never money:
+ * Live's `transactions` or OpenVibe.Billing is the ledger.
+ */
+const db = require('../db/database');
+
+const SOURCES = new Set(['vibes', 'powerchat', 'tips']);
+
+/**
+ * Record one tip. ref is the tip's own id (a repeat keeps one row). amount null = hidden by the supporter. Never
+ * throws: a display row that failed to save must not fail the tip that was already announced.
+ */
+async function record({ toUserId, fromUserId = null, username = null, streamId = null, amount = null, source, ref }) {
+ if (!toUserId || !SOURCES.has(source) || !ref) return false;
+ const amt = amount == null ? null : Math.max(0, Math.round(Number(amount) || 0));
+ try {
+ const r = await db.run(`INSERT INTO channel_tips (to_user_id, from_user_id, username, stream_id, amount, source, ref)
+ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (ref) DO NOTHING`,
+ [Number(toUserId), fromUserId ? Number(fromUserId) : null, fromUserId ? null : (username ? String(username).slice(0, 80) : null),
+ streamId ? Number(streamId) : null, amt, source, String(ref).slice(0, 200)]);
+ return r.changes > 0;
+ } catch (e) {
+ console.warn('[channel-tips] not recorded:', e.message);
+ return false;
+ }
+}
+
+/** SQL for one supporter, with or without a Live account (alias `t` for channel_tips). */
+const SUPPORTER_KEY = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";
+
+module.exports = { record, SUPPORTER_KEY };
@@ -17,6 +17,7 @@ const openvibeBucks = require('./vibes');
@@ -17,6 +17,7 @@ const openvibeBucks = require('./vibes');
const db = require('../db/database');
const db = require('../db/database');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const { publicGoal, announceGoal } = require('./goal-announce');
const { publicGoal, announceGoal } = require('./goal-announce');
+const channelTips = require('./channel-tips');
// BILLING_AUTHORITY=billing: every money action below goes to OpenVibe.Billing instead of Live's
// 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.
// columns (billing-actions.js); money_writes_frozen refuses them in both modes.
const money = require('./money-authority');
const money = require('./money-authority');
@@ -78,6 +79,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -78,6 +79,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
// A request the browser repeated with the same Idempotency-Key: already celebrated.
// A request the browser repeated with the same Idempotency-Key: already celebrated.
if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });
if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });
+ // The channel's tip history (leaderboard, recap, home stats), whichever ledger moved the Vibes.
+ 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()}`,
+ });
+
const alerts = require('./alerts');
const alerts = require('./alerts');
const donorUser = await db.getUserById(req.user.id);
const donorUser = await db.getUserById(req.user.id);
const donor = donorUser?.display_name || donorUser?.username || 'Someone';
const donor = donorUser?.display_name || donorUser?.username || 'Someone';
@@ -249,16 +249,19 @@ class Vibes {
@@ -249,16 +249,19 @@ class Vibes {
}
}
/**
/**
- * Get donation leaderboard for a stream
+ * 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.
*/
*/
async getLeaderboard(streamId, limit = 10) {
async getLeaderboard(streamId, limit = 10) {
+ const { SUPPORTER_KEY } = require('./channel-tips');
return await db.all(`
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
+ SELECT MAX(t.from_user_id) AS from_user_id, MAX(u.username) AS username,
+ MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name, MAX(u.avatar_url) AS avatar_url,
+ SUM(t.amount)::bigint AS total_donated
+ FROM channel_tips t
+ LEFT JOIN users u ON t.from_user_id = u.id
+ WHERE t.stream_id = ? AND t.amount IS NOT NULL
+ GROUP BY ${SUPPORTER_KEY}
ORDER BY total_donated DESC
ORDER BY total_donated DESC
LIMIT ?
LIMIT ?
`, [streamId, limit]);
`, [streamId, limit]);
@@ -53,9 +53,12 @@ async function gather(streamId) {
@@ -53,9 +53,12 @@ async function gather(streamId) {
const micLines = await safe(async () => await db.all(`SELECT text, quality, kind, aimed_at, sec, vod_id FROM arena_mic_moments WHERE stream_id = ? ORDER BY quality DESC, said_at ASC LIMIT 4`, [streamId]), []);
const micLines = await safe(async () => await db.all(`SELECT text, quality, kind, aimed_at, sec, vod_id FROM arena_mic_moments WHERE stream_id = ? ORDER BY quality DESC, said_at ASC LIMIT 4`, [streamId]), []);
// Money + love.
// Money + love.
- const tips = await safe(async () => await db.get(`SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS total FROM transactions WHERE type = 'donation' AND COALESCE(status, 'completed') NOT IN ('failed', 'refunded', 'pending') AND ((stream_id = ?) OR (to_user_id = ? AND created_at BETWEEN ? AND ?))`, [streamId, stream.user_id, start, end]), { n: 0, total: 0 });
- const topTipper = await safe(async () => await db.get(`SELECT u.username, u.display_name, SUM(t.amount)::bigint AS total FROM transactions t JOIN users u ON u.id = t.from_user_id
- WHERE t.type = 'donation' AND ((t.stream_id = ?) OR (t.to_user_id = ? AND t.created_at BETWEEN ? AND ?)) GROUP BY t.from_user_id, u.id ORDER BY total DESC LIMIT 1`, [streamId, stream.user_id, start, end]), null);
+ // Tips from channel_tips: on-site Vibes, PowerChat and OpenVibe.Tips (a hidden amount counts as a tip, adds nothing).
+ const { SUPPORTER_KEY } = require('../monetization/channel-tips');
+ const tips = await safe(async () => await db.get(`SELECT COUNT(*) AS n, COALESCE(SUM(amount), 0)::bigint AS total FROM channel_tips WHERE (stream_id = ?) OR (to_user_id = ? AND created_at BETWEEN ? AND ?)`, [streamId, stream.user_id, start, end]), { n: 0, total: 0 });
+ const topTipper = await safe(async () => await db.get(`SELECT MAX(u.username) AS username, MAX(COALESCE(u.display_name, u.username, t.username)) AS display_name, SUM(t.amount)::bigint AS total
+ FROM channel_tips t LEFT JOIN users u ON u.id = t.from_user_id
+ WHERE t.amount IS NOT NULL AND ((t.stream_id = ?) OR (t.to_user_id = ? AND t.created_at BETWEEN ? AND ?)) GROUP BY ${SUPPORTER_KEY} ORDER BY total DESC LIMIT 1`, [streamId, stream.user_id, start, end]), null);
const follows = await safe(async () => (await db.get(`SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?`, [stream.user_id, start, end])).n, 0);
const follows = await safe(async () => (await db.get(`SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?`, [stream.user_id, start, end])).n, 0);
const followersNow = await safe(async () => await db.getFollowerCount(stream.user_id), null);
const followersNow = await safe(async () => await db.getFollowerCount(stream.user_id), null);
@@ -119,6 +119,10 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -119,6 +119,10 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
};
};
// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
const chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
+ if (!b.test) {
+ const supporterId = await channelUserId(b.supporter && b.supporter.subject);
+ await require('../monetization/channel-tips').record({ toUserId: userId, fromUserId: supporterId, username: name, streamId, amount, source: 'tips', ref: `tips:${key}` });
+ }
await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
await require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
// A settled tip in Vibes counts toward the channel's donation goal, like an on-site donation (a test, or a
// A settled tip in Vibes counts toward the channel's donation goal, like an on-site donation (a test, or a
// tip whose amount the supporter hid, does not). The claim above makes this once per Idempotency-Key.
// tip whose amount the supporter hid, does not). The claim above makes this once per Idempotency-Key.
@@ -131,6 +131,10 @@ async function check(name, fn) {
@@ -131,6 +131,10 @@ async function check(name, fn) {
assert.strictEqual(billing.payable[SID.bob], 150);
assert.strictEqual(billing.payable[SID.bob], 150);
const row = await raw.prepare("SELECT * FROM billing_actions WHERE idempotency_key = ?").get(c[0].key);
const row = await raw.prepare("SELECT * FROM billing_actions WHERE idempotency_key = ?").get(c[0].key);
assert.strictEqual(row.status, 'done'); assert.match(row.billing_ref, /^txn_/);
assert.strictEqual(row.status, 'done'); assert.match(row.billing_ref, /^txn_/);
+ // The channel's tip history keeps counting after the cutover (leaderboard, recap, home stats), named after
+ // Billing's transaction; Live's ledger stays untouched.
+ assert.deepStrictEqual(await raw.prepare('SELECT to_user_id, from_user_id, stream_id, amount, source FROM channel_tips WHERE ref = ?').get(`billing-txn:${row.billing_ref}`),
+ { to_user_id: 3, from_user_id: 2, stream_id: streamB, amount: 150, source: 'vibes' });
// The chat celebration is OpenVibe.Chat's now: Live sends it the line through the delivery
// The chat celebration is OpenVibe.Chat's now: Live sends it the line through the delivery
// seam and keeps no copy (test/tips-delivery.test.js covers the payload).
// seam and keeps no copy (test/tips-delivery.test.js covers the payload).
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * channel_tips (migrations/0009_channel_tips.sql, server/monetization/channel-tips.js): every tip a channel received,
+ * whatever carried it. The stream leaderboard, the home's pulse and stats read it instead of Live's `transactions`, which
+ * stops at the Billing cutover and never held PowerChat or OpenVibe.Tips tips.
+ *
+ * node --import ./test/helpers/pg-preload.mjs test/channel-tips.test.js
+ */
+const assert = require('assert');
+const fs = require('fs');
+const http = require('http');
+const path = require('path');
+
+process.env.NODE_ENV = 'test';
+delete process.env.BILLING_AUTHORITY;
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+ const addUser = async (id, username, bucks = 0) => await raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, openvibe_bucks_balance) OVERRIDING SYSTEM VALUE
+ VALUES (?, ?, ?, ?, 'x', 'user', ?)`).run(id, username, username[0].toUpperCase() + username.slice(1), `${username}@x`, bucks);
+ await addUser(2, 'ann', 2000);
+ await addUser(3, 'bob');
+ const streamId = Number((await db.run("INSERT INTO streams (user_id, title, protocol, is_live) VALUES (3, 'bob live', 'webrtc', 1) RETURNING id")).lastInsertRowid);
+
+ // Chat shows the lines (Live keeps no chat table): record what is sent instead.
+ const delivery = require('../server/chat/chat-delivery');
+ delivery.message = async () => 1;
+ delivery.event = async () => {};
+
+ 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); };
+ const express = require('express');
+ const app = express();
+ app.use(express.json());
+ app.use('/api/funds', require('../server/monetization/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const donate = async (user, body) => {
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/funds/donate`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-test-user': String(user) }, body: JSON.stringify(body) });
+ return { status: res.status, json: await res.json() };
+ };
+ 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.
+ 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' });
+
+ // 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');
+ await powerchat.creditDonationPipeline(3, { eventId: 'evt_9', amountUsdCents: 300, donorName: 'pcfan', message: '' });
+ await powerchat.creditDonationPipeline(3, { eventId: 'evt_9', amountUsdCents: 300, donorName: 'pcfan', message: '' });
+ assert.deepStrictEqual(await tip('powerchat:evt_9'), { to_user_id: 3, from_user_id: null, username: 'pcfan', stream_id: streamId, amount: 300, source: 'powerchat' });
+ // A PowerChat tip routed through the site account names the donor's Live account.
+ await powerchat.creditDonationPipeline(3, { eventId: 'evt_10', amountUsdCents: 400, donorName: 'Ann', message: '' }, { fromUserId: 2 });
+ assert.deepStrictEqual(await tip('powerchat:evt_10'), { to_user_id: 3, from_user_id: 2, username: null, stream_id: streamId, amount: 400, source: 'powerchat' });
+ // A supporter who hid the amount (OpenVibe.Tips privacy.hide_amount) tipped, but adds nothing to the sums.
+ const channelTips = require('../server/monetization/channel-tips');
+ assert.strictEqual(await channelTips.record({ toUserId: 3, username: 'shy', streamId, amount: null, source: 'tips', ref: 'tips:tint_s:chat_line' }), true);
+ assert.strictEqual(await channelTips.record({ toUserId: 3, username: 'shy', streamId, amount: null, source: 'tips', ref: 'tips:tint_s:chat_line' }), false, 'a repeat keeps one row');
+ assert.strictEqual(await channelTips.record({ toUserId: 3, amount: 5, source: 'nope', ref: 'x:1' }), false, 'an unknown source is refused');
+ assert.strictEqual(await count(), 4);
+
+ // The stream's leaderboard: Ann's on-site and PowerChat tips together, then the PowerChat supporter (no profile link).
+ const board = await require('../server/monetization/vibes').getLeaderboard(streamId);
+ assert.deepStrictEqual(board.map((r) => [r.username, r.display_name, Number(r.total_donated)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);
+
+ // The home pulse and stats count every rail.
+ const pulse = await db.getHomePulse();
+ assert.strictEqual(pulse.latestTip.from_username, 'ann', 'the newest tip with an amount');
+ assert.strictEqual(Number(pulse.latestTip.amount), 400);
+ assert.deepStrictEqual(pulse.topSupporters.map((r) => [r.username, r.display_name, Number(r.total)]), [['ann', 'Ann', 550], [null, 'pcfan', 300]]);
+ const stats = await db.getHomeStats();
+ assert.strictEqual(stats.vibesTipped, 850);
+ assert.strictEqual(stats.supporters, 3, 'ann, pcfan and the supporter who hid the amount');
+ assert.strictEqual((await db.getHomeStatSeries('supporters', 30)).total, 3);
+
+ // The migration's backfill: completed donations in the ledger become tips once; failed ones and purchases do not.
+ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0009_channel_tips.sql'), 'utf8');
+ const backfill = sql.slice(sql.indexOf('INSERT INTO channel_tips'));
+ const ok = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (2, 3, 70, 'donation', 'completed') RETURNING id")).lastInsertRowid);
+ const failed = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (2, 3, 80, 'donation', 'failed') RETURNING id")).lastInsertRowid);
+ const bought = Number((await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status) VALUES (NULL, 2, 500, 'purchase', 'completed') RETURNING id")).lastInsertRowid);
+ await raw.prepare(backfill).run();
+ await raw.prepare(backfill).run();
+ assert.deepStrictEqual(await tip(`live-txn:${ok}`), { to_user_id: 3, from_user_id: 2, username: null, stream_id: null, amount: 70, source: 'vibes' });
+ assert.strictEqual(await tip(`live-txn:${failed}`), undefined);
+ assert.strictEqual(await tip(`live-txn:${bought}`), undefined);
+ assert.strictEqual(await count(), 5, 'the donation the route already recorded is not copied again');
+
+ server.close();
+ quiet('channel tips: all checks passed');
+ process.exit(0);
+})().catch((err) => { quiet(err); process.exit(1); });
@@ -58,9 +58,10 @@ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM viewer_samples WHERE
@@ -58,9 +58,10 @@ assert.strictEqual((await db.get("SELECT COUNT(*) AS n FROM viewer_samples WHERE
console.log('✅ reading series are bucketed, null-filled and retained for 400 days');
console.log('✅ reading series are bucketed, null-filled and retained for 400 days');
// ── vibes: test money before the reset never counts ──
// ── vibes: test money before the reset never counts ──
-await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 500, 'donation', 'completed', datetime('now', '-10 days'))", [a, b]);
-await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 200, 'donation', 'completed', datetime('now', '-2 days'))", [b, a]);
-await db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 50, 'donation', 'completed', datetime('now', '-1 hours'))", [b, a]);
+// The counters read channel_tips (every tip a channel received, on any rail), not the money ledger.
+await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source, ref, created_at) VALUES (?, ?, 500, 'vibes', 'live-txn:1', datetime('now', '-10 days'))", [a, b]);
+await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source, ref, created_at) VALUES (?, ?, 200, 'vibes', 'live-txn:2', datetime('now', '-2 days'))", [b, a]);
+await db.run("INSERT INTO channel_tips (from_user_id, to_user_id, amount, source, ref, created_at) VALUES (?, ?, 50, 'vibes', 'live-txn:3', datetime('now', '-1 hours'))", [b, a]);
let stats = await db.getHomeStats();
let stats = await db.getHomeStats();
assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');
assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');
@@ -18,7 +18,8 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
@@ -18,7 +18,8 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];
chatReads.topChatters = async () => [{ username: 'fan1', display_name: 'Fan1', count: 9 }, { username: 'fan2', display_name: 'Fan2', count: 4 }];
await db.run('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)', [fan2, host, ts(30)]);
await db.run('INSERT INTO follows (follower_id, streamer_id, created_at) VALUES (?,?,?)', [fan2, host, ts(30)]);
await db.run('INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)', [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);
await db.run('INSERT INTO arena_mic_moments (user_id, stream_id, kind, text, about, quality, announcer, sec, said_at) VALUES (?,?,?,?,?,?,?,?,?)', [host, sid, 'trash', 'my soldering iron has more rizz than your whole setup', 'setup', 8, '', 1200, ts(40)]);
- await db.run('INSERT INTO transactions (from_user_id, to_user_id, stream_id, amount, type, status) VALUES (?,?,?,?,?,?)', [fan, host, sid, 5, 'donation', 'completed']);
+ await require('../server/monetization/channel-tips').record({ toUserId: host, fromUserId: fan, streamId: sid, amount: 5, source: 'vibes', ref: 'live-txn:1' });
+ await require('../server/monetization/channel-tips').record({ toUserId: host, username: 'pcfan', streamId: sid, amount: 3, source: 'powerchat', ref: 'powerchat:evt_1' });
await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);
await db.run("INSERT INTO streams (user_id, title, category, protocol, is_live, started_at, ended_at, duration_seconds, peak_viewers) VALUES (?,?,?,?,0,?,?,?,?)", [host, 'oops', 'irl', 'webrtc', ts(9), ts(5), 240, 1]);
const pend = (await recap.pending()).map(r => r.id);
const pend = (await recap.pending()).map(r => r.id);
assert.deepStrictEqual(pend, [sid], 'only the long-enough, settled stream is pending');
assert.deepStrictEqual(pend, [sid], 'only the long-enough, settled stream is pending');
@@ -31,7 +32,8 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
@@ -31,7 +32,8 @@ const ts = (minsAgo) => new Date(Date.now() - minsAgo * 60000).toISOString().rep
assert.strictEqual(r.chat.top[0].username, 'fan1', 'host excluded from top chatters, fan1 leads');
assert.strictEqual(r.chat.top[0].username, 'fan1', 'host excluded from top chatters, fan1 leads');
assert.strictEqual(r.chat.top[0].n, 9);
assert.strictEqual(r.chat.top[0].n, 9);
assert.strictEqual(r.love.follows, 1);
assert.strictEqual(r.love.follows, 1);
- assert.strictEqual(r.love.tips, 1); assert.strictEqual(r.love.tips_total, 5);
+ assert.strictEqual(r.love.tips, 2); assert.strictEqual(r.love.tips_total, 8, 'on-site and PowerChat tips both count');
+ assert.deepStrictEqual(r.love.top_tipper, { username: 'fan1', display_name: 'Fan1', total: 5 });
assert.strictEqual(r.mic.length, 1);
assert.strictEqual(r.mic.length, 1);
assert.strictEqual(r.viewers.curve.length, 12);
assert.strictEqual(r.viewers.curve.length, 12);
assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');
assert.ok(r.chat.busiest_n === 14, 'busiest 5-min window from snapshots');
@@ -81,6 +81,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -81,6 +81,8 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
assert.strictEqual(await goalNow(), 250, 'the tip counts toward the goal');
assert.strictEqual(await goalNow(), 250, 'the tip counts toward the goal');
assert.deepStrictEqual(goalUpdates(), [250], 'and the goal widget hears it');
assert.deepStrictEqual(goalUpdates(), [250], 'and the goal widget hears it');
assert.deepStrictEqual(a.json.ref.goal, { id: goalId, reached: false });
assert.deepStrictEqual(a.json.ref.goal, { id: goalId, reached: false });
+ assert.deepStrictEqual(await d.prepare("SELECT to_user_id, from_user_id, username, amount, source FROM channel_tips WHERE ref = 'tips:tint_1:chat_line'").get(),
+ { to_user_id: 501, from_user_id: null, username: 'Viewer', amount: 250, source: 'tips' }, "the channel's tip history has it");
// A retry of the same delivery changes nothing.
// A retry of the same delivery changes nothing.
const b = await post(job);
const b = await post(job);
@@ -101,6 +103,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -101,6 +103,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');
assert.deepStrictEqual(afterRestart.json, a.json, 'after a restart the same key answers the first result');
assert.strictEqual(sent.length, 1, 'and delivers nothing again');
assert.strictEqual(sent.length, 1, 'and delivers nothing again');
assert.strictEqual(await goalNow(), 250, 'nor counts toward the goal again');
assert.strictEqual(await goalNow(), 250, 'nor counts toward the goal again');
+ assert.strictEqual((await d.prepare("SELECT COUNT(*) AS n FROM channel_tips WHERE ref = 'tips:tint_1:chat_line'").get()).n, 1);
// A retry while the first attempt is still running is told to come back; a claim left by a
// A retry while the first attempt is still running is told to come back; a claim left by a
// crash (older than two minutes) is taken over.
// crash (older than two minutes) is taken over.
@@ -146,6 +149,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -146,6 +149,7 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
// A simulation (test: true) shows the line but leaves the goal alone.
// A simulation (test: true) shows the line but leaves the goal alone.
assert.strictEqual((await post({ ...job, test: true, interaction: { ...job.interaction, id: 'tint_t' } }, { key: 'tint_t:chat_line' })).status, 200);
assert.strictEqual((await post({ ...job, test: true, interaction: { ...job.interaction, id: 'tint_t' } }, { key: 'tint_t:chat_line' })).status, 200);
assert.strictEqual(await goalNow(), 500);
assert.strictEqual(await goalNow(), 500);
+ assert.strictEqual((await d.prepare("SELECT COUNT(*) AS n FROM channel_tips WHERE ref = 'tips:tint_t:chat_line'").get()).n, 0, 'nor the tip history');
// Reaching the goal: the widget shows it full, a saved "Goal reached" line in Vibes, the goal sound.
// Reaching the goal: the widget shows it full, a saved "Goal reached" line in Vibes, the goal sound.
const before = sent.length;
const before = sent.length;