openvibers/openvibe.live Commits

Vibes history: the reason is a sentence, not a ledger type (#767)

OP OpenVibers committed 3 days ago · 9be1661e6e · 7 files, +215 −8

* Vibes history: the reason is a sentence made on the server, linked to who it was for

* Money reasons copy: contracts 0.392.0

* Money reasons: the copied list until the pin carries it

Changes

ddiff --git a/public/css/features/dashboard.css b/public/css/features/dashboard.css
iindex 77bf311a..0a47a03e 100644
---- a/public/css/features/dashboard.css
++++ b/public/css/features/dashboard.css
@@@ -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; }
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex 0eff78fa..45ec831c 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -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>`;
}
ddiff --git a/server/monetization/billing-actions.js b/server/monetization/billing-actions.js
iindex 367b54dc..dc16aa87 100644
---- a/server/monetization/billing-actions.js
++++ b/server/monetization/billing-actions.js
@@@ -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,
ddiff --git a/server/money/reasons-list.js b/server/money/reasons-list.js
nnew file mode 100644
iindex 00000000..25351bfd
---- /dev/null
++++ b/server/money/reasons-list.js
@@@ -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 };
ddiff --git a/server/money/reasons.js b/server/money/reasons.js
nnew file mode 100644
iindex 00000000..c25b8c83
---- /dev/null
++++ b/server/money/reasons.js
@@@ -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 };
ddiff --git a/test/billing-authority.test.js b/test/billing-authority.test.js
iindex 9bfe6389..55c68404 100644
---- a/test/billing-authority.test.js
++++ b/test/billing-authority.test.js
@@@ -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 () => {
ddiff --git a/test/money-reasons.test.js b/test/money-reasons.test.js
nnew file mode 100644
iindex 00000000..a5953b7a
---- /dev/null
++++ b/test/money-reasons.test.js
@@@ -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`);