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)

OP OpenVibers committed 27 days ago · ef791e9e9a · 15 files, +240 −33

Changes

ddiff --git a/migrations/0009_channel_tips.sql b/migrations/0009_channel_tips.sql
nnew file mode 100644
iindex 00000000..4fb51fa7
---- /dev/null
++++ b/migrations/0009_channel_tips.sql
@@@ -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;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 8e245a88..2c57cbba 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1319,12 +1319,12 @@ async function getHomePulse() {
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`), []),
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 t
LEFT JOIN users fu ON fu.id = t.from_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,
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_id
ORDER 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 total
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')
@@@ -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')"),
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex c21c92ec..a80ea0ab 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -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}`);
}
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 4ce68995..faaf8935 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -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,
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 65dd53c7..5bbf1979 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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,
};
}
ddiff --git a/server/monetization/channel-tips.js b/server/monetization/channel-tips.js
nnew file mode 100644
iindex 00000000..20e04aa6
---- /dev/null
++++ b/server/monetization/channel-tips.js
@@@ -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 };
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex b335cc3e..229bdbef 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -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';
ddiff --git a/server/monetization/vibes.js b/server/monetization/vibes.js
iindex 60f3e39f..92398f07 100644
---- a/server/monetization/vibes.js
++++ b/server/monetization/vibes.js
@@@ -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 DESC
LIMIT ?
`, [streamId, limit]);
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
iindex 4f3f35cf..c57a395c 100644
---- a/server/recap/recap.js
++++ b/server/recap/recap.js
@@@ -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);
ddiff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
iindex aacd7f67..0adad113 100644
---- a/server/tips/delivery-routes.js
++++ b/server/tips/delivery-routes.js
@@@ -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.
ddiff --git a/test/billing-authority.test.js b/test/billing-authority.test.js
iindex 05c95e5d..aba05101 100644
---- a/test/billing-authority.test.js
++++ b/test/billing-authority.test.js
@@@ -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).
});
ddiff --git a/test/channel-tips.test.js b/test/channel-tips.test.js
nnew file mode 100644
iindex 00000000..4f58e6c8
---- /dev/null
++++ b/test/channel-tips.test.js
@@@ -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); });
ddiff --git a/test/home-stats-series.test.js b/test/home-stats-series.test.js
iindex a2fba7b5..d31f7aae 100644
---- a/test/home-stats-series.test.js
++++ b/test/home-stats-series.test.js
@@@ -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');
// ── 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');
ddiff --git a/test/recap.test.js b/test/recap.test.js
iindex 2412f083..386a9568 100644
---- a/test/recap.test.js
++++ b/test/recap.test.js
@@@ -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 }];
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().rep
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.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');
ddiff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
iindex 91e237b4..6dd1a5ed 100644
---- a/test/tips-delivery.test.js
++++ b/test/tips-delivery.test.js
@@@ -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.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({ iss
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(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;