openvibers/openvibe.billing Commits
Money reads: plain reason text and its subject, never the ledger key (#251)
Changes
@@@ -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.nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++/**++ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so 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 };nnew file mode 100644@@@ -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) };@@@ -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 = nullif (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 };}@@@ -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 {@@@ -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));nnew file mode 100644@@@ -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`);@@@ -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;
@@ -200,6 +200,8 @@ function loadConfig(env = process.env) {
@@ -200,6 +200,8 @@ function loadConfig(env = process.env) {
},
},
liveUrl: String(env.LIVE_URL || 'https://openvibe.live').replace(/\/+$/, ''),
liveUrl: String(env.LIVE_URL || 'https://openvibe.live').replace(/\/+$/, ''),
inventoryUrl: String(env.INVENTORY_URL || 'https://inventory.openvibe.network').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: {
credits: {
// AI Exchange D2/D12: high-precision Balance ledger. Off by default; no inference money moves without it.
// AI Exchange D2/D12: high-precision Balance ledger. Off by default; no inference money moves without it.
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * The same list as openvibe-contracts/lib/money-reasons.js (0.392.0), copied so 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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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) };
@@ -5,11 +5,14 @@
@@ -5,11 +5,14 @@
* touched their Balance (`usd_credit`, user:<usr_id>) or their spendable Vibes (`user_credit`). A transaction that
* 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.
* 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 { fail, userSubject } = require('./common');
+const money = require('../money/reasons');
const DEFAULT_LIMIT = 50;
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 100;
const MAX_LIMIT = 100;
@@ -34,41 +37,52 @@ function modelOf(attempt) {
@@ -34,41 +37,52 @@ function modelOf(attempt) {
return model ? model.slice(0, 120) : null;
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 }) {
function labelFor(t, { settlement, projectName, creatorName }) {
const m = parseJson(t.metadata);
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 attempts = settlement && Array.isArray(settlement.attempts) ? settlement.attempts : [];
const model = modelOf(attempts[attempts.length - 1]);
const model = modelOf(attempts[attempts.length - 1]);
const requests = 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
@@ -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;
if (cur.txn.type === 'balance_transfer') projectName = projects.get(meta.to_project_id) || null;
const creator = names.get(cur.txn.to_subject);
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 });
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 };
return { activity: out, next_before: more ? page[page.length - 1].id : null };
}
}
@@ -4,6 +4,35 @@ const { balance } = require('../ledger');
@@ -4,6 +4,35 @@ const { balance } = require('../ledger');
const { A, fail } = require('./common');
const { A, fail } = require('./common');
const { presentBalance } = require('./balance');
const { presentBalance } = require('./balance');
const { activity } = require('./activity');
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.
// Network remains authoritative for OpenCoins. Never request with=vibes here: that asks Billing again.
async function coinsRead(ctx, authorization, path) {
async function coinsRead(ctx, authorization, path) {
@@ -42,12 +71,14 @@ async function wallet(ctx, subject, authorization) {
@@ -42,12 +71,14 @@ async function wallet(ctx, subject, authorization) {
for (const e of events) if (e.id === row.id) e.site = site;
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) {
for (const e of history.transactions) {
if (!Number.isSafeInteger(Number(e.delta)) || !Number.isFinite(Date.parse(e.created_at))) {
if (!Number.isSafeInteger(Number(e.delta)) || !Number.isFinite(Date.parse(e.created_at))) {
fail(502, 'billing.coins_unavailable', 'Network returned invalid OpenCoins activity');
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',
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));
events.sort((a, b) => Date.parse(b.at) - Date.parse(a.at) || b.id.localeCompare(a.id));
return {
return {
@@ -67,14 +67,17 @@ const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
@@ -67,14 +67,17 @@ const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
const rows = r.body.activity;
const rows = r.body.activity;
const byLabel = (l) => rows.filter((x) => x.label === l);
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.strictEqual(ai.kind, 'balance');
assert.ok(ai.detail.includes('qwen/qwen3-4b:q4_k_m@3f2a1c9'), ai.detail);
assert.ok(ai.detail.includes('qwen/qwen3-4b:q4_k_m@3f2a1c9'), ai.detail);
assert.ok(ai.detail.includes('1 request'), ai.detail);
assert.ok(ai.detail.includes('1 request'), ai.detail);
@@ -90,7 +93,8 @@ const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
@@ -90,7 +93,8 @@ const key = (p) => `${p}:${crypto.randomBytes(8).toString('hex')}`;
assert.deepStrictEqual(pair.map((x) => x.kind).sort(), ['balance', 'vibes']);
assert.deepStrictEqual(pair.map((x) => x.kind).sort(), ['balance', 'vibes']);
const bal = pair.find((x) => x.kind === 'balance');
const bal = pair.find((x) => x.kind === 'balance');
const vib = pair.find((x) => x.kind === 'vibes');
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(bal.amount, '-1000000000');
assert.strictEqual(vib.amount, 100);
assert.strictEqual(vib.amount, 100);
assert.ok(pair.every((x) => x.id === got.transaction_id));
assert.ok(pair.every((x) => x.id === got.transaction_id));
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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`);
@@ -36,14 +36,15 @@ const { boot, fund, check, done } = require('./helpers/app');
@@ -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 () => {
await check('own balances and newest five events merge Network and Billing with site attribution', async () => {
coins = 45;
coins = 45;
await fund(t, alice.id, 120);
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();
const r = await read();
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.balances.coins.amount, '45');
assert.strictEqual(r.json.balances.coins.amount, '45');
assert.strictEqual(r.json.balances.vibes.amount, '120');
assert.strictEqual(r.json.balances.vibes.amount, '120');
assert.strictEqual(r.json.recent_activity.length, 5);
assert.strictEqual(r.json.recent_activity.length, 5);
assert.strictEqual(r.json.recent_activity[0].kind, 'vibes');
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 () => {
await check('no cookies, forged tokens, service tokens or subject overrides can read another wallet', async () => {
const before = requests;
const before = requests;