openvibers/openvibe.live Commits

Home: Vibes bought and subscriptions are OpenVibe.Billing's counts (billing.stats.read); Live stops reading payment_orders, subscriptions and the old balance column (#184)

OP OpenVibers committed 27 days ago · e75d35c8d1 · 6 files, +123 −12

Changes

ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex f67e462d..06fad8cc 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -127,7 +127,7 @@ router.get('/users', async (req, res) => {
// No email here: the OpenVibe account keeps it (Network's admin finds people by address), and Live
// no longer reads users.email (WS-B task 2). Staff find people by username or display name.
-- let sql = `SELECT u.id, u.username, u.display_name, u.role, u.openvibe_bucks_balance,
++ let sql = `SELECT u.id, u.username, u.display_name, u.role,
u.is_banned, u.ban_reason, u.created_at, u.last_seen,
COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabled
FROM users u
ddiff --git a/server/db/database.js b/server/db/database.js
iindex c3061103..fcacb192 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1387,8 +1387,7 @@ const HOME_SERIES = {
// 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(*)' },
++ // vibesBought and subs are OpenVibe.Billing's series (home/routes.js, monetization/billing-stats.js).
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },
pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount)::float8, 0)', where: 'amount < 0' },
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
@@@ -1528,8 +1527,8 @@ async function _computeHomeStats() {
hoursWatched: Math.round(await c(`SELECT COALESCE(SUM(minutes_watched)::float8, 0) AS count FROM watch_time`) / 60),
// 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 subscriptions and Vibes bought are OpenVibe.Billing's (home/routes.js fills them; null until it answers).
++ activeSubs: null,
// Channel points earned by viewers across every channel (watch/chat/follow bonuses).
pointsEarned: await c(`SELECT COALESCE(SUM(amount)::float8, 0) AS count FROM coin_transactions WHERE amount > 0`),
// …and spent back on channel rewards.
@@@ -1538,8 +1537,7 @@ async function _computeHomeStats() {
redemptions: await c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),
// 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'`),
++ vibesBought: null,
// Donation goals: currently running + ever reached.
goalsActive: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
goalsReached: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE reached_at IS NOT NULL OR current_amount >= target_amount`),
@@@ -1612,8 +1610,8 @@ async function _computeHomeStats() {
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')"),
-- vibesBought: await winSum('payment_orders', 'bucks', 'updated_at', "kind = 'bucks' AND status = 'credited'"),
-- subs: await winCount('subscriptions', 'created_at'),
++ vibesBought: null,
++ subs: null,
follows: await winCount('follows', 'created_at'),
},
};
@@@ -2153,7 +2151,7 @@ async function countEasterEggSolves(eggDate) {
async function getUserProfile(userId) {
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
-- openvibe_bucks_balance, created_at, last_seen
++ created_at, last_seen
FROM users WHERE id = ?`, [userId]);
if (!user) return null;
// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex ae51b99c..5da42311 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -184,6 +184,10 @@ router.get('/stats/series/:metric', async (req, res) => {
if (m && Array.isArray(m.points)) series = { ...m, kind: 'count', peak: Math.max(0, ...m.points.map(p => p.value)), source: 'media' };
} catch (err) { console.warn('[Home] Media series unavailable:', err.message); }
if (!series) return res.status(503).json({ error: 'Media stats are unavailable right now' });
++ } else if (require('../monetization/billing-stats').METRICS[metric]) {
++ // Vibes bought and channel subscriptions: OpenVibe.Billing's (the money ledger since 2026-10-17).
++ series = await require('../monetization/billing-stats').series(metric, days);
++ if (!series) return res.status(503).json({ error: 'Billing stats are unavailable right now' });
} else if (metric === 'pastes') {
try {
const c = await require('../pastes-client').request('GET', '/admin/stats/series', { query: { days }, act: { staff: true }, timeoutMs: 5000 });
@@@ -193,7 +197,7 @@ router.get('/stats/series/:metric', async (req, res) => {
} else {
series = await db.getReadingSeries(metric, days) || await db.getHomeStatSeries(metric, days);
}
-- if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: [...db.HOME_SERIES_KEYS, ...MEDIA_SERIES, 'pastes'] });
++ if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: [...db.HOME_SERIES_KEYS, ...MEDIA_SERIES, 'pastes', 'vibesBought', 'subs'] });
if (_seriesCache.size > 200) _seriesCache.clear();
_seriesCache.set(key, { at: Date.now(), data: series });
res.json(series);
@@@ -207,10 +211,17 @@ async function heroStats(timing) {
// Media stats and the network coin ledger are independent upstream calls; they used to be awaited
// one after the other, so a cold cache paid for both in sequence.
a = Date.now();
-- const [, c] = await Promise.all([
++ const [, c, money] = await Promise.all([
lookups.withArchiveStats(stats).then(v => { mark('media_stats', a); return v; }),
require('../monetization/wallet-client').networkCoinStats().then(v => { mark('coins', a); return v; }).catch(() => null),
++ require('../monetization/billing-stats').counts().then(v => { mark('billing_stats', a); return v; }).catch(() => null),
]);
++ // Vibes bought and channel subscriptions: OpenVibe.Billing's counts (cached), empty when it does not answer.
++ if (money) {
++ stats.vibesBought = money.vibesBought;
++ stats.activeSubs = money.activeSubs;
++ stats.recent = { ...(stats.recent || {}), vibesBought: money.recent.vibesBought, subs: money.recent.subs };
++ }
// Site-wide OpenCoins sit next to channel points on the board: one is the network-wide
// currency people earn everywhere, the other is per-channel. The ledger lives on
// OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.
ddiff --git a/server/monetization/billing-client.js b/server/monetization/billing-client.js
iindex 354c755a..7f8c6e75 100644
---- a/server/monetization/billing-client.js
++++ b/server/monetization/billing-client.js
@@@ -193,6 +193,8 @@ const api = {
entitlement: async (subject, streamer, o) => await request('GET', `/entitlements/${enc(subject)}?streamer=${enc(streamer)}`, o),
// A creator's tier prices (contracts 0.201.0): Live sells tier 1, at the creator's price when they set one on VIP.
tiers: async (streamer, o) => await request('GET', `/tiers/${enc(streamer)}`, o),
++ // Network-wide money counts with no person in them, for the home (billing.stats.read, contracts 0.203.0).
++ stats: async (days = 30, o) => await request('GET', `/stats?days=${enc(days)}`, o),
/** billing.balance.read */
balance: async (subject, o) => await request('GET', `/balances/${enc(subject)}`, o),
transactions: async (subject, { limit = 50, cursor } = {}, o) => await request('GET', `/transactions?${new URLSearchParams({ subject, limit: String(limit), ...(cursor ? { cursor } : {}) })}`, o),
ddiff --git a/server/monetization/billing-stats.js b/server/monetization/billing-stats.js
nnew file mode 100644
iindex 00000000..65cbcb11
---- /dev/null
++++ b/server/monetization/billing-stats.js
@@@ -0,0 +1,57 @@
++'use strict';
++/**
++ * The home's money counts from OpenVibe.Billing (GET /api/v1/stats, billing.stats.read, openvibe-contracts 0.203.0):
++ * Vibes bought with money and channel subscriptions, network-wide and naming nobody. Live's own payment_orders and
++ * subscriptions stopped at the cutover of 2026-10-17, so they are not counted.
++ *
++ * counts() { vibesBought, activeSubs, recent: { vibesBought, subs } } (windows d/w/m/pw) or null
++ * series(metric, n) the home's series shape for 'vibesBought' or 'subs' over up to 90 days, or null
++ *
++ * One Billing read serves both, cached five minutes (a failure is cached for a minute and answers null: the board
++ * shows the tiles empty rather than a guess).
++ */
++const api = require('./billing-client');
++
++const TTL_MS = 5 * 60_000;
++const MISS_MS = 60_000;
++const SERIES_DAYS = 90;
++let cache = { at: 0, ttl: 0, value: null };
++
++async function read() {
++ const now = Date.now();
++ if (now - cache.at < cache.ttl) return cache.value;
++ let value = null;
++ try { value = await api.stats(SERIES_DAYS); } catch (e) { console.warn('[Home] Billing stats unavailable:', e.detail || e.message); }
++ cache = { at: now, ttl: value ? TTL_MS : MISS_MS, value };
++ return value;
++}
++
++async function counts() {
++ const s = await read();
++ if (!s) return null;
++ return {
++ vibesBought: s.purchases.bits_total, activeSubs: s.subscriptions.active,
++ recent: { vibesBought: s.purchases.windows, subs: s.subscriptions.windows },
++ };
++}
++
++const METRICS = { vibesBought: 'purchases', subs: 'subscriptions' };
++
++async function series(metric, days) {
++ const key = METRICS[metric];
++ if (!key) return null;
++ const s = await read();
++ if (!s) return null;
++ const n = Math.max(1, Math.min(SERIES_DAYS, days));
++ const points = s[key].series.slice(-n).map((p) => ({ day: p.day, value: p.value }));
++ const total = points.reduce((a, p) => a + p.value, 0);
++ return {
++ metric, kind: 'count', days: n, points, total, peak: Math.max(0, ...points.map((p) => p.value)),
++ // A running total of Vibes bought starts at the all-time figure; subscriptions have no such base.
++ before: key === 'purchases' ? Math.max(0, s.purchases.bits_total - total) : null, prev_total: null, source: 'billing',
++ };
++}
++
++function _reset() { cache = { at: 0, ttl: 0, value: null }; }
++
++module.exports = { counts, series, METRICS, _reset };
ddiff --git a/test/billing-stats.test.js b/test/billing-stats.test.js
nnew file mode 100644
iindex 00000000..28352c5b
---- /dev/null
++++ b/test/billing-stats.test.js
@@@ -0,0 +1,43 @@
++'use strict';
++/**
++ * The home's money counts from OpenVibe.Billing (server/monetization/billing-stats.js): Vibes bought and channel
++ * subscriptions with their windows, the two series in the home's series shape, one cached Billing read for both, and
++ * nothing (not a guess) while Billing does not answer.
++ *
++ * node test/billing-stats.test.js
++ */
++const assert = require('assert');
++
++(async () => {
++ const api = require('../server/monetization/billing-client');
++ let calls = 0;
++ let fail = false;
++ const days = Array.from({ length: 90 }, (_, i) => ({ day: new Date(Date.now() - (89 - i) * 86400000).toISOString().slice(0, 10), value: i === 89 ? 500 : (i === 88 ? 250 : 0) }));
++ api.stats = async () => {
++ calls++;
++ if (fail) throw new Error('Billing down');
++ return {
++ as_of: new Date().toISOString(),
++ purchases: { bits_total: 25000, windows: { d: 500, w: 750, m: 750, pw: 0 }, series: days },
++ subscriptions: { active: 3, windows: { d: 1, w: 2, m: 2, pw: 1 }, series: days.map((p) => ({ day: p.day, value: p.value ? 1 : 0 })) },
++ };
++ };
++ const stats = require('../server/monetization/billing-stats');
++
++ const c = await stats.counts();
++ assert.deepStrictEqual(c, { vibesBought: 25000, activeSubs: 3, recent: { vibesBought: { d: 500, w: 750, m: 750, pw: 0 }, subs: { d: 1, w: 2, m: 2, pw: 1 } } });
++ const s = await stats.series('vibesBought', 7);
++ assert.strictEqual(s.points.length, 7);
++ assert.deepStrictEqual([s.metric, s.kind, s.total, s.peak, s.before, s.source], ['vibesBought', 'count', 750, 500, 24250, 'billing']);
++ const subs = await stats.series('subs', 365);
++ assert.strictEqual(subs.days, 90, 'Billing keeps 90 days of series');
++ assert.strictEqual(subs.before, null);
++ assert.strictEqual(await stats.series('vibes', 7), null, 'not a Billing metric');
++ assert.strictEqual(calls, 1, 'one cached Billing read serves the counts and both series');
++
++ stats._reset();
++ fail = true;
++ assert.strictEqual(await stats.counts(), null, 'Billing down: nothing, not a guess');
++ assert.strictEqual(await stats.series('subs', 30), null);
++ console.log('billing stats: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });