openvibers/openvibe.live Commits
Vibes history: the reason is a sentence, not a ledger type (#767)
Changes
@@@ -83,6 +83,7 @@.bucks-log-row:hover { background: var(--bg-tertiary); }.bucks-log-icon { width: 34px; height: 34px; flex: none; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }.bucks-log-label { font-size: 0.9rem; font-weight: 600; }++.bucks-log-label a { color: inherit; text-decoration: none; border-bottom: 1px solid currentColor; }.bucks-log-when { font-size: 0.76rem; color: var(--text-muted); }.bucks-log-note { font-size: 0.78rem; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.bucks-log-amt { font-weight: 700; font-size: 0.92rem; white-space: nowrap; }@@@ -869,17 +869,22 @@ function _bucksTxRow(t) {const dt = new Date(t.created_at + (String(t.created_at).includes('Z') ? '' : 'Z'));const when = isNaN(dt) ? (t.created_at || '') : dt.toLocaleString();const amt = Math.round(parseFloat(t.amount || 0));-- let icon = 'fa-coins', label = t.type || 'transaction', sign = '', cls = '';-- if (t.type === 'purchase') { icon = 'fa-cart-shopping'; label = 'Purchased Vibes'; sign = '+'; cls = 'pos'; }-- else if (t.type === 'donation' && t.from_user_id === me) { icon = 'fa-hand-holding-heart'; label = `Tipped ${esc(t.to_display || t.to_username || 'a streamer')}`; sign = '−'; cls = 'neg'; }-- else if (t.type === 'donation') { icon = 'fa-gift'; label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }-- else if (t.type === 'cashout') { icon = 'fa-money-bill-transfer'; label = 'Cashout requested'; sign = '−'; cls = 'neg'; }-- else if (t.type === 'refund') { icon = 'fa-rotate-left'; label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }-- else if (t.type === 'recycle') { icon = 'fa-recycle'; label = 'Moved cashout → spendable'; sign = ''; cls = ''; }++ // The label is the sentence billing.money-reason@1 made on the server ("for a tip to @alice");++ // the type only chooses the icon and the sign. A ledger key never reaches the page.++ let icon = 'fa-coins', label = t.label || 'transaction', sign = '', cls = '';++ if (t.type === 'purchase') { icon = 'fa-cart-shopping'; if (!t.label) label = 'Purchased Vibes'; sign = '+'; cls = 'pos'; }++ else if (t.type === 'donation' && t.from_user_id === me) { icon = 'fa-hand-holding-heart'; if (!t.label) label = `Tipped ${esc(t.to_display || t.to_username || 'a streamer')}`; sign = '−'; cls = 'neg'; }++ else if (t.type === 'donation') { icon = 'fa-gift'; if (!t.label) label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }++ else if (t.type === 'cashout' || t.type === 'cashout_request') { icon = 'fa-money-bill-transfer'; if (!t.label) label = 'Cashout requested'; sign = '−'; cls = 'neg'; }++ else if (t.type === 'refund') { icon = 'fa-rotate-left'; if (!t.label) label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }++ else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout → spendable'; sign = ''; cls = ''; }++ const url = t.subject && t.subject.url;++ const text = url && /^https:\/\//.test(url)++ ? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label);const note = t.message ? `<div class="bucks-log-note">${esc(t.message)}</div>` : '';return `<div class="bucks-log-row"><div class="bucks-log-icon"><i class="fa-solid ${icon}"></i></div>-- <div class="bucks-log-main"><div class="bucks-log-label">${label}</div><div class="bucks-log-when">${esc(when)}</div>${note}</div>++ <div class="bucks-log-main"><div class="bucks-log-label">${text}</div><div class="bucks-log-when">${esc(when)}</div>${note}</div><div class="bucks-log-amt ${cls}">${sign}${amt.toLocaleString()} Vibes</div></div>`;}@@@ -28,6 +28,7 @@ const db = require('../db/database');const config = require('../config');const billing = require('./billing-client');const { assertionFor } = require('../net/subject-assertion');++const money = require('../money/reasons');const { BillingCallError, subjectFor, ref, api } = billing;const base = () => config.baseUrl.replace(/\/+$/, '');@@@ -247,6 +248,8 @@ async function balance(liveUserId, trace) {}const LIVE_TYPE = { cashout_request: 'cashout', cashout_denied: 'refund', cashout_paid: 'cashout_paid' };++// Live's transaction type in the shared reason list (billing.money-reason@1).++const LIVE_REASON = { purchase: 'purchase:vibes', refund: 'refund', cashout_request: 'cashout_request', cashout_denied: 'cashout_denied', cashout_paid: 'cashout_paid', recycle: 'recycle', subscription: 'subscription', subscription_share: 'subscription_share' };/** GET /api/funds/history rows in Live's transaction shape (the dashboard renders these). */async function history(liveUserId, limit = 50, trace = null) {const sid = await subjectFor(liveUserId);@@@ -259,9 +262,15 @@ async function history(liveUserId, limit = 50, trace = null) {const amount = m.amount_bits ?? m.cost_bits ?? m.share_bits ?? m.bits ?? mine.reduce((a, e) => Math.max(a, Math.abs(e.amount)), 0);const from = people.get(t.from_subject);const to = people.get(t.to_subject);++ // The public words for this row (billing.money-reason@1), made here rather than in the browser.++ const key = t.type === 'donation' ? (t.from_subject === sid ? 'donation' : 'donation:received') : (LIVE_REASON[t.type] || t.type);++ const other = t.type === 'donation' ? (t.from_subject === sid ? to : from) : null;++ const name = other && other.username ? `@${other.username}` : null;++ const d = money.describe(key, { site: 'live', subject: name ? { name, url: `https://openvibe.live/@${encodeURIComponent(other.username)}` } : null });return {id: t.id, type: LIVE_TYPE[t.type] || t.type, status: t.status, amount, created_at: t.created_at,message: m.message || null,++ label: d.text, subject: d.subject, reason_key: d.key,from_user_id: from ? from.id : (t.from_subject === sid ? liveUserId : null), to_user_id: to ? to.id : (t.to_subject === sid ? liveUserId : null),from_username: from ? from.username : null, from_display: from ? from.display_name : null,to_username: to ? to.username : null, to_display: to ? to.display_name : null,nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++/**++ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so Live answers with the plain words++ * before its installed contract package carries the module. reasons.js prefers the installed copy when it is there.++ *++ * Money reasons: the plain words for a ledger row (2026-11-10).++ *++ * A ledger holds a key, never a sentence: OpenCoins rows carry `reason` ("quest:publish-a-video"),++ * Vibes/Balance rows carry a type and metadata. This list is the one place each kind is declared:++ * the key it matches, the site it belongs to, what its subject is (a quest, a stream, a tip, a++ * creator, an app, a project, a purchase), the phrase that names it, and how the subject's own++ * name and address are found.++ *++ * `describe(key, options)` makes `{ key, text, subject: { name, url } | null }`. `text` is the++ * phrase without the amount ("for the quest Publish a video"); the page writes++ * "+15 OpenCoins" before it and links the phrase to subject.url. A key nobody declared answers++ * "for something on <site>" and is marked known: false, so a caller logs it once and never shows++ * the key itself.++ *++ * `options.resolve` maps a subject type to a lookup: `{ quest: (id) => ({ name, url }) }`.++ * `options.subject` is an already-found subject (a tip's creator, a project). `options.site`++ * names the site for an undeclared key.++ */++++/** The sites a key can belong to, in the words a person reads. */++const SITES = Object.freeze({++ quest: 'OpenVibe.Quest',++ fun: 'OpenVibe.Fun',++ live: 'OpenVibe.Live',++ network: 'OpenVibe.Network',++ billing: 'OpenVibe',++ inventory: 'OpenVibe.Inventory',++});++++/**++ * One entry per reason kind.++ * match RegExp whose first group is the subject's id or name (or an exact key string)++ * site SITES key, for an undeclared key and for callers that show provenance++ * subject the subject type `options.resolve` can look up; omitted when there is none++ * text the phrase; `{name}` is where the subject's name goes++ * fallback the name when the subject cannot be found ("a creator", "an item")++ * path the subject's address; `{id}` is the matched group++ * catalog where the subject's own names live (a declaration for the resolver)++ */++const KINDS = Object.freeze([++ // ── OpenCoins: Network's coin_transactions.reason ───────────────────────────────────────────++ Object.freeze({ id: 'quest', match: /^quest:([a-z0-9-]+)$/i, site: 'quest', subject: 'quest', text: 'for the quest {name}',++ path: 'https://openvibe.quest/quests/{id}', catalog: 'https://openvibe.quest/api/v1/quests' }),++ Object.freeze({ id: 'fun-quest', match: /^fun:([a-z0-9-]+)$/i, site: 'fun', subject: 'fun-quest', text: 'for the quest {name}',++ path: 'https://openvibe.fun/q/{id}', catalog: 'https://openvibe.fun/api/v1/quests' }),++ Object.freeze({ id: 'media-request', match: /^Media request: (.+)$/, site: 'live', subject: 'media', text: 'for the stream {name}' }),++ Object.freeze({ id: 'media-refund', match: /^Refund: (.+)$/, site: 'live', subject: 'media', text: 'for a refund of {name}' }),++ Object.freeze({ id: 'migration', match: 'migration:hobo-coins', site: 'network', text: 'for OpenCoins moved from the old Live wallet' }),++ Object.freeze({ id: 'account-merge', match: 'account_merge', site: 'network', text: 'for merging your accounts' }),++ Object.freeze({ id: 'account-deleted', match: 'account_deleted', site: 'network', text: 'for closing your account' }),++ Object.freeze({ id: 'watch', match: 'watch', site: 'live', text: 'for watching a stream' }),++ Object.freeze({ id: 'chat', match: 'chat', site: 'live', text: 'for chatting on a stream' }),++ Object.freeze({ id: 'follow', match: 'follow', site: 'live', text: 'for following a channel' }),++ Object.freeze({ id: 'bonus-game', match: 'bonus game', site: 'live', text: 'for winning the bonus game' }),++ Object.freeze({ id: 'admin-grant', match: 'Admin grant', site: 'network', text: 'for OpenCoins from OpenVibe' }),++++ // ── Vibes / Balance: Billing's transaction type + metadata ──────────────────────────────────++ Object.freeze({ id: 'ai-usage', match: 'credit:settle', site: 'billing', text: 'for AI usage' }),++ Object.freeze({ id: 'topup', match: 'credit:topup', site: 'billing', text: 'for adding money to your Balance' }),++ Object.freeze({ id: 'vibes-from-balance', match: 'purchase:vibes_from_balance', site: 'billing', text: 'for buying Vibes with your Balance' }),++ Object.freeze({ id: 'vibes-bought', match: 'purchase:vibes', site: 'billing', text: 'for buying Vibes' }),++ Object.freeze({ id: 'store-purchase', match: 'purchase:store', site: 'inventory', subject: 'item', text: 'for buying {name}', fallback: 'an item' }),++ Object.freeze({ id: 'app-payment', match: 'vibes_charge', site: 'billing', subject: 'app', text: 'for paying {name}', fallback: 'an app' }),++ Object.freeze({ id: 'app-purchase', match: 'purchase:app', site: 'billing', subject: 'app', text: 'for paying {name}', fallback: 'an app' }),++ Object.freeze({ id: 'tip', match: 'donation', site: 'billing', subject: 'creator', text: 'for a tip to {name}', fallback: 'a creator' }),++ Object.freeze({ id: 'tip-received', match: 'donation:received', site: 'billing', subject: 'creator', text: 'for a tip from {name}', fallback: 'someone' }),++ Object.freeze({ id: 'subscription', match: 'subscription', site: 'billing', subject: 'creator', text: 'for a subscription to {name}', fallback: 'a creator' }),++ Object.freeze({ id: 'subscription-share', match: 'subscription_share', site: 'billing', subject: 'creator', text: 'for a subscription to {name}', fallback: 'a creator' }),++ Object.freeze({ id: 'refund', match: 'refund', site: 'billing', text: 'for a refund' }),++ Object.freeze({ id: 'vibes-refund', match: 'vibes_refund', site: 'billing', text: 'for a refund of Vibes' }),++ Object.freeze({ id: 'move-to-project', match: 'balance_transfer', site: 'billing', subject: 'project', text: 'for moving money to {name}', fallback: 'a project' }),++ Object.freeze({ id: 'cashout-request', match: 'cashout_request', site: 'billing', text: 'for cashing out' }),++ Object.freeze({ id: 'cashout-paid', match: 'cashout_paid', site: 'billing', text: 'for a payout' }),++ Object.freeze({ id: 'cashout-denied', match: 'cashout_denied', site: 'billing', text: 'for a cashout that was denied' }),++ Object.freeze({ id: 'recycle', match: 'recycle', site: 'billing', text: 'for moving your cashout balance back to Vibes' }),++ Object.freeze({ id: 'adjustment', match: 'adjustment', site: 'billing', text: 'for an adjustment by OpenVibe' }),++]);++++/** "live-setup-slot" → "Live setup slot": the name a slug gives when its catalog is not at hand. */++function titleFromSlug(slug) {++ const words = String(slug == null ? '' : slug).replace(/[-_]+/g, ' ').trim();++ return words ? words.charAt(0).toUpperCase() + words.slice(1) : '';++}++++function siteName(id) {++ return SITES[String(id || '').toLowerCase()] || 'OpenVibe';++}++++function subjectFrom(value, kind, options) {++ if (options.subject && typeof options.subject === 'object' && options.subject.name) {++ return { name: String(options.subject.name).slice(0, 160), url: options.subject.url ? String(options.subject.url).slice(0, 500) : null };++ }++ let found = null;++ if (value != null && kind.subject && options.resolve && typeof options.resolve[kind.subject] === 'function') {++ try { found = options.resolve[kind.subject](value); } catch { found = null; }++ }++ if (typeof found === 'string') found = { name: found };++ const name = found && found.name ? String(found.name).slice(0, 160) : (kind.fallback || titleFromSlug(value));++ const url = found && found.url ? String(found.url).slice(0, 500) : (kind.path && value != null ? kind.path.replace('{id}', encodeURIComponent(value)) : null);++ if (!name) return null;++ return { name, url: url || null };++}++++/**++ * The plain words for a ledger reason.++ * @returns {{ key: string, text: string, subject: { name: string, url: string|null }|null, site: string, known: boolean }}++ */++function describe(key, options = {}) {++ const raw = key == null ? '' : String(key).trim();++ let kind = null;++ let value = null;++ for (const entry of KINDS) {++ if (typeof entry.match === 'string') {++ if (raw === entry.match) { kind = entry; break; }++ continue;++ }++ const m = raw.match(entry.match);++ if (m) { kind = entry; value = m[1] == null ? null : m[1].trim(); break; }++ }++ if (!kind) {++ return { key: raw, text: `for something on ${siteName(options.site || raw.split(':')[0])}`, subject: null,++ site: siteName(options.site || raw.split(':')[0]), known: false };++ }++ const subject = kind.subject ? subjectFrom(value, kind, options) : null;++ const text = kind.text.indexOf('{name}') < 0 ? kind.text++ : kind.text.replace('{name}', subject ? subject.name : (kind.fallback || 'something'));++ return { key: raw, text, subject, site: siteName(kind.site), known: true };++}++++/** A row from a service that keeps the raw key: `{ reason, app_id }` or `{ reason }`. */++function describeRow(row, options = {}) {++ const reason = row && (row.reason != null ? row.reason : row.reason_key);++ return describe(reason, { ...options, site: options.site || (row && (row.app_id || row.site)) || undefined });++}++++/** What an undeclared key reads, for a test and for a caller that wants only the words. */++function unknownText(site) {++ return `for something on ${siteName(site)}`;++}++++module.exports = { KINDS, SITES, describe, describeRow, unknownText, siteName, titleFromSlug };nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++++/**++ * The plain words for a money row. The list and the formatter live in openvibe-contracts++ * (`billing.money-reason@1`, `lib/money-reasons.js`); Live re-exports them when the installed++ * package has them and otherwise answers from the same list copied at 0.392.0.++ */++// The list is openvibe-contracts/lib/money-reasons.js; copied at 0.392.0 until Live's pin carries the module.++const reasons = require('./reasons-list');++++const unknown = new Set();++function describe(key, options) {++ const d = reasons.describe(key, options);++ if (!d.known && d.key && !unknown.has(d.key)) {++ unknown.add(d.key);++ console.warn(`[Money] undeclared reason key ${JSON.stringify(d.key.slice(0, 120))} — reads "${d.text}"`);++ }++ return d;++}++++module.exports = { ...reasons, describe };@@@ -179,6 +179,8 @@ async function check(name, fn) {assert.strictEqual(h.status, 200, h.text);const d = h.json.transactions.find((t) => t.type === 'donation');assert.strictEqual(d.from_user_id, 2); assert.strictEqual(d.to_username, 'bob');++ assert.strictEqual(d.label, 'for a tip to @bob', 'the reason is a sentence, not a type');++ assert.strictEqual(d.subject.url, 'https://openvibe.live/@bob', 'and links to the creator');});await check('cashout request → POST /cashouts (cashout.request); approving/denying is not Live\'s any more', async () => {nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++// Live's Vibes history words (billing.money-reason@1): a sentence, never a ledger key.++const assert = require('assert');++const money = require('../server/money/reasons');++++let n = 0;++const ok = (cond, msg) => { assert.ok(cond, msg); n++; };++++const tip = money.describe('donation', { site: 'live', subject: { name: '@alice' } });++ok(tip.text === 'for a tip to @alice', 'a tip names the creator');++ok(tip.subject.name === '@alice', 'and carries them as its subject');++++const received = money.describe('donation:received', { site: 'live', subject: { name: '@bob' } });++ok(received.text === 'for a tip from @bob', 'a received tip names the sender');++++ok(money.describe('purchase:vibes').text === 'for buying Vibes', 'a purchase is plain words');++ok(money.describe('cashout_request').text === 'for cashing out', 'a cashout is plain words');++ok(money.describe('recycle').text === 'for moving your cashout balance back to Vibes', 'a recycle is plain words');++++const unknown = money.describe('nobody:declared-this', { site: 'live' });++ok(!unknown.known && unknown.text === 'for something on OpenVibe.Live', 'an undeclared key never shows itself');++++console.log(`openvibe-live money reasons: ${n} checks passed`);
@@ -83,6 +83,7 @@
@@ -83,6 +83,7 @@
.bucks-log-row:hover { background: var(--bg-tertiary); }
.bucks-log-row:hover { background: var(--bg-tertiary); }
.bucks-log-icon { width: 34px; height: 34px; flex: none; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
.bucks-log-icon { width: 34px; height: 34px; flex: none; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center; color: var(--text-secondary); }
.bucks-log-label { font-size: 0.9rem; font-weight: 600; }
.bucks-log-label { font-size: 0.9rem; font-weight: 600; }
+.bucks-log-label a { color: inherit; text-decoration: none; border-bottom: 1px solid currentColor; }
.bucks-log-when { font-size: 0.76rem; color: var(--text-muted); }
.bucks-log-when { font-size: 0.76rem; color: var(--text-muted); }
.bucks-log-note { font-size: 0.78rem; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bucks-log-note { font-size: 0.78rem; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bucks-log-amt { font-weight: 700; font-size: 0.92rem; white-space: nowrap; }
.bucks-log-amt { font-weight: 700; font-size: 0.92rem; white-space: nowrap; }
@@ -869,17 +869,22 @@ function _bucksTxRow(t) {
@@ -869,17 +869,22 @@ function _bucksTxRow(t) {
const dt = new Date(t.created_at + (String(t.created_at).includes('Z') ? '' : 'Z'));
const dt = new Date(t.created_at + (String(t.created_at).includes('Z') ? '' : 'Z'));
const when = isNaN(dt) ? (t.created_at || '') : dt.toLocaleString();
const when = isNaN(dt) ? (t.created_at || '') : dt.toLocaleString();
const amt = Math.round(parseFloat(t.amount || 0));
const amt = Math.round(parseFloat(t.amount || 0));
- let icon = 'fa-coins', label = t.type || 'transaction', sign = '', cls = '';
- if (t.type === 'purchase') { icon = 'fa-cart-shopping'; label = 'Purchased Vibes'; sign = '+'; cls = 'pos'; }
- else if (t.type === 'donation' && t.from_user_id === me) { icon = 'fa-hand-holding-heart'; label = `Tipped ${esc(t.to_display || t.to_username || 'a streamer')}`; sign = '−'; cls = 'neg'; }
- else if (t.type === 'donation') { icon = 'fa-gift'; label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }
- else if (t.type === 'cashout') { icon = 'fa-money-bill-transfer'; label = 'Cashout requested'; sign = '−'; cls = 'neg'; }
- else if (t.type === 'refund') { icon = 'fa-rotate-left'; label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }
- else if (t.type === 'recycle') { icon = 'fa-recycle'; label = 'Moved cashout → spendable'; sign = ''; cls = ''; }
+ // The label is the sentence billing.money-reason@1 made on the server ("for a tip to @alice");
+ // the type only chooses the icon and the sign. A ledger key never reaches the page.
+ let icon = 'fa-coins', label = t.label || 'transaction', sign = '', cls = '';
+ if (t.type === 'purchase') { icon = 'fa-cart-shopping'; if (!t.label) label = 'Purchased Vibes'; sign = '+'; cls = 'pos'; }
+ else if (t.type === 'donation' && t.from_user_id === me) { icon = 'fa-hand-holding-heart'; if (!t.label) label = `Tipped ${esc(t.to_display || t.to_username || 'a streamer')}`; sign = '−'; cls = 'neg'; }
+ else if (t.type === 'donation') { icon = 'fa-gift'; if (!t.label) label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }
+ else if (t.type === 'cashout' || t.type === 'cashout_request') { icon = 'fa-money-bill-transfer'; if (!t.label) label = 'Cashout requested'; sign = '−'; cls = 'neg'; }
+ else if (t.type === 'refund') { icon = 'fa-rotate-left'; if (!t.label) label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }
+ else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout → spendable'; sign = ''; cls = ''; }
+ const url = t.subject && t.subject.url;
+ const text = url && /^https:\/\//.test(url)
+ ? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label);
const note = t.message ? `<div class="bucks-log-note">${esc(t.message)}</div>` : '';
const note = t.message ? `<div class="bucks-log-note">${esc(t.message)}</div>` : '';
return `<div class="bucks-log-row">
return `<div class="bucks-log-row">
<div class="bucks-log-icon"><i class="fa-solid ${icon}"></i></div>
<div class="bucks-log-icon"><i class="fa-solid ${icon}"></i></div>
- <div class="bucks-log-main"><div class="bucks-log-label">${label}</div><div class="bucks-log-when">${esc(when)}</div>${note}</div>
+ <div class="bucks-log-main"><div class="bucks-log-label">${text}</div><div class="bucks-log-when">${esc(when)}</div>${note}</div>
<div class="bucks-log-amt ${cls}">${sign}${amt.toLocaleString()} Vibes</div>
<div class="bucks-log-amt ${cls}">${sign}${amt.toLocaleString()} Vibes</div>
</div>`;
</div>`;
}
}
@@ -28,6 +28,7 @@ const db = require('../db/database');
@@ -28,6 +28,7 @@ const db = require('../db/database');
const config = require('../config');
const config = require('../config');
const billing = require('./billing-client');
const billing = require('./billing-client');
const { assertionFor } = require('../net/subject-assertion');
const { assertionFor } = require('../net/subject-assertion');
+const money = require('../money/reasons');
const { BillingCallError, subjectFor, ref, api } = billing;
const { BillingCallError, subjectFor, ref, api } = billing;
const base = () => config.baseUrl.replace(/\/+$/, '');
const base = () => config.baseUrl.replace(/\/+$/, '');
@@ -247,6 +248,8 @@ async function balance(liveUserId, trace) {
@@ -247,6 +248,8 @@ async function balance(liveUserId, trace) {
}
}
const LIVE_TYPE = { cashout_request: 'cashout', cashout_denied: 'refund', cashout_paid: 'cashout_paid' };
const LIVE_TYPE = { cashout_request: 'cashout', cashout_denied: 'refund', cashout_paid: 'cashout_paid' };
+// Live's transaction type in the shared reason list (billing.money-reason@1).
+const LIVE_REASON = { purchase: 'purchase:vibes', refund: 'refund', cashout_request: 'cashout_request', cashout_denied: 'cashout_denied', cashout_paid: 'cashout_paid', recycle: 'recycle', subscription: 'subscription', subscription_share: 'subscription_share' };
/** GET /api/funds/history rows in Live's transaction shape (the dashboard renders these). */
/** GET /api/funds/history rows in Live's transaction shape (the dashboard renders these). */
async function history(liveUserId, limit = 50, trace = null) {
async function history(liveUserId, limit = 50, trace = null) {
const sid = await subjectFor(liveUserId);
const sid = await subjectFor(liveUserId);
@@ -259,9 +262,15 @@ async function history(liveUserId, limit = 50, trace = null) {
@@ -259,9 +262,15 @@ async function history(liveUserId, limit = 50, trace = null) {
const amount = m.amount_bits ?? m.cost_bits ?? m.share_bits ?? m.bits ?? mine.reduce((a, e) => Math.max(a, Math.abs(e.amount)), 0);
const amount = m.amount_bits ?? m.cost_bits ?? m.share_bits ?? m.bits ?? mine.reduce((a, e) => Math.max(a, Math.abs(e.amount)), 0);
const from = people.get(t.from_subject);
const from = people.get(t.from_subject);
const to = people.get(t.to_subject);
const to = people.get(t.to_subject);
+ // The public words for this row (billing.money-reason@1), made here rather than in the browser.
+ const key = t.type === 'donation' ? (t.from_subject === sid ? 'donation' : 'donation:received') : (LIVE_REASON[t.type] || t.type);
+ const other = t.type === 'donation' ? (t.from_subject === sid ? to : from) : null;
+ const name = other && other.username ? `@${other.username}` : null;
+ const d = money.describe(key, { site: 'live', subject: name ? { name, url: `https://openvibe.live/@${encodeURIComponent(other.username)}` } : null });
return {
return {
id: t.id, type: LIVE_TYPE[t.type] || t.type, status: t.status, amount, created_at: t.created_at,
id: t.id, type: LIVE_TYPE[t.type] || t.type, status: t.status, amount, created_at: t.created_at,
message: m.message || null,
message: m.message || null,
+ label: d.text, subject: d.subject, reason_key: d.key,
from_user_id: from ? from.id : (t.from_subject === sid ? liveUserId : null), to_user_id: to ? to.id : (t.to_subject === sid ? liveUserId : null),
from_user_id: from ? from.id : (t.from_subject === sid ? liveUserId : null), to_user_id: to ? to.id : (t.to_subject === sid ? liveUserId : null),
from_username: from ? from.username : null, from_display: from ? from.display_name : null,
from_username: from ? from.username : null, from_display: from ? from.display_name : null,
to_username: to ? to.username : null, to_display: to ? to.display_name : null,
to_username: to ? to.username : null, to_display: to ? to.display_name : null,
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so Live answers with the plain words
+ * before its installed contract package carries the module. reasons.js prefers the installed copy when it is there.
+ *
+ * Money reasons: the plain words for a ledger row (2026-11-10).
+ *
+ * A ledger holds a key, never a sentence: OpenCoins rows carry `reason` ("quest:publish-a-video"),
+ * Vibes/Balance rows carry a type and metadata. This list is the one place each kind is declared:
+ * the key it matches, the site it belongs to, what its subject is (a quest, a stream, a tip, a
+ * creator, an app, a project, a purchase), the phrase that names it, and how the subject's own
+ * name and address are found.
+ *
+ * `describe(key, options)` makes `{ key, text, subject: { name, url } | null }`. `text` is the
+ * phrase without the amount ("for the quest Publish a video"); the page writes
+ * "+15 OpenCoins" before it and links the phrase to subject.url. A key nobody declared answers
+ * "for something on <site>" and is marked known: false, so a caller logs it once and never shows
+ * the key itself.
+ *
+ * `options.resolve` maps a subject type to a lookup: `{ quest: (id) => ({ name, url }) }`.
+ * `options.subject` is an already-found subject (a tip's creator, a project). `options.site`
+ * names the site for an undeclared key.
+ */
+
+/** The sites a key can belong to, in the words a person reads. */
+const SITES = Object.freeze({
+ quest: 'OpenVibe.Quest',
+ fun: 'OpenVibe.Fun',
+ live: 'OpenVibe.Live',
+ network: 'OpenVibe.Network',
+ billing: 'OpenVibe',
+ inventory: 'OpenVibe.Inventory',
+});
+
+/**
+ * One entry per reason kind.
+ * match RegExp whose first group is the subject's id or name (or an exact key string)
+ * site SITES key, for an undeclared key and for callers that show provenance
+ * subject the subject type `options.resolve` can look up; omitted when there is none
+ * text the phrase; `{name}` is where the subject's name goes
+ * fallback the name when the subject cannot be found ("a creator", "an item")
+ * path the subject's address; `{id}` is the matched group
+ * catalog where the subject's own names live (a declaration for the resolver)
+ */
+const KINDS = Object.freeze([
+ // ── OpenCoins: Network's coin_transactions.reason ───────────────────────────────────────────
+ Object.freeze({ id: 'quest', match: /^quest:([a-z0-9-]+)$/i, site: 'quest', subject: 'quest', text: 'for the quest {name}',
+ path: 'https://openvibe.quest/quests/{id}', catalog: 'https://openvibe.quest/api/v1/quests' }),
+ Object.freeze({ id: 'fun-quest', match: /^fun:([a-z0-9-]+)$/i, site: 'fun', subject: 'fun-quest', text: 'for the quest {name}',
+ path: 'https://openvibe.fun/q/{id}', catalog: 'https://openvibe.fun/api/v1/quests' }),
+ Object.freeze({ id: 'media-request', match: /^Media request: (.+)$/, site: 'live', subject: 'media', text: 'for the stream {name}' }),
+ Object.freeze({ id: 'media-refund', match: /^Refund: (.+)$/, site: 'live', subject: 'media', text: 'for a refund of {name}' }),
+ Object.freeze({ id: 'migration', match: 'migration:hobo-coins', site: 'network', text: 'for OpenCoins moved from the old Live wallet' }),
+ Object.freeze({ id: 'account-merge', match: 'account_merge', site: 'network', text: 'for merging your accounts' }),
+ Object.freeze({ id: 'account-deleted', match: 'account_deleted', site: 'network', text: 'for closing your account' }),
+ Object.freeze({ id: 'watch', match: 'watch', site: 'live', text: 'for watching a stream' }),
+ Object.freeze({ id: 'chat', match: 'chat', site: 'live', text: 'for chatting on a stream' }),
+ Object.freeze({ id: 'follow', match: 'follow', site: 'live', text: 'for following a channel' }),
+ Object.freeze({ id: 'bonus-game', match: 'bonus game', site: 'live', text: 'for winning the bonus game' }),
+ Object.freeze({ id: 'admin-grant', match: 'Admin grant', site: 'network', text: 'for OpenCoins from OpenVibe' }),
+
+ // ── Vibes / Balance: Billing's transaction type + metadata ──────────────────────────────────
+ Object.freeze({ id: 'ai-usage', match: 'credit:settle', site: 'billing', text: 'for AI usage' }),
+ Object.freeze({ id: 'topup', match: 'credit:topup', site: 'billing', text: 'for adding money to your Balance' }),
+ Object.freeze({ id: 'vibes-from-balance', match: 'purchase:vibes_from_balance', site: 'billing', text: 'for buying Vibes with your Balance' }),
+ Object.freeze({ id: 'vibes-bought', match: 'purchase:vibes', site: 'billing', text: 'for buying Vibes' }),
+ Object.freeze({ id: 'store-purchase', match: 'purchase:store', site: 'inventory', subject: 'item', text: 'for buying {name}', fallback: 'an item' }),
+ Object.freeze({ id: 'app-payment', match: 'vibes_charge', site: 'billing', subject: 'app', text: 'for paying {name}', fallback: 'an app' }),
+ Object.freeze({ id: 'app-purchase', match: 'purchase:app', site: 'billing', subject: 'app', text: 'for paying {name}', fallback: 'an app' }),
+ Object.freeze({ id: 'tip', match: 'donation', site: 'billing', subject: 'creator', text: 'for a tip to {name}', fallback: 'a creator' }),
+ Object.freeze({ id: 'tip-received', match: 'donation:received', site: 'billing', subject: 'creator', text: 'for a tip from {name}', fallback: 'someone' }),
+ Object.freeze({ id: 'subscription', match: 'subscription', site: 'billing', subject: 'creator', text: 'for a subscription to {name}', fallback: 'a creator' }),
+ Object.freeze({ id: 'subscription-share', match: 'subscription_share', site: 'billing', subject: 'creator', text: 'for a subscription to {name}', fallback: 'a creator' }),
+ Object.freeze({ id: 'refund', match: 'refund', site: 'billing', text: 'for a refund' }),
+ Object.freeze({ id: 'vibes-refund', match: 'vibes_refund', site: 'billing', text: 'for a refund of Vibes' }),
+ Object.freeze({ id: 'move-to-project', match: 'balance_transfer', site: 'billing', subject: 'project', text: 'for moving money to {name}', fallback: 'a project' }),
+ Object.freeze({ id: 'cashout-request', match: 'cashout_request', site: 'billing', text: 'for cashing out' }),
+ Object.freeze({ id: 'cashout-paid', match: 'cashout_paid', site: 'billing', text: 'for a payout' }),
+ Object.freeze({ id: 'cashout-denied', match: 'cashout_denied', site: 'billing', text: 'for a cashout that was denied' }),
+ Object.freeze({ id: 'recycle', match: 'recycle', site: 'billing', text: 'for moving your cashout balance back to Vibes' }),
+ Object.freeze({ id: 'adjustment', match: 'adjustment', site: 'billing', text: 'for an adjustment by OpenVibe' }),
+]);
+
+/** "live-setup-slot" → "Live setup slot": the name a slug gives when its catalog is not at hand. */
+function titleFromSlug(slug) {
+ const words = String(slug == null ? '' : slug).replace(/[-_]+/g, ' ').trim();
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : '';
+}
+
+function siteName(id) {
+ return SITES[String(id || '').toLowerCase()] || 'OpenVibe';
+}
+
+function subjectFrom(value, kind, options) {
+ if (options.subject && typeof options.subject === 'object' && options.subject.name) {
+ return { name: String(options.subject.name).slice(0, 160), url: options.subject.url ? String(options.subject.url).slice(0, 500) : null };
+ }
+ let found = null;
+ if (value != null && kind.subject && options.resolve && typeof options.resolve[kind.subject] === 'function') {
+ try { found = options.resolve[kind.subject](value); } catch { found = null; }
+ }
+ if (typeof found === 'string') found = { name: found };
+ const name = found && found.name ? String(found.name).slice(0, 160) : (kind.fallback || titleFromSlug(value));
+ const url = found && found.url ? String(found.url).slice(0, 500) : (kind.path && value != null ? kind.path.replace('{id}', encodeURIComponent(value)) : null);
+ if (!name) return null;
+ return { name, url: url || null };
+}
+
+/**
+ * The plain words for a ledger reason.
+ * @returns {{ key: string, text: string, subject: { name: string, url: string|null }|null, site: string, known: boolean }}
+ */
+function describe(key, options = {}) {
+ const raw = key == null ? '' : String(key).trim();
+ let kind = null;
+ let value = null;
+ for (const entry of KINDS) {
+ if (typeof entry.match === 'string') {
+ if (raw === entry.match) { kind = entry; break; }
+ continue;
+ }
+ const m = raw.match(entry.match);
+ if (m) { kind = entry; value = m[1] == null ? null : m[1].trim(); break; }
+ }
+ if (!kind) {
+ return { key: raw, text: `for something on ${siteName(options.site || raw.split(':')[0])}`, subject: null,
+ site: siteName(options.site || raw.split(':')[0]), known: false };
+ }
+ const subject = kind.subject ? subjectFrom(value, kind, options) : null;
+ const text = kind.text.indexOf('{name}') < 0 ? kind.text
+ : kind.text.replace('{name}', subject ? subject.name : (kind.fallback || 'something'));
+ return { key: raw, text, subject, site: siteName(kind.site), known: true };
+}
+
+/** A row from a service that keeps the raw key: `{ reason, app_id }` or `{ reason }`. */
+function describeRow(row, options = {}) {
+ const reason = row && (row.reason != null ? row.reason : row.reason_key);
+ return describe(reason, { ...options, site: options.site || (row && (row.app_id || row.site)) || undefined });
+}
+
+/** What an undeclared key reads, for a test and for a caller that wants only the words. */
+function unknownText(site) {
+ return `for something on ${siteName(site)}`;
+}
+
+module.exports = { KINDS, SITES, describe, describeRow, unknownText, siteName, titleFromSlug };
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+
+/**
+ * The plain words for a money row. The list and the formatter live in openvibe-contracts
+ * (`billing.money-reason@1`, `lib/money-reasons.js`); Live re-exports them when the installed
+ * package has them and otherwise answers from the same list copied at 0.392.0.
+ */
+// The list is openvibe-contracts/lib/money-reasons.js; copied at 0.392.0 until Live's pin carries the module.
+const reasons = require('./reasons-list');
+
+const unknown = new Set();
+function describe(key, options) {
+ const d = reasons.describe(key, options);
+ if (!d.known && d.key && !unknown.has(d.key)) {
+ unknown.add(d.key);
+ console.warn(`[Money] undeclared reason key ${JSON.stringify(d.key.slice(0, 120))} — reads "${d.text}"`);
+ }
+ return d;
+}
+
+module.exports = { ...reasons, describe };
@@ -179,6 +179,8 @@ async function check(name, fn) {
@@ -179,6 +179,8 @@ async function check(name, fn) {
assert.strictEqual(h.status, 200, h.text);
assert.strictEqual(h.status, 200, h.text);
const d = h.json.transactions.find((t) => t.type === 'donation');
const d = h.json.transactions.find((t) => t.type === 'donation');
assert.strictEqual(d.from_user_id, 2); assert.strictEqual(d.to_username, 'bob');
assert.strictEqual(d.from_user_id, 2); assert.strictEqual(d.to_username, 'bob');
+ assert.strictEqual(d.label, 'for a tip to @bob', 'the reason is a sentence, not a type');
+ assert.strictEqual(d.subject.url, 'https://openvibe.live/@bob', 'and links to the creator');
});
});
await check('cashout request → POST /cashouts (cashout.request); approving/denying is not Live\'s any more', async () => {
await check('cashout request → POST /cashouts (cashout.request); approving/denying is not Live\'s any more', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+// Live's Vibes history words (billing.money-reason@1): a sentence, never a ledger key.
+const assert = require('assert');
+const money = require('../server/money/reasons');
+
+let n = 0;
+const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
+
+const tip = money.describe('donation', { site: 'live', subject: { name: '@alice' } });
+ok(tip.text === 'for a tip to @alice', 'a tip names the creator');
+ok(tip.subject.name === '@alice', 'and carries them as its subject');
+
+const received = money.describe('donation:received', { site: 'live', subject: { name: '@bob' } });
+ok(received.text === 'for a tip from @bob', 'a received tip names the sender');
+
+ok(money.describe('purchase:vibes').text === 'for buying Vibes', 'a purchase is plain words');
+ok(money.describe('cashout_request').text === 'for cashing out', 'a cashout is plain words');
+ok(money.describe('recycle').text === 'for moving your cashout balance back to Vibes', 'a recycle is plain words');
+
+const unknown = money.describe('nobody:declared-this', { site: 'live' });
+ok(!unknown.known && unknown.text === 'for something on OpenVibe.Live', 'an undeclared key never shows itself');
+
+console.log(`openvibe-live money reasons: ${n} checks passed`);