openvibers/openvibe.billing Commits

Money reads: plain reason text and its subject, never the ledger key (#251)

OP OpenVibers committed 3 days ago · ebde283b4c · 8 files, +294 −46

* Money reads return the plain reason and its subject; OpenCoins rows stop showing the ledger key

* Money reasons copy: contracts 0.392.0

Changes

ddiff --git a/server/config.js b/server/config.js
iindex 77aedb7..81a987b 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -200,6 +200,8 @@ function loadConfig(env = process.env) {
},
liveUrl: String(env.LIVE_URL || 'https://openvibe.live').replace(/\/+$/, ''),
inventoryUrl: String(env.INVENTORY_URL || 'https://inventory.openvibe.network').replace(/\/+$/, ''),
++ // The quest catalog names a quest in an OpenCoins reason (billing.money-reason@1); public, cached.
++ quests: { url: String(env.QUESTS_URL || 'https://openvibe.quest').replace(/\/+$/, '') },
credits: {
// AI Exchange D2/D12: high-precision Balance ledger. Off by default; no inference money moves without it.
ddiff --git a/server/money/reasons-list.js b/server/money/reasons-list.js
nnew file mode 100644
iindex 0000000..8350489
---- /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 Billing answers with the plain words
++ * before its installed contract package carries the module. reasons.js prefers the contract's 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 0000000..4577a3c
---- /dev/null
++++ b/server/money/reasons.js
@@@ -0,0 +1,24 @@
++'use strict';
++
++/**
++ * The plain words for a ledger row. The list and the formatter live in openvibe-contracts
++ * (`billing.money-reason@1`, `lib/money-reasons.js`); Billing re-exports them when the installed
++ * package has them and otherwise answers from the same list copied at 0.392.0. One site never
++ * builds the sentence itself: every read here returns `{ label, subject }` already made.
++ */
++let canonical = null;
++try { canonical = require('openvibe-contracts').moneyReasons || null; } catch { /* an older contract package: the copy below answers */ }
++const reasons = canonical || require('./reasons-list');
++
++/** Log an undeclared key once per process: support can see it, a person never does. */
++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, describeRow: (row, options) => describe(row && (row.reason != null ? row.reason : row.reason_key), options) };
ddiff --git a/server/ops/activity.js b/server/ops/activity.js
iindex ce8c82a..033fcc4 100644
---- a/server/ops/activity.js
++++ b/server/ops/activity.js
@@@ -5,11 +5,14 @@
* touched their Balance (`usd_credit`, user:<usr_id>) or their spendable Vibes (`user_credit`). A transaction that
* moved both (Get Vibes with Balance) has two rows with the same id, so the wallet can group them.
*
-- * Labels are the public words: Added money, AI usage, Got Vibes, Tipped @name, Paid <app>, Moved to <project>,
-- * Refund. Only a creator's public name is read from Network; no other subject id, email or project secret is in a
-- * row. Balance amounts are signed nano-USD decimal strings; Vibes are signed whole integers.
++ * Each row is the public words (openvibe-contracts billing.money-reason@1): `label` is the phrase without the
++ * amount ("for a tip to @alice"), `subject` is the thing it was for with its own name and address, and
++ * `reason_key` is the ledger's own key for support. Only a creator's public name is read from Network; no other
++ * subject id, email or project secret is in a row. Balance amounts are signed nano-USD decimal strings; Vibes are
++ * signed whole integers.
*/
const { fail, userSubject } = require('./common');
++const money = require('../money/reasons');
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
@@@ -34,41 +37,52 @@ function modelOf(attempt) {
return model ? model.slice(0, 120) : null;
}
++/** The reason key this journal row means: Billing's type, refined by the metadata that separates its shapes. */
++function keyOf(t, m) {
++ if (t.type === 'credit' && m.credit === 'settle') return 'credit:settle';
++ if (t.type === 'credit' && m.credit === 'topup') return 'credit:topup';
++ if (t.type === 'purchase' && m.balance === 'vibes_from_balance') return 'purchase:vibes_from_balance';
++ if (t.type === 'purchase' && m.store_purchase) return 'purchase:store';
++ if (t.type === 'purchase' && (m.vibes_charge || m.vibes_charge_id || m.charge === 'vibes_pay' || m.app_name)) return 'purchase:app';
++ if (t.type === 'purchase') return 'purchase:vibes';
++ return String(t.type || '');
++}
++
++function appNameOf(m) {
++ const app = m.app_name || (m.app && typeof m.app === 'object' ? m.app.name : null) || (m.app && typeof m.app !== 'object' ? m.app : null);
++ return app ? String(app).slice(0, APP_NAME_MAX) : null;
++}
++
function labelFor(t, { settlement, projectName, creatorName }) {
const m = parseJson(t.metadata);
-- if (t.type === 'credit' && m.credit === 'settle') {
++ const key = keyOf(t, m);
++ let subject = null;
++ let detail = '';
++ if (key === 'credit:settle') {
const attempts = settlement && Array.isArray(settlement.attempts) ? settlement.attempts : [];
const model = modelOf(attempts[attempts.length - 1]);
const requests = attempts.length || 1;
-- return { label: 'AI usage', detail: [model, `${requests} request${requests === 1 ? '' : 's'}`].filter(Boolean).join(' · ') };
-- }
-- if (t.type === 'credit' && m.credit === 'topup') {
-- const provider = PROVIDERS[String(t.provider || m.provider || '').toLowerCase()] || titleCase(t.provider || m.provider || '');
-- return { label: 'Added money', detail: provider };
-- }
-- if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {
-- const n = Number(m.vibes || 0);
-- return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };
-- }
-- if ((t.type === 'purchase' && (m.vibes_charge || m.vibes_charge_id || m.charge === 'vibes_pay' || m.app_name)) || t.type === 'vibes_charge') {
-- const app = m.app_name || (m.app && typeof m.app === 'object' ? m.app.name : null) || (m.app && typeof m.app !== 'object' ? m.app : null);
-- const name = app ? String(app).slice(0, APP_NAME_MAX) : null;
-- return { label: name ? `Paid ${name}` : 'Paid an app', detail: m.description ? String(m.description).slice(0, 120) : '' };
-- }
-- if (t.type === 'purchase' && m.store_purchase) return { label: `Bought ${String(m.item_name || 'an item').slice(0, 120)}`, detail: 'Inventory' };
-- if (t.type === 'purchase') {
-- const n = Number(m.bits || m.vibes || m.amount_bits || 0);
-- return { label: 'Got Vibes', detail: n ? `${n} Vibes` : '' };
-- }
-- if (t.type === 'balance_transfer') {
-- return { label: projectName ? `Moved to ${projectName}` : 'Moved to a project', detail: '' };
-- }
-- if (t.type === 'donation') {
-- return { label: creatorName ? `Tipped @${creatorName}` : 'Tipped a creator', detail: '' };
++ detail = [model, `${requests} request${requests === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
++ } else if (key === 'credit:topup') {
++ detail = PROVIDERS[String(t.provider || m.provider || '').toLowerCase()] || titleCase(t.provider || m.provider || '');
++ } else if (key === 'purchase:app' || key === 'vibes_charge') {
++ const name = appNameOf(m);
++ if (name) subject = { name };
++ detail = m.description ? String(m.description).slice(0, 120) : '';
++ } else if (key === 'purchase:store') {
++ subject = { name: String(m.item_name || 'an item').slice(0, 120) };
++ detail = 'Inventory';
++ } else if (key === 'balance_transfer') {
++ if (projectName) subject = { name: String(projectName).slice(0, 120), url: m.to_project_id ? `https://openvibe.services/projects/${encodeURIComponent(String(m.to_project_id))}` : null };
++ } else if (key === 'donation' || key === 'subscription' || key === 'subscription_share') {
++ if (creatorName) subject = { name: `@${creatorName}`, url: `https://openvibe.live/@${encodeURIComponent(String(creatorName))}` };
++ } else if (key === 'refund' || key === 'vibes_refund') {
++ detail = 'Returned';
++ } else if (key === 'cashout_request' || key === 'cashout_paid') {
++ detail = 'Payable';
}
-- if (t.type === 'refund' || t.type === 'vibes_refund') return { label: 'Refund', detail: 'Returned' };
-- if (t.type === 'cashout_request' || t.type === 'cashout_paid') return { label: 'Cash out', detail: 'Payable' };
-- return { label: titleCase(t.type) || 'Activity', detail: '' };
++ const d = money.describe(key, { site: 'billing', subject });
++ return { label: d.text, detail, subject: d.subject, reason_key: d.key };
}
/**
@@@ -143,8 +157,9 @@ async function activity(ctx, rawSubject, { limit = DEFAULT_LIMIT, before = null
if (cur.txn.type === 'balance_transfer') projectName = projects.get(meta.to_project_id) || null;
const creator = names.get(cur.txn.to_subject);
const d = labelFor(cur.txn, { settlement: settlementOf.get(t.id), projectName, creatorName: creator && creator.username ? creator.username : null });
-- if (cur.nano !== 0n) out.push({ id: t.id, at: t.created_at, kind: 'balance', label: d.label, detail: d.detail, amount: cur.nano.toString() });
-- if (cur.vibes !== 0) out.push({ id: t.id, at: t.created_at, kind: 'vibes', label: d.label, detail: d.detail, amount: cur.vibes });
++ const common = { label: d.label, detail: d.detail, subject: d.subject, reason_key: d.reason_key };
++ if (cur.nano !== 0n) out.push({ id: t.id, at: t.created_at, kind: 'balance', amount: cur.nano.toString(), ...common });
++ if (cur.vibes !== 0) out.push({ id: t.id, at: t.created_at, kind: 'vibes', amount: cur.vibes, ...common });
}
return { activity: out, next_before: more ? page[page.length - 1].id : null };
}
ddiff --git a/server/ops/wallet.js b/server/ops/wallet.js
iindex 76a7568..a0d4907 100644
---- a/server/ops/wallet.js
++++ b/server/ops/wallet.js
@@@ -4,6 +4,35 @@ const { balance } = require('../ledger');
const { A, fail } = require('./common');
const { presentBalance } = require('./balance');
const { activity } = require('./activity');
++const money = require('../money/reasons');
++
++const QUEST_TTL_MS = 10 * 60_000;
++let questCache = { at: 0, names: new Map() };
++
++/**
++ * Quest ids → their own names and addresses, from Quest's public catalog (the declaration in
++ * billing.money-reason@1 names this URL). Best-effort and cached: without it a slug still names the
++ * quest ("for the quest Publish a video") and the address is still built from the id.
++ */
++async function questNames(ctx) {
++ if (questCache.at && Date.now() - questCache.at < QUEST_TTL_MS) return questCache.names;
++ const base = String(ctx.config.quests && ctx.config.quests.url || 'https://openvibe.quest').replace(/\/+$/, '');
++ const names = new Map();
++ try {
++ const r = await ctx.fetchImpl(`${base}/api/v1/quests`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(2500) });
++ if (r.ok) {
++ const body = await r.json();
++ for (const site of (body && Array.isArray(body.sites) ? body.sites : [])) {
++ for (const q of (Array.isArray(site.quests) ? site.quests : [])) {
++ if (!q || !q.id) continue;
++ names.set(String(q.id), { name: String(q.title || q.id).slice(0, 160), url: `${base}${String(q.url || `/quests/${encodeURIComponent(q.id)}`)}` });
++ }
++ }
++ }
++ } catch { /* the slug and the id still answer */ }
++ questCache = { at: Date.now(), names };
++ return names;
++}
// Network remains authoritative for OpenCoins. Never request with=vibes here: that asks Billing again.
async function coinsRead(ctx, authorization, path) {
@@@ -42,12 +71,14 @@ async function wallet(ctx, subject, authorization) {
for (const e of events) if (e.id === row.id) e.site = site;
}
}
++ const quests = history.transactions.some((e) => /^quest:/.test(String(e.reason || ''))) ? await questNames(ctx) : new Map();
for (const e of history.transactions) {
if (!Number.isSafeInteger(Number(e.delta)) || !Number.isFinite(Date.parse(e.created_at))) {
fail(502, 'billing.coins_unavailable', 'Network returned invalid OpenCoins activity');
}
++ const d = money.describe(e.reason, { site: e.app_id || 'network', resolve: { quest: (id) => quests.get(id) } });
events.push({ id: `coins:${e.id}`, at: new Date(e.created_at).toISOString(), kind: 'coins',
-- amount: String(e.delta), label: String(e.reason || 'OpenCoins').slice(0, 120), detail: '', site: String(e.app_id || 'network').slice(0, 80) });
++ amount: String(e.delta), label: d.text, subject: d.subject, reason_key: d.key, detail: '', site: d.site });
}
events.sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || b.id.localeCompare(a.id));
return {
ddiff --git a/test/activity.test.js b/test/activity.test.js
iindex 6cf0058..ba93826 100644
---- a/test/activity.test.js
++++ b/test/activity.test.js
@@@ -67,14 +67,17 @@ const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
assert.strictEqual(r.status, 200, r.text);
const rows = r.body.activity;
const byLabel = (l) => rows.filter((x) => x.label === l);
-- assert.strictEqual(byLabel('Added money')[0].detail, 'Stripe');
-- assert.strictEqual(byLabel('Added money')[0].amount, '3000000000');
-- assert.strictEqual(byLabel('Moved to Studio')[0].amount, '-300000000');
-- assert.strictEqual(byLabel('Tipped @bob')[0].amount, -25);
-- assert.strictEqual(byLabel('Refund')[0].amount, 25);
-- assert.strictEqual(byLabel('Paid Sticker Shop')[0].amount, -50);
-- assert.strictEqual(byLabel('Paid Sticker Shop')[0].detail, 'Neon cats');
-- const ai = byLabel('AI usage')[0];
++ assert.strictEqual(byLabel('for adding money to your Balance')[0].detail, 'Stripe');
++ assert.strictEqual(byLabel('for adding money to your Balance')[0].amount, '3000000000');
++ assert.strictEqual(byLabel('for moving money to Studio')[0].amount, '-300000000');
++ assert.strictEqual(byLabel('for a tip to @bob')[0].amount, -25);
++ assert.deepStrictEqual(byLabel('for a tip to @bob')[0].subject, { name: '@bob', url: 'https://openvibe.live/@bob' });
++ assert.deepStrictEqual(byLabel('for moving money to Studio')[0].subject && byLabel('for moving money to Studio')[0].subject.name, 'Studio');
++ assert.strictEqual(byLabel('for a refund')[0].reason_key, 'refund');
++ assert.strictEqual(byLabel('for a refund')[0].amount, 25);
++ assert.strictEqual(byLabel('for paying Sticker Shop')[0].amount, -50);
++ assert.strictEqual(byLabel('for paying Sticker Shop')[0].detail, 'Neon cats');
++ const ai = byLabel('for AI usage')[0];
assert.strictEqual(ai.kind, 'balance');
assert.ok(ai.detail.includes('qwen/qwen3-4b:q4_k_m@3f2a1c9'), ai.detail);
assert.ok(ai.detail.includes('1 request'), ai.detail);
@@@ -90,7 +93,8 @@ const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
assert.deepStrictEqual(pair.map((x) => x.kind).sort(), ['balance', 'vibes']);
const bal = pair.find((x) => x.kind === 'balance');
const vib = pair.find((x) => x.kind === 'vibes');
-- assert.strictEqual(bal.label, 'Got Vibes');
++ assert.strictEqual(bal.label, 'for buying Vibes with your Balance');
++ assert.strictEqual(vib.label, 'for buying Vibes with your Balance');
assert.strictEqual(bal.amount, '-1000000000');
assert.strictEqual(vib.amount, 100);
assert.ok(pair.every((x) => x.id === got.transaction_id));
ddiff --git a/test/money-reasons.test.js b/test/money-reasons.test.js
nnew file mode 100644
iindex 0000000..09619b6
---- /dev/null
++++ b/test/money-reasons.test.js
@@@ -0,0 +1,25 @@
++'use strict';
++
++const assert = require('assert');
++const money = require('../server/money/reasons');
++
++let n = 0;
++const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
++
++const quest = money.describe('quest:publish-a-video', { site: 'quest', resolve: { quest: (id) => ({ name: 'Publish a video', url: `https://openvibe.quest/quests/${id}` }) } });
++ok(quest.text === 'for the quest Publish a video', 'a quest key is a sentence');
++ok(quest.subject.url === 'https://openvibe.quest/quests/publish-a-video', 'with the quest as a link');
++ok(quest.site === 'OpenVibe.Quest', 'and its site in words');
++
++const refund = money.describe('Refund: A film', { site: 'live' });
++ok(refund.text === 'for a refund of A film' && refund.subject.name === 'A film', 'a media refund names the stream');
++
++const tip = money.describe('donation', { site: 'billing', subject: { name: '@bob', url: 'https://openvibe.live/@bob' } });
++ok(tip.text === 'for a tip to @bob', 'a tip names its creator');
++ok(tip.subject.url === 'https://openvibe.live/@bob', 'and links to them');
++
++const unknown = money.describe('nobody:declared-this', { site: 'fun' });
++ok(!unknown.known && unknown.text === 'for something on OpenVibe.Fun', 'an undeclared key never shows itself');
++ok(money.describeRow({ reason: 'quest:go-live', app_id: 'quest' }).text === 'for the quest Go live', 'a raw row is described');
++
++console.log(`openvibe-billing money reasons: ${n} checks passed`);
ddiff --git a/test/wallet.test.js b/test/wallet.test.js
iindex 516d97b..4e244c7 100644
---- a/test/wallet.test.js
++++ b/test/wallet.test.js
@@@ -36,14 +36,15 @@ const { boot, fund, check, done } = require('./helpers/app');
await check('own balances and newest five events merge Network and Billing with site attribution', async () => {
coins = 45;
await fund(t, alice.id, 120);
-- history = Array.from({ length: 5 }, (_, i) => ({ id: i, delta: 9, app_id: 'quest', reason: 'Daily quest', created_at: new Date(t.ctx.now() - (i + 1) * 60000).toISOString() }));
++ history = Array.from({ length: 5 }, (_, i) => ({ id: i, delta: 9, app_id: 'quest', reason: 'Media request: A film', created_at: new Date(t.ctx.now() - (i + 1) * 60000).toISOString() }));
const r = await read();
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.balances.coins.amount, '45');
assert.strictEqual(r.json.balances.vibes.amount, '120');
assert.strictEqual(r.json.recent_activity.length, 5);
assert.strictEqual(r.json.recent_activity[0].kind, 'vibes');
-- assert.strictEqual(r.json.recent_activity[1].site, 'quest');
++ assert.strictEqual(r.json.recent_activity[1].site, 'OpenVibe.Live');
++ assert.strictEqual(r.json.recent_activity[1].label, 'for the stream A film');
});
await check('no cookies, forged tokens, service tokens or subject overrides can read another wallet', async () => {
const before = requests;