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)
Changes
@@@ -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_disabledFROM users u@@@ -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_seenFROM 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@@@ -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.@@@ -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),nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -127,7 +127,7 @@ router.get('/users', async (req, res) => {
@@ -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 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.
// 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,
u.is_banned, u.ban_reason, u.created_at, u.last_seen,
COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabled
COALESCE(c.force_vod_recording_disabled, 0) AS force_vod_recording_disabled
FROM users u
FROM users u
@@ -1387,8 +1387,7 @@ const HOME_SERIES = {
@@ -1387,8 +1387,7 @@ const HOME_SERIES = {
// Tips from channel_tips (every rail: on-site Vibes, PowerChat, OpenVibe.Tips), not the money ledger.
// 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 },
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 },
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' },
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' },
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')" },
redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
@@ -1528,8 +1527,8 @@ async function _computeHomeStats() {
@@ -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),
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).
// 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]),
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).
// 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`),
pointsEarned: await c(`SELECT COALESCE(SUM(amount)::float8, 0) AS count FROM coin_transactions WHERE amount > 0`),
// …and spent back on channel rewards.
// …and spent back on channel rewards.
@@ -1538,8 +1537,7 @@ async function _computeHomeStats() {
@@ -1538,8 +1537,7 @@ async function _computeHomeStats() {
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 someone.
// Distinct people who have tipped someone.
supporters: await c(`SELECT COUNT(DISTINCT ${TIP_SUPPORTER}) AS count FROM channel_tips WHERE created_at >= ?`, [vibesSince]),
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.
// Donation goals: currently running + ever reached.
goalsActive: await c(`SELECT COUNT(*) AS count FROM donation_goals WHERE is_active = 1`),
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`),
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() {
@@ -1612,8 +1610,8 @@ async function _computeHomeStats() {
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')"),
- 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'),
follows: await winCount('follows', 'created_at'),
},
},
};
};
@@ -2153,7 +2151,7 @@ async function countEasterEggSolves(eggDate) {
@@ -2153,7 +2151,7 @@ async function countEasterEggSolves(eggDate) {
async function getUserProfile(userId) {
async function getUserProfile(userId) {
const user = await get(`SELECT id, username, display_name, avatar_url, profile_color, role,
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]);
FROM users WHERE id = ?`, [userId]);
if (!user) return null;
if (!user) return null;
// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
// The user's chat total is OpenVibe.Chat's; a synchronous peek answers the last good count, else
@@ -184,6 +184,10 @@ router.get('/stats/series/:metric', async (req, res) => {
@@ -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' };
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); }
} catch (err) { console.warn('[Home] Media series unavailable:', err.message); }
if (!series) return res.status(503).json({ error: 'Media stats are unavailable right now' });
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') {
} else if (metric === 'pastes') {
try {
try {
const c = await require('../pastes-client').request('GET', '/admin/stats/series', { query: { days }, act: { staff: true }, timeoutMs: 5000 });
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) => {
@@ -193,7 +197,7 @@ router.get('/stats/series/:metric', async (req, res) => {
} else {
} else {
series = await db.getReadingSeries(metric, days) || await db.getHomeStatSeries(metric, days);
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();
if (_seriesCache.size > 200) _seriesCache.clear();
_seriesCache.set(key, { at: Date.now(), data: series });
_seriesCache.set(key, { at: Date.now(), data: series });
res.json(series);
res.json(series);
@@ -207,10 +211,17 @@ async function heroStats(timing) {
@@ -207,10 +211,17 @@ async function heroStats(timing) {
// Media stats and the network coin ledger are independent upstream calls; they used to be awaited
// 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.
// one after the other, so a cold cache paid for both in sequence.
a = Date.now();
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; }),
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/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
// 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
// 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.
// OpenVibe.Network, so this is a cached internal call that is allowed to come back empty.
@@ -193,6 +193,8 @@ const api = {
@@ -193,6 +193,8 @@ const api = {
entitlement: async (subject, streamer, o) => await request('GET', `/entitlements/${enc(subject)}?streamer=${enc(streamer)}`, o),
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.
// 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),
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 */
/** billing.balance.read */
balance: async (subject, o) => await request('GET', `/balances/${enc(subject)}`, o),
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),
transactions: async (subject, { limit = 50, cursor } = {}, o) => await request('GET', `/transactions?${new URLSearchParams({ subject, limit: String(limit), ...(cursor ? { cursor } : {}) })}`, o),
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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); });