openvibers/openvibe.network Commits
Money reasons: the account card and wallet show sentences, never ledger keys (#763)
Changes
@@@ -198,7 +198,19 @@ function renderMoney(coins, last) {if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);const t = last && last.transactions && last.transactions[0];const line = document.getElementById('td-money-last');-- line.textContent = t ? `${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} ${t.reason ? `for ${String(t.reason).replace(/_/g, ' ')}` : ''} · ${tdAgo(t.created_at)}` : '';++ line.replaceChildren();++ if (t) {++ // The server makes the words (billing.money-reason@1). The raw key never reaches this line:++ // an undeclared reason already reads "for something on <site>".++ const phrase = t.reason_text || `for something on ${t.reason_site || 'OpenVibe'}`;++ line.append(document.createTextNode(`${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} `));++ if (t.reason_subject && t.reason_subject.url) {++ const a = document.createElement('a');++ a.href = t.reason_subject.url; a.textContent = phrase; a.target = '_blank'; a.rel = 'noopener';++ line.append(a);++ } else line.append(document.createTextNode(phrase));++ line.append(document.createTextNode(` · ${tdAgo(t.created_at)}`));++ }line.hidden = !t;box.hidden = false;}@@@ -149,7 +149,15 @@date.dateTime = String(row.at || '');if (t.time) date.title = t.time;const main = W.el('div', 'w-act-main');-- main.appendChild(W.el('b', 'w-act-label', row.label || 'Activity'));++ // The reason is a sentence from billing.money-reason@1; when it names a thing with a page, the++ // sentence is the link to it. The raw ledger key is never drawn.++ const label = String(row.label || 'Activity');++ const url = row.subject && row.subject.url;++ if (url && /^https:\/\//.test(url)) {++ const a = W.el('a', 'w-act-label', label);++ a.href = url; a.target = '_blank'; a.rel = 'noopener';++ main.appendChild(a);++ } else main.appendChild(W.el('b', 'w-act-label', label));const kindNames = [...new Set(kinds.map((k) => (k === 'vibes' ? 'Vibes' : 'Balance')))];const kindName = kindNames.join(' · ');main.appendChild(W.el('span', 'w-act-detail', row.detail ? `${kindName} · ${row.detail}` : kindName));@@@ -9,6 +9,7 @@const express = require('express');const wallet = require('./wallet');++const money = require('../money/reasons');const config = require('../config');const VIBES_TIMEOUT_MS = 2000;@@@ -41,7 +42,9 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {});// ── GET /api/coins/me/history?limit=50&offset=0 ──────────-- // → { transactions: [{ id, app_id, delta, reason, created_at }] }++ // → { transactions: [{ id, app_id, delta, reason, reason_text, reason_subject, reason_site, created_at }] }++ // `reason` stays for support; no page prints it. `reason_text` is the phrase without the amount++ // ("for the quest Publish a video"), `reason_subject` is { name, url } for the thing it was for.router.get('/me/history', requireAuth, async (req, res) => {const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);@@@ -52,7 +55,13 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {ORDER BY id DESCLIMIT ? OFFSET ?`).all(req.user.id, limit, offset);-- res.json({ transactions });++ const quests = transactions.some((t) => /^quest:/.test(String(t.reason || ''))) ? await money.questNames() : new Map();++ res.json({++ transactions: transactions.map((t) => {++ const d = money.describe(t.reason, { site: t.app_id || 'network', resolve: { quest: (id) => quests.get(id) } });++ return { ...t, reason_text: d.text, reason_subject: d.subject, reason_site: d.site };++ }),++ });});return router;nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++/**++ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so Network's OpenCoins history 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 };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++/**++ * The plain words for an OpenCoins ledger row (`coin_transactions.reason`). The list and the++ * formatter live in openvibe-contracts (`billing.money-reason@1`, `lib/money-reasons.js`); Network++ * re-exports them when the installed package has them and otherwise answers from the same list++ * copied at 0.392.0. A page writes the amount before `text` and links it to `subject.url`.++ */++// The list is openvibe-contracts/lib/money-reasons.js; copied at 0.392.0 until Network's pin carries the module.++const reasons = require('./reasons-list');++++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 (cached, best-effort). */++async function questNames(fetchImpl = globalThis.fetch) {++ if (questCache.at && Date.now() - questCache.at < QUEST_TTL_MS) return questCache.names;++ const kind = reasons.KINDS.find((k) => k.subject === 'quest');++ const base = String(process.env.QUESTS_URL || (kind && kind.catalog) || 'https://openvibe.quest/api/v1/quests').replace(/\/+$/, '');++ const names = new Map();++ try {++ const r = await fetchImpl(/\/api\/v1\/quests$/.test(base) ? base : `${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;++ const host = base.replace(/\/api\/v1\/quests$/, '');++ names.set(String(q.id), { name: String(q.title || q.id).slice(0, 160), url: `${host}${String(q.url || `/quests/${encodeURIComponent(q.id)}`)}` });++ }++ }++ }++ } catch { /* the slug and the id still answer */ }++ questCache = { at: Date.now(), names };++ return names;++}++++/** 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, questNames };@@@ -24,6 +24,7 @@const express = require('express');const wallet = require('../coins/wallet');++const money = require('../money/reasons');const config = require('../config');const TIMEOUT_MS = 3500;@@@ -126,26 +127,33 @@ function titleCase(s) {return String(s || 'Activity').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase());}--/** The label and the second line of what a journal transaction was, from its type and metadata. */++/** The reason key a 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 || '');++}++++/** The label and the second line of what a journal transaction was, from its type and metadata.++ * The words come from billing.money-reason@1, so this fallback reads like Billing's own activity. */function describe(t) {const m = (t && t.metadata) || {};-- if (t.type === 'credit' && m.credit === 'settle') return { label: 'AI usage', detail: m.model ? String(m.model) : 'Metered usage' };-- if (t.type === 'credit' && m.credit === 'topup') return { label: 'Added money', detail: 'Balance top-up' };-- if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {-- const got = Number(m.vibes || 0);-- return { label: got ? `Got ${got.toLocaleString('en-US')} Vibes with your Balance` : 'Got Vibes with your Balance', detail: '' };-- }-- if (t.type === 'purchase') {-- const bought = Number(m.vibes || m.bits || m.amount_bits || 0);-- return { label: bought ? `Bought ${bought.toLocaleString('en-US')} Vibes` : 'Bought Vibes', detail: 'With a card' };-- }-- if (t.type === 'balance_transfer') return { label: 'Moved to a project', detail: m.to_project_id ? String(m.to_project_id) : 'Project Balance' };-- if (t.type === 'donation') return { label: 'Tip', detail: m.message ? text(m.message, 80) : 'Live tip' };-- if (t.type === 'subscription' || t.type === 'subscription_share') return { label: 'Subscription', detail: 'Recurring' };-- if (t.type === 'refund') return { label: 'Refund', detail: 'Returned' };-- if (t.type === 'vibes_charge' || m.vibes_charge) return { label: m.description ? text(m.description, 80) : 'App payment', detail: 'Vibes Pay' };-- if (t.type === 'cashout_paid' || t.type === 'cashout_request') return { label: 'Cash out', detail: 'Payable' };-- return { label: titleCase(t.type), detail: '' };++ const key = keyOf(t, m);++ let detail = '';++ if (key === 'credit:settle') detail = m.model ? String(m.model) : 'Metered usage';++ else if (key === 'credit:topup') detail = 'Balance top-up';++ else if (key === 'balance_transfer') detail = m.to_project_id ? String(m.to_project_id) : 'Project Balance';++ else if (key === 'donation') detail = m.message ? text(m.message, 80) : 'Live tip';++ else if (key === 'subscription' || key === 'subscription_share') detail = 'Recurring';++ else if (key === 'refund' || key === 'vibes_refund') detail = 'Returned';++ else if (key === 'vibes_charge') detail = 'Vibes Pay';++ else if (key === 'cashout_paid' || key === 'cashout_request') detail = 'Payable';++ const d = money.describe(key, { site: 'billing' });++ return { label: d.text, detail, subject: d.subject, reason_key: d.key };}/** The person's two spendable parts of one journal transaction, in display order: Balance, then Vibes. */@@@ -188,6 +196,7 @@ function mapActivity(subject, transactions) {const d = describe(t);rows.push({id: String(t.id), at: t.created_at, label: d.label, detail: d.detail,++ subject: d.subject || null, reason_key: d.reason_key || null,kinds: parts.map((p) => p.kind), parts,});}@@@ -259,7 +268,10 @@ function mapPersonActivity(subject, body) {const at = item.at || item.created_at || item.date || null;const label = text(item.label || item.description || item.title || 'Activity', 120);const detail = text(item.detail || item.subtitle || '', 120);-- const row = grouped.get(id) || { id, at, label, detail, parts: [] };++ const anchors = item.subject && typeof item.subject === 'object' && item.subject.name++ ? { name: text(item.subject.name, 160), url: item.subject.url ? text(item.subject.url, 500) : null } : null;++ const row = grouped.get(id) || { id, at, label, detail, parts: [], subject: anchors, reason_key: item.reason_key || null };++ if (anchors) row.subject = anchors;for (const p of parts) if (!row.parts.some((x) => x.kind === p.kind)) row.parts.push(p);grouped.set(id, row);}nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++// The plain words for an OpenCoins reason (billing.money-reason@1), and the quest catalog it names from.++const assert = require('assert');++const money = require('../server/money/reasons');++++let n = 0;++const ok = (cond, msg) => { assert.ok(cond, msg); n++; };++++(async () => {++ 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 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.describe('account_merge').text === 'for merging your accounts', 'a reason with no page has its own words');++++ const names = await money.questNames(async (url) => {++ assert.match(url, /\/api\/v1\/quests$/);++ return { ok: true, json: async () => ({ sites: [{ quests: [{ id: 'go-live', title: 'Go live once', url: '/quests/go-live' }] }] }) };++ });++ ok(names.get('go-live').name === 'Go live once', 'the Quest catalog names the quest');++ ok(names.get('go-live').url === 'https://openvibe.quest/quests/go-live', 'and gives its address');++++ console.log(`openvibe-network money reasons: ${n} checks passed`);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -33,6 +33,7 @@ class Node {get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }set textContent(v) { this._text = String(v); this.children = []; }get href() { return this.attrs.href; }++ set href(v) { this.attrs.href = String(v); }get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }}@@@ -42,7 +43,7 @@ function page() {for (const [, id] of HTML.matchAll(/id="((?:td-|me-|page-header|user-greeting|needs-you|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');const shown = [];const sandbox = {-- document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },++ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), createTextNode: (t) => Object.assign(new Node('#text'), { _text: String(t) }), querySelector: () => null },console, Date, Number, String, Map, Set, Math, JSON, Promise,currentUser: { role: 'user' },showSection: (id) => shown.push(id),@@@ -134,9 +135,14 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');-- p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'watch_time', created_at: ago(90) }] });++ p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'quest:go-live', reason_text: 'for the quest Go live',++ reason_subject: { name: 'Go live', url: 'https://openvibe.quest/quests/go-live' }, created_at: ago(90) }] });assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);-- assert.match(p.ids['td-money-last'].textContent, /^\+20 for watch time · 1 h ago$/);++ assert.match(p.ids['td-money-last'].textContent, /^\+20 for the quest Go live · 1 h ago$/);++ assert.strictEqual(p.ids['td-money-last'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.quest/quests/go-live', 'the reason links to the quest');++ p.sandbox.renderMoney({ balance: 1240 }, { transactions: [{ delta: 5, reason: 'nobody:declared-this', reason_site: 'OpenVibe.Fun', created_at: ago(90) }] });++ assert.match(p.ids['td-money-last'].textContent, /^\+5 for something on OpenVibe.Fun · 1 h ago$/);++ assert.doesNotMatch(p.ids['td-money-last'].textContent, /nobody|declared-this/);p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });assert.match(p.ids['td-stuff-row'].textContent, /14following/);@@@ -124,6 +124,7 @@ const REVIEWED = {'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],++ 'server/money/reasons.js fetch': [1, 'Quest\'s public catalog URL from operator config (QUESTS_URL, default https://openvibe.quest): the fixed /api/v1/quests path, 2.5 s timeout, held 10 minutes; it only names a quest id already in a ledger reason, and no request value names a host'],'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],@@@ -75,16 +75,16 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;},]);assert.deepStrictEqual(activity.map((r) => [r.kinds, r.parts, r.label]), [-- [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'Got 500 Vibes with your Balance'],-- [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'AI usage'],++ [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'for buying Vibes with your Balance'],++ [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'for AI usage'],], 'one row per transaction, both currencies together; creator payable and sandbox are not the spendable wallet');assert.strictEqual(createWalletRoutes.mapActivity(subject, null).length, 0);assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [-- { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },-- { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },++ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'for buying Vibes with your Balance', amount: 500 },++ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'for buying Vibes with your Balance', amount: '-5000000000' },] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]]], 'the person-shaped route is grouped too');assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [-- { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'Got 10 Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },++ { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'for buying Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-100000000' }, { kind: 'vibes', amount: 10 }]]], 'an { amounts } shape is read too');assert.ok(!principals.DEFAULT_GRANTS.some(([client, cap]) => client === 'network' && cap === 'billing.balance.read'), 'Network does not seed a broad Billing read');@@@ -116,8 +116,8 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;if (url.pathname === '/api/me/activity') {if (activityMode === 'missing') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.not_found' })); }return res.end(JSON.stringify({ activity: [-- { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },-- { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },++ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'for buying Vibes with your Balance', amount: 500 },++ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'for buying Vibes with your Balance', amount: '-5000000000' },] }));}if (url.pathname === '/api/v1/transactions') return res.end(JSON.stringify({ transactions: [{ id: 'txn_buy', type: 'purchase', created_at: '2026-11-04T10:00:00.000Z', metadata: { balance: 'vibes_from_balance', vibes: 500 }, entries: [@@@ -179,7 +179,7 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;assert.strictEqual(w.body.activity.length, 1);assert.deepStrictEqual(w.body.activity[0].kinds, ['balance', 'vibes']);assert.deepStrictEqual(w.body.activity[0].parts, [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]);-- assert.strictEqual(w.body.activity[0].label, 'Got 500 Vibes with your Balance');++ assert.strictEqual(w.body.activity[0].label, 'for buying Vibes with your Balance');assert.strictEqual(w.body.activity_source, 'journal', 'the marked fallback until Billing has /api/me/activity');const personActivity = asked.filter((a) => a.url.startsWith('/api/me/activity'));assert.strictEqual(personActivity.length, 1);
@@ -198,7 +198,19 @@ function renderMoney(coins, last) {
@@ -198,7 +198,19 @@ function renderMoney(coins, last) {
if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);
if (coins.vibes != null) document.getElementById('td-vibes').textContent = tdNum(typeof coins.vibes === 'object' ? coins.vibes.balance : coins.vibes);
const t = last && last.transactions && last.transactions[0];
const t = last && last.transactions && last.transactions[0];
const line = document.getElementById('td-money-last');
const line = document.getElementById('td-money-last');
- line.textContent = t ? `${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} ${t.reason ? `for ${String(t.reason).replace(/_/g, ' ')}` : ''} · ${tdAgo(t.created_at)}` : '';
+ line.replaceChildren();
+ if (t) {
+ // The server makes the words (billing.money-reason@1). The raw key never reaches this line:
+ // an undeclared reason already reads "for something on <site>".
+ const phrase = t.reason_text || `for something on ${t.reason_site || 'OpenVibe'}`;
+ line.append(document.createTextNode(`${t.delta > 0 ? '+' : ''}${tdNum(t.delta)} `));
+ if (t.reason_subject && t.reason_subject.url) {
+ const a = document.createElement('a');
+ a.href = t.reason_subject.url; a.textContent = phrase; a.target = '_blank'; a.rel = 'noopener';
+ line.append(a);
+ } else line.append(document.createTextNode(phrase));
+ line.append(document.createTextNode(` · ${tdAgo(t.created_at)}`));
+ }
line.hidden = !t;
line.hidden = !t;
box.hidden = false;
box.hidden = false;
}
}
@@ -149,7 +149,15 @@
@@ -149,7 +149,15 @@
date.dateTime = String(row.at || '');
date.dateTime = String(row.at || '');
if (t.time) date.title = t.time;
if (t.time) date.title = t.time;
const main = W.el('div', 'w-act-main');
const main = W.el('div', 'w-act-main');
- main.appendChild(W.el('b', 'w-act-label', row.label || 'Activity'));
+ // The reason is a sentence from billing.money-reason@1; when it names a thing with a page, the
+ // sentence is the link to it. The raw ledger key is never drawn.
+ const label = String(row.label || 'Activity');
+ const url = row.subject && row.subject.url;
+ if (url && /^https:\/\//.test(url)) {
+ const a = W.el('a', 'w-act-label', label);
+ a.href = url; a.target = '_blank'; a.rel = 'noopener';
+ main.appendChild(a);
+ } else main.appendChild(W.el('b', 'w-act-label', label));
const kindNames = [...new Set(kinds.map((k) => (k === 'vibes' ? 'Vibes' : 'Balance')))];
const kindNames = [...new Set(kinds.map((k) => (k === 'vibes' ? 'Vibes' : 'Balance')))];
const kindName = kindNames.join(' · ');
const kindName = kindNames.join(' · ');
main.appendChild(W.el('span', 'w-act-detail', row.detail ? `${kindName} · ${row.detail}` : kindName));
main.appendChild(W.el('span', 'w-act-detail', row.detail ? `${kindName} · ${row.detail}` : kindName));
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const express = require('express');
const express = require('express');
const wallet = require('./wallet');
const wallet = require('./wallet');
+const money = require('../money/reasons');
const config = require('../config');
const config = require('../config');
const VIBES_TIMEOUT_MS = 2000;
const VIBES_TIMEOUT_MS = 2000;
@@ -41,7 +42,9 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
@@ -41,7 +42,9 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
});
});
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
// ── GET /api/coins/me/history?limit=50&offset=0 ──────────
- // → { transactions: [{ id, app_id, delta, reason, created_at }] }
+ // → { transactions: [{ id, app_id, delta, reason, reason_text, reason_subject, reason_site, created_at }] }
+ // `reason` stays for support; no page prints it. `reason_text` is the phrase without the amount
+ // ("for the quest Publish a video"), `reason_subject` is { name, url } for the thing it was for.
router.get('/me/history', requireAuth, async (req, res) => {
router.get('/me/history', requireAuth, async (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 50, 1), 200);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
@@ -52,7 +55,13 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
@@ -52,7 +55,13 @@ module.exports = function createCoinsRoutes(db, requireAuth, opts = {}) {
ORDER BY id DESC
ORDER BY id DESC
LIMIT ? OFFSET ?
LIMIT ? OFFSET ?
`).all(req.user.id, limit, offset);
`).all(req.user.id, limit, offset);
- res.json({ transactions });
+ const quests = transactions.some((t) => /^quest:/.test(String(t.reason || ''))) ? await money.questNames() : new Map();
+ res.json({
+ transactions: transactions.map((t) => {
+ const d = money.describe(t.reason, { site: t.app_id || 'network', resolve: { quest: (id) => quests.get(id) } });
+ return { ...t, reason_text: d.text, reason_subject: d.subject, reason_site: d.site };
+ }),
+ });
});
});
return router;
return router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so Network's OpenCoins history 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * The plain words for an OpenCoins ledger row (`coin_transactions.reason`). The list and the
+ * formatter live in openvibe-contracts (`billing.money-reason@1`, `lib/money-reasons.js`); Network
+ * re-exports them when the installed package has them and otherwise answers from the same list
+ * copied at 0.392.0. A page writes the amount before `text` and links it to `subject.url`.
+ */
+// The list is openvibe-contracts/lib/money-reasons.js; copied at 0.392.0 until Network's pin carries the module.
+const reasons = require('./reasons-list');
+
+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 (cached, best-effort). */
+async function questNames(fetchImpl = globalThis.fetch) {
+ if (questCache.at && Date.now() - questCache.at < QUEST_TTL_MS) return questCache.names;
+ const kind = reasons.KINDS.find((k) => k.subject === 'quest');
+ const base = String(process.env.QUESTS_URL || (kind && kind.catalog) || 'https://openvibe.quest/api/v1/quests').replace(/\/+$/, '');
+ const names = new Map();
+ try {
+ const r = await fetchImpl(/\/api\/v1\/quests$/.test(base) ? base : `${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;
+ const host = base.replace(/\/api\/v1\/quests$/, '');
+ names.set(String(q.id), { name: String(q.title || q.id).slice(0, 160), url: `${host}${String(q.url || `/quests/${encodeURIComponent(q.id)}`)}` });
+ }
+ }
+ }
+ } catch { /* the slug and the id still answer */ }
+ questCache = { at: Date.now(), names };
+ return names;
+}
+
+/** 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, questNames };
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
const express = require('express');
const express = require('express');
const wallet = require('../coins/wallet');
const wallet = require('../coins/wallet');
+const money = require('../money/reasons');
const config = require('../config');
const config = require('../config');
const TIMEOUT_MS = 3500;
const TIMEOUT_MS = 3500;
@@ -126,26 +127,33 @@ function titleCase(s) {
@@ -126,26 +127,33 @@ function titleCase(s) {
return String(s || 'Activity').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase());
return String(s || 'Activity').replace(/[_-]+/g, ' ').replace(/\b[a-z]/g, (c) => c.toUpperCase());
}
}
-/** The label and the second line of what a journal transaction was, from its type and metadata. */
+/** The reason key a 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 || '');
+}
+
+/** The label and the second line of what a journal transaction was, from its type and metadata.
+ * The words come from billing.money-reason@1, so this fallback reads like Billing's own activity. */
function describe(t) {
function describe(t) {
const m = (t && t.metadata) || {};
const m = (t && t.metadata) || {};
- if (t.type === 'credit' && m.credit === 'settle') return { label: 'AI usage', detail: m.model ? String(m.model) : 'Metered usage' };
- if (t.type === 'credit' && m.credit === 'topup') return { label: 'Added money', detail: 'Balance top-up' };
- if (t.type === 'purchase' && m.balance === 'vibes_from_balance') {
- const got = Number(m.vibes || 0);
- return { label: got ? `Got ${got.toLocaleString('en-US')} Vibes with your Balance` : 'Got Vibes with your Balance', detail: '' };
- }
- if (t.type === 'purchase') {
- const bought = Number(m.vibes || m.bits || m.amount_bits || 0);
- return { label: bought ? `Bought ${bought.toLocaleString('en-US')} Vibes` : 'Bought Vibes', detail: 'With a card' };
- }
- if (t.type === 'balance_transfer') return { label: 'Moved to a project', detail: m.to_project_id ? String(m.to_project_id) : 'Project Balance' };
- if (t.type === 'donation') return { label: 'Tip', detail: m.message ? text(m.message, 80) : 'Live tip' };
- if (t.type === 'subscription' || t.type === 'subscription_share') return { label: 'Subscription', detail: 'Recurring' };
- if (t.type === 'refund') return { label: 'Refund', detail: 'Returned' };
- if (t.type === 'vibes_charge' || m.vibes_charge) return { label: m.description ? text(m.description, 80) : 'App payment', detail: 'Vibes Pay' };
- if (t.type === 'cashout_paid' || t.type === 'cashout_request') return { label: 'Cash out', detail: 'Payable' };
- return { label: titleCase(t.type), detail: '' };
+ const key = keyOf(t, m);
+ let detail = '';
+ if (key === 'credit:settle') detail = m.model ? String(m.model) : 'Metered usage';
+ else if (key === 'credit:topup') detail = 'Balance top-up';
+ else if (key === 'balance_transfer') detail = m.to_project_id ? String(m.to_project_id) : 'Project Balance';
+ else if (key === 'donation') detail = m.message ? text(m.message, 80) : 'Live tip';
+ else if (key === 'subscription' || key === 'subscription_share') detail = 'Recurring';
+ else if (key === 'refund' || key === 'vibes_refund') detail = 'Returned';
+ else if (key === 'vibes_charge') detail = 'Vibes Pay';
+ else if (key === 'cashout_paid' || key === 'cashout_request') detail = 'Payable';
+ const d = money.describe(key, { site: 'billing' });
+ return { label: d.text, detail, subject: d.subject, reason_key: d.key };
}
}
/** The person's two spendable parts of one journal transaction, in display order: Balance, then Vibes. */
/** The person's two spendable parts of one journal transaction, in display order: Balance, then Vibes. */
@@ -188,6 +196,7 @@ function mapActivity(subject, transactions) {
@@ -188,6 +196,7 @@ function mapActivity(subject, transactions) {
const d = describe(t);
const d = describe(t);
rows.push({
rows.push({
id: String(t.id), at: t.created_at, label: d.label, detail: d.detail,
id: String(t.id), at: t.created_at, label: d.label, detail: d.detail,
+ subject: d.subject || null, reason_key: d.reason_key || null,
kinds: parts.map((p) => p.kind), parts,
kinds: parts.map((p) => p.kind), parts,
});
});
}
}
@@ -259,7 +268,10 @@ function mapPersonActivity(subject, body) {
@@ -259,7 +268,10 @@ function mapPersonActivity(subject, body) {
const at = item.at || item.created_at || item.date || null;
const at = item.at || item.created_at || item.date || null;
const label = text(item.label || item.description || item.title || 'Activity', 120);
const label = text(item.label || item.description || item.title || 'Activity', 120);
const detail = text(item.detail || item.subtitle || '', 120);
const detail = text(item.detail || item.subtitle || '', 120);
- const row = grouped.get(id) || { id, at, label, detail, parts: [] };
+ const anchors = item.subject && typeof item.subject === 'object' && item.subject.name
+ ? { name: text(item.subject.name, 160), url: item.subject.url ? text(item.subject.url, 500) : null } : null;
+ const row = grouped.get(id) || { id, at, label, detail, parts: [], subject: anchors, reason_key: item.reason_key || null };
+ if (anchors) row.subject = anchors;
for (const p of parts) if (!row.parts.some((x) => x.kind === p.kind)) row.parts.push(p);
for (const p of parts) if (!row.parts.some((x) => x.kind === p.kind)) row.parts.push(p);
grouped.set(id, row);
grouped.set(id, row);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+// The plain words for an OpenCoins reason (billing.money-reason@1), and the quest catalog it names from.
+const assert = require('assert');
+const money = require('../server/money/reasons');
+
+let n = 0;
+const ok = (cond, msg) => { assert.ok(cond, msg); n++; };
+
+(async () => {
+ 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 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.describe('account_merge').text === 'for merging your accounts', 'a reason with no page has its own words');
+
+ const names = await money.questNames(async (url) => {
+ assert.match(url, /\/api\/v1\/quests$/);
+ return { ok: true, json: async () => ({ sites: [{ quests: [{ id: 'go-live', title: 'Go live once', url: '/quests/go-live' }] }] }) };
+ });
+ ok(names.get('go-live').name === 'Go live once', 'the Quest catalog names the quest');
+ ok(names.get('go-live').url === 'https://openvibe.quest/quests/go-live', 'and gives its address');
+
+ console.log(`openvibe-network money reasons: ${n} checks passed`);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -33,6 +33,7 @@ class Node {
@@ -33,6 +33,7 @@ class Node {
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
set textContent(v) { this._text = String(v); this.children = []; }
set textContent(v) { this._text = String(v); this.children = []; }
get href() { return this.attrs.href; }
get href() { return this.attrs.href; }
+ set href(v) { this.attrs.href = String(v); }
get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }
get classList() { const n = this; return { add: (c) => { n.className += ` ${c}`; } }; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
}
}
@@ -42,7 +43,7 @@ function page() {
@@ -42,7 +43,7 @@ function page() {
for (const [, id] of HTML.matchAll(/id="((?:td-|me-|page-header|user-greeting|needs-you|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
for (const [, id] of HTML.matchAll(/id="((?:td-|me-|page-header|user-greeting|needs-you|today|coins|settings)[a-z-]*)"/g)) ids[id] = new Node('div');
const shown = [];
const shown = [];
const sandbox = {
const sandbox = {
- document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), querySelector: () => null },
+ document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t), createTextNode: (t) => Object.assign(new Node('#text'), { _text: String(t) }), querySelector: () => null },
console, Date, Number, String, Map, Set, Math, JSON, Promise,
console, Date, Number, String, Map, Set, Math, JSON, Promise,
currentUser: { role: 'user' },
currentUser: { role: 'user' },
showSection: (id) => shown.push(id),
showSection: (id) => shown.push(id),
@@ -134,9 +135,14 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
@@ -134,9 +135,14 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');
assert.ok(!tries.some((u) => /live|tools|games|design/.test(u)), 'none you already use');
assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');
assert.strictEqual(p.ids['td-sites-h'].textContent, 'Your sites');
- p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'watch_time', created_at: ago(90) }] });
+ p.sandbox.renderMoney({ balance: 1240, vibes: 30 }, { transactions: [{ delta: 20, reason: 'quest:go-live', reason_text: 'for the quest Go live',
+ reason_subject: { name: 'Go live', url: 'https://openvibe.quest/quests/go-live' }, created_at: ago(90) }] });
assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);
assert.deepStrictEqual([p.ids.coins.hidden, p.ids['td-coins'].textContent, p.ids['td-vibes'].textContent, p.ids['td-vibes-row'].hidden], [false, (1240).toLocaleString(), '30', false]);
- assert.match(p.ids['td-money-last'].textContent, /^\+20 for watch time · 1 h ago$/);
+ assert.match(p.ids['td-money-last'].textContent, /^\+20 for the quest Go live · 1 h ago$/);
+ assert.strictEqual(p.ids['td-money-last'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.quest/quests/go-live', 'the reason links to the quest');
+ p.sandbox.renderMoney({ balance: 1240 }, { transactions: [{ delta: 5, reason: 'nobody:declared-this', reason_site: 'OpenVibe.Fun', created_at: ago(90) }] });
+ assert.match(p.ids['td-money-last'].textContent, /^\+5 for something on OpenVibe.Fun · 1 h ago$/);
+ assert.doesNotMatch(p.ids['td-money-last'].textContent, /nobody|declared-this/);
p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });
p.sandbox.renderStuff({ following: 14 }, { total: 230 }, { nodes: [{ id: 1 }, { id: 2, revoked_at: 'x' }] });
assert.match(p.ids['td-stuff-row'].textContent, /14following/);
assert.match(p.ids['td-stuff-row'].textContent, /14following/);
@@ -124,6 +124,7 @@ const REVIEWED = {
@@ -124,6 +124,7 @@ const REVIEWED = {
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/discord/discord-service.js discord.js': [1, 'discord.js login: Discord gateway only'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
+ 'server/money/reasons.js fetch': [1, 'Quest\'s public catalog URL from operator config (QUESTS_URL, default https://openvibe.quest): the fixed /api/v1/quests path, 2.5 s timeout, held 10 minutes; it only names a quest id already in a ledger reason, and no request value names a host'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
@@ -75,16 +75,16 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
@@ -75,16 +75,16 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
},
},
]);
]);
assert.deepStrictEqual(activity.map((r) => [r.kinds, r.parts, r.label]), [
assert.deepStrictEqual(activity.map((r) => [r.kinds, r.parts, r.label]), [
- [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'Got 500 Vibes with your Balance'],
- [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'AI usage'],
+ [['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }], 'for buying Vibes with your Balance'],
+ [['balance'], [{ kind: 'balance', amount: '-123000000' }], 'for AI usage'],
], 'one row per transaction, both currencies together; creator payable and sandbox are not the spendable wallet');
], 'one row per transaction, both currencies together; creator payable and sandbox are not the spendable wallet');
assert.strictEqual(createWalletRoutes.mapActivity(subject, null).length, 0);
assert.strictEqual(createWalletRoutes.mapActivity(subject, null).length, 0);
assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
- { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },
- { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },
+ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'for buying Vibes with your Balance', amount: 500 },
+ { id: 'txn_one', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'for buying Vibes with your Balance', amount: '-5000000000' },
] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]]], 'the person-shaped route is grouped too');
] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]]], 'the person-shaped route is grouped too');
assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
assert.deepStrictEqual(createWalletRoutes.mapPersonActivity(subject, { activity: [
- { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'Got 10 Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },
+ { id: 'txn_two', at: '2026-11-04T11:00:00.000Z', label: 'for buying Vibes with your Balance', amounts: { balance: '-100000000', vibes: 10 } },
] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-100000000' }, { kind: 'vibes', amount: 10 }]]], 'an { amounts } shape is read too');
] }).map((r) => [r.kinds, r.parts]), [[['balance', 'vibes'], [{ kind: 'balance', amount: '-100000000' }, { kind: 'vibes', amount: 10 }]]], 'an { amounts } shape is read too');
assert.ok(!principals.DEFAULT_GRANTS.some(([client, cap]) => client === 'network' && cap === 'billing.balance.read'), 'Network does not seed a broad Billing read');
assert.ok(!principals.DEFAULT_GRANTS.some(([client, cap]) => client === 'network' && cap === 'billing.balance.read'), 'Network does not seed a broad Billing read');
@@ -116,8 +116,8 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
@@ -116,8 +116,8 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
if (url.pathname === '/api/me/activity') {
if (url.pathname === '/api/me/activity') {
if (activityMode === 'missing') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.not_found' })); }
if (activityMode === 'missing') { res.statusCode = 404; return res.end(JSON.stringify({ error: 'billing.not_found' })); }
return res.end(JSON.stringify({ activity: [
return res.end(JSON.stringify({ activity: [
- { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'Got 500 Vibes with your Balance', amount: 500 },
- { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'Got 500 Vibes with your Balance', amount: '-5000000000' },
+ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'vibes', label: 'for buying Vibes with your Balance', amount: 500 },
+ { id: 'txn_buy', at: '2026-11-04T10:00:00.000Z', kind: 'balance', label: 'for buying Vibes with your Balance', amount: '-5000000000' },
] }));
] }));
}
}
if (url.pathname === '/api/v1/transactions') return res.end(JSON.stringify({ transactions: [{ id: 'txn_buy', type: 'purchase', created_at: '2026-11-04T10:00:00.000Z', metadata: { balance: 'vibes_from_balance', vibes: 500 }, entries: [
if (url.pathname === '/api/v1/transactions') return res.end(JSON.stringify({ transactions: [{ id: 'txn_buy', type: 'purchase', created_at: '2026-11-04T10:00:00.000Z', metadata: { balance: 'vibes_from_balance', vibes: 500 }, entries: [
@@ -179,7 +179,7 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
@@ -179,7 +179,7 @@ const KEY = (n) => `idem-${String(n).padStart(4, '0')}`;
assert.strictEqual(w.body.activity.length, 1);
assert.strictEqual(w.body.activity.length, 1);
assert.deepStrictEqual(w.body.activity[0].kinds, ['balance', 'vibes']);
assert.deepStrictEqual(w.body.activity[0].kinds, ['balance', 'vibes']);
assert.deepStrictEqual(w.body.activity[0].parts, [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]);
assert.deepStrictEqual(w.body.activity[0].parts, [{ kind: 'balance', amount: '-5000000000' }, { kind: 'vibes', amount: 500 }]);
- assert.strictEqual(w.body.activity[0].label, 'Got 500 Vibes with your Balance');
+ assert.strictEqual(w.body.activity[0].label, 'for buying Vibes with your Balance');
assert.strictEqual(w.body.activity_source, 'journal', 'the marked fallback until Billing has /api/me/activity');
assert.strictEqual(w.body.activity_source, 'journal', 'the marked fallback until Billing has /api/me/activity');
const personActivity = asked.filter((a) => a.url.startsWith('/api/me/activity'));
const personActivity = asked.filter((a) => a.url.startsWith('/api/me/activity'));
assert.strictEqual(personActivity.length, 1);
assert.strictEqual(personActivity.length, 1);