openvibers/openvibe.tips Commits
EXTERNAL PowerChat tips from Billing: consume billing.receipt.external (a tip on a creator's own PowerChat, announced by Billing once it receives the PowerChat webhook) and celebrate it the way Live's webhook did: recorded once by (provider, provider event id), settled, chat line "… tipped N Vibes: msg (PowerChat)" through Live's /internal/tips/deliveries (origin billing-external), overlay alert and goal (a Tips goal id or a Live goal id through the import map, else the only active goal); never counted as Billing money. npm run subscribe also creates the billing.receipt.* subscription; test/external.test.js
Changes
@@@ -35,7 +35,8 @@ from *interaction delivered*.## Depends on- **OpenVibe.Billing** — `POST /api/v1/intents` (checkout), `POST /api/v1/transfers` (a tip from credit),-- `billing.transaction.settled|reversed` events++ `billing.transaction.settled|reversed` events, and `billing.receipt.external` (a tip on a creator's own++ PowerChat, once Billing receives the PowerChat webhook)- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out(openvibe-sdk transactional outbox)- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (importer)@@@ -49,8 +50,8 @@ from *interaction delivered*.fnm exec --using=22.22.1 npm installcp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …npm run dev # http://localhost:4610--npm test # 6 files: stub Network/Billing/Events/Live, temp DBs, random ports--npm run subscribe # create the billing.transaction.* subscription in OpenVibe.Events++npm test # 7 files: stub Network/Billing/Events/Live, temp DBs, random ports++npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Eventsnode scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]```@@@ -80,7 +81,7 @@ delivery record; a reversal before delivery cancels the queued effects (`tips.in| `credit` | `POST /transfers {from: supporter, to: creator, kind: tip\|paid_interaction, target: {service: tips, type: interaction, id}}`, `Idempotency-Key: tips:transfer:<id>` | the response (the settled event for that transaction is then a no-op) || `checkout` | `POST /intents {kind: purchase, subject: supporter, bits: amount}`, key `tips:intent:<id>` → checkout URL | the purchase's `billing.transaction.settled` (metadata.intent_id) triggers the transfer above — CREDIT becomes MONEY only by giving it (ADR-012 rule 3) || `provider` / origin `billing` | nothing — a donation settled in Billing that Tips did not start (Live's donate flow, a site-routed PowerChat tip) | recorded once by its Billing transaction id; overlay + goals, no second chat line |--| `external` | nothing — a tip on the creator's own PowerChat (ADR-012 EXTERNAL) | `POST /api/v1/interactions/external`, once per (provider, provider_ref); never in Billing totals |++| `external` | nothing — a tip on the creator's own PowerChat (ADR-012 EXTERNAL) | `POST /api/v1/interactions/external`, or Billing's `billing.receipt.external` (origin `billing-external`: Tips posts the chat line — `… tipped N Vibes: msg (PowerChat)` — plus overlay alert and goal, as Live's webhook did); once per (provider, provider_ref); never in Billing totals || `none` (simulation) | nothing | at once, `test = 1`: full effect path, no goal contribution, no durable event, excluded from totals |**Exactly one logical interaction:** the inbox claims `(consumer, event_id)` in the same SQLite@@@ -137,7 +138,13 @@ Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, schange; relayed when `EVENTS_URL` and the client secret are set): `tips.interaction.ready`,`tips.interaction.failed`, `tips.interaction.cancelled`, `tips.goal.updated`, `tips.overlay.delivered`,`tips.overlay.failed`. Simulations and Billing test money produce none. Consumed:--`billing.transaction.settled`, `billing.transaction.reversed` (only from source `billing`).++`billing.transaction.settled`, `billing.transaction.reversed`, `billing.receipt.external` (only from source++`billing`). `billing.receipt.external` (payload: `streamer` SubjectRef, `amount_cents`, `value_bits`,++`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,++`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` in++billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced++twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal: a Tips goal id, or a Live `donation_goals`++id mapped by the Live import; otherwise Live's rule (the only active goal).## Overlays@@@ -198,6 +205,7 @@ a copy of Billing's database for the links and totals:| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** || creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `test/api.test.js` (totals = the stub Billing payable) || simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |++| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips and Live goal ids, anonymous, test receipts, malformed payloads) |## What waits@@@ -205,8 +213,11 @@ a copy of Billing's database for the links and totals:still happen on Live; Tips can only record them from Billing events once Live sends donations asBilling transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs`TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.--- **EXTERNAL PowerChat tips** reach Billing's webhook, which records them with no effect and no event;-- until Billing emits one (or Live forwards them with `tips.interaction.record`), Tips does not see them.++- **EXTERNAL PowerChat tips** arrive as `billing.receipt.external` once Billing is the authority. They need++ the `billing.receipt.*` subscription (`npm run subscribe`) and `TIPS_CHAT_ADAPTER=live-chat`; Live's++ `/internal/tips/deliveries` posts the chat line and plays the alert but does not advance **Live's own**++ `donation_goals` or send its `goal-update`/`goal-reached` frames (Tips' goals and overlay do advance).++ PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.- **Chat**: `live-chat` needs `TIPS_CHAT_ADAPTER=live-chat` after the cutover (Live's route is deployed); OpenVibe.Chat has no publicpaid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered byits test.@@@ -42,7 +42,8 @@],"eventsConsumed": ["billing.transaction.settled",-- "billing.transaction.reversed"++ "billing.transaction.reversed",++ "billing.receipt.external"],"namespacesOwned": ["tips.*"@@@ -1,10 +1,13 @@#!/usr/bin/env node'use strict';/**-- * Create Tips' OpenVibe.Events subscription for Billing's settlement events.++ * Create Tips' OpenVibe.Events subscriptions for Billing's events: settlements (billing.transaction.*)++ * and EXTERNAL receipts (billing.receipt.*, tips on a creator's own PowerChat).** node scripts/subscribe.js [--endpoint http://127.0.0.1:4610/internal/events] [--pattern 'billing.transaction.*']*++ * Without --pattern both subscriptions are made (an existing one is reported, not duplicated).++ ** Reads the environment (.env or /etc/openvibe/tips.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,* OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the tips principal needs events.subscription.manage for* audience openvibe.events) and TIPS_EVENTS_SECRET — the delivery signing secret, handed to Events@@@ -21,7 +24,7 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?(async () => {const config = loadConfig();const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);-- const pattern = opt('pattern', 'billing.transaction.*');++ const patterns = opt('pattern') ? [opt('pattern')] : ['billing.transaction.*', 'billing.receipt.*'];const secret = config.events.webhookSecrets[0];if (!config.events.url) throw new Error('EVENTS_URL is not set');if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');@@@ -30,13 +33,15 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,audience: 'openvibe.events', scope: 'events.subscription.manage',});-- const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },-- body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),-- });-- const body = await res.json().catch(() => ({}));-- if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); return; }-- if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());-- console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);++ for (const pattern of patterns) {++ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),++ });++ const body = await res.json().catch(() => ({}));++ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); continue; }++ if (!res.ok) throw new Error(`Events answered ${res.status} for ${pattern}: ${body.code || ''} ${body.detail || ''}`.trim());++ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);++ }})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });@@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS tip_interactions (legacy_source TEXT, -- 'live:transactions:<id>' etc.idempotency_key TEXT UNIQUE,failure TEXT,-- origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | import++ origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | billing-external (announced by Billing, delivered here) | importtransfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)triedtransfer_attempts INTEGER NOT NULL DEFAULT 0,next_transfer_at INTEGER NOT NULL DEFAULT 0,@@@ -27,7 +27,8 @@ function createEffects(ctx) {creator: { type: 'user', id: i.creator_subject, handle: profile ? profile.handle : null },supporter: { name, subject: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null },interaction: { id: i.id, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null },-- text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}`,++ // A tip on the creator's own PowerChat reads as Live's webhook wrote it: "… (PowerChat)".++ text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}${i.settlement === 'external' && i.provider === 'powerchat' ? ' (PowerChat)' : ''}`,target: json(i.target, null),};if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;@@@ -21,6 +21,10 @@* a site-routed PowerChat tip) is recorded once, by its Billing transaction id.* external a tip on the creator's own PowerChat account: no Billing liability; recorded* once by (provider, provider_ref); never part of the Billing reconciliation.++ * Two ways in: POST /interactions/external (a service records it), or Billing's++ * billing.receipt.external once Billing receives the PowerChat webhook — then++ * nobody else announces it, so Tips delivers the chat line itself (origin++ * 'billing-external'), exactly as Live's webhook did: chat line + alert, goal.* simulated the whole effect path with test = 1: no Billing call, no goal contribution,* no durable event, excluded from every total.*@@@ -33,6 +37,11 @@ const { VOICES } = require('./profiles');const { BillingCallError } = require('../billing-client');const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];++// Origins whose chat effects Tips delivers: its own requests, and EXTERNAL tips Billing announced++// (their only announcement since the PowerChat webhook moved from Live to Billing).++const ANNOUNCED_HERE = ['tips', 'billing-external'];++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_EXTERNAL_CENTS = 100_000_000; // Billing's per-receipt maximum// Paid-message highlight time by amount (bits): the bigger the message, the longer it stays up.const HIGHLIGHT = [[5000, 300], [1000, 120], [500, 60], [100, 30]];const highlightFor = (bits) => (HIGHLIGHT.find(([min]) => bits >= min) || [0, 0])[1];@@@ -251,7 +260,7 @@ function createInteractions(ctx) {// Chat effects: never for imports (history), never twice for a donation another product already// announced (origin billing), never to a real chat room for a simulation.const adapter = test ? 'test' : config.chat.adapter;-- if (i.origin === 'tips' && adapter !== 'none') {++ if (ANNOUNCED_HERE.includes(i.origin) && adapter !== 'none') {if (i.kind === 'tip') addEffect(i, 'chat_line', adapter);if (i.kind === 'paid_message') addEffect(i, 'paid_message', adapter);if (i.kind === 'tts') { addEffect(i, 'chat_line', adapter); addEffect(i, 'tts', adapter); }@@@ -379,6 +388,55 @@ function createInteractions(ctx) {return get(id);}++ /**++ * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id,++ * or a Live donation_goals id (links minted by Live), which the Live import mapped to a Tips goal.++ * goals.contribute() still checks it is the creator's and active, else falls back to Live's rule.++ */++ function goalFromPurpose(...refs) {++ for (const v of refs) {++ const m = String(v || '').match(/(?:^|[:_-])goal[:_-]?([A-Za-z0-9_]+)/i);++ if (!m) continue;++ if (/^\d+$/.test(m[1])) {++ const map = db.prepare("SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = ? AND status = 'imported'").get(m[1]);++ if (map && map.target_id) return map.target_id;++ continue;++ }++ return m[1];++ }++ return null;++ }++++ /**++ * Billing announced an EXTERNAL receipt (billing.receipt.external): a tip paid on the creator's own++ * PowerChat. Inside the inbox transaction. Recorded once by (provider, provider event id) — the same++ * key POST /interactions/external uses — settled at once (the provider already confirmed it), and++ * announced by Tips: chat line through the chat adapter, overlay alert, goal contribution.++ */++ function onBillingExternal(p) {++ const provider = String(p.provider || '').toLowerCase();++ const ref = String(p.provider_event_id || '').trim();++ if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider) || !ref || ref.length > 200) return 'ignored:no_reference';++ const creator = p.streamer && p.streamer.type === 'user' && SUBJECT_RE.test(String(p.streamer.id || '')) ? p.streamer.id : null;++ if (!creator) return 'ignored:no_streamer';++ const cents = Number(p.amount_cents);++ if (!Number.isSafeInteger(cents) || cents < 1 || cents > MAX_EXTERNAL_CENTS) return 'ignored:no_amount';++ if (db.prepare('SELECT 1 FROM tip_interactions WHERE provider = ? AND provider_ref = ?').get(provider, ref)) return 'duplicate_receipt';++ // Goals and the chat line count Vibes: the value Billing put on the money (1 bit = 1 cent today).++ const bits = Number(p.value_bits);++ const amount = Math.min(Number.isSafeInteger(bits) && bits > 0 ? bits : cents, config.limits.maxBits);++ const message = p.message ? String(p.message).replace(/\r\n?/g, '\n').trim().slice(0, config.limits.messageChars) || null : null;++ const goalId = goalFromPurpose(p.app_purpose, p.app_ref);++ const i = insert({++ id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,++ supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), kind: 'tip', amount, amount_cents: cents,++ message, request: goalId ? { goal_id: goalId } : {}, funding: 'external', settlement: 'external', payment_state: 'pending',++ delivery_state: 'awaiting_payment', provider, provider_ref: ref, origin: 'billing-external', test: p.test ? 1 : 0,++ });++ settle(i);++ return 'recorded_external';++ }++// ── External and simulated ───────────────────────────────/** A tip on the creator's own PowerChat (EXTERNAL, ADR-012): recorded once by provider ref. */function recordExternal(profile, input) {@@@ -489,7 +547,7 @@ function createInteractions(ctx) {return {get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,-- onBillingSettled, onBillingReversed, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,++ onBillingSettled, onBillingReversed, onBillingExternal, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,};}@@@ -1,14 +1,18 @@'use strict';/**-- * Billing → Tips: POST /internal/events, the endpoint of Tips' OpenVibe.Events subscription-- * (topic pattern `billing.transaction.*`, created by scripts/subscribe.js).++ * Billing → Tips: POST /internal/events, the endpoint of Tips' OpenVibe.Events subscriptions++ * (topic patterns `billing.transaction.*` and `billing.receipt.*`, created by scripts/subscribe.js).** billing.transaction.settled a donation targeting a Tips interaction settles it; a donation Tips* did not start is recorded once; a purchase that funds a Tips* checkout triggers the transfer* billing.transaction.reversed flips the interaction's payment state; undelivered effects are* cancelled, delivered ones stay on record++ * billing.receipt.external a tip on the creator's own PowerChat (EXTERNAL, no Billing money):++ * recorded once by (provider, provider event id) and announced by Tips —++ * chat line, overlay alert, goal. Billing sends it only once it is the++ * money authority (while Live is, Live's webhook announces the tip)** Exactly once, twice over: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite* transaction as the change, so a redelivered event does nothing; and the Billing transaction id is@@@ -37,6 +41,7 @@ function consumerRouter({ domain, config, log = console }) {const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};if (event.event_type === 'billing.transaction.settled') return domain.interactions.onBillingSettled(payload);if (event.event_type === 'billing.transaction.reversed') return domain.interactions.onBillingReversed(payload);++ if (event.event_type === 'billing.receipt.external') return domain.interactions.onBillingExternal(payload);return 'ignored:type';});return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++// EXTERNAL PowerChat tips announced by Billing (billing.receipt.external): once the PowerChat webhook++// points at Billing, Live no longer celebrates a tip on a streamer's own PowerChat, so Tips does —++// the chat line through Live's /internal/tips/deliveries (as Live's webhook wrote it), the overlay++// alert and the goal — exactly once per provider payment, and never counted as Billing money.++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ live: true });++ const { domain, live } = t;++ const alex = await t.creator('alex');++ console.log('external receipts');++++ /** billing.receipt.external as Billing's ops/external.js writes it. */++ const receipt = (fields = {}, { streamer = alex.subject } = {}) => {++ const paymentId = fields.provider_event_id || `don-${Math.random().toString(36).slice(2)}`;++ return {++ event_id: ids.newId('event'), event_type: 'billing.receipt.external', version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },++ timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type: 'provider_receipt', id: `powerchat:${paymentId}` },++ payload: {++ classification: 'EXTERNAL', provider: 'powerchat', receipt_ref: `powerchat:${paymentId}`, provider_event_id: paymentId, delivery_id: `dlv-${paymentId}`,++ streamer: { type: 'user', id: streamer }, receiving_account: { provider: 'powerchat', id: 'pc-1', username: 'alexpc' },++ amount_cents: 500, currency: 'usd-cents', value_bits: 500, donor_name: 'Fan', anonymous: false, message: null,++ app_ref: null, app_purpose: null, occurred_at: new Date().toISOString(), test: false, rates: { bits_per_usd: 100 }, ...fields,++ },++ };++ };++ const byRef = (ref) => t.db.prepare("SELECT * FROM tip_interactions WHERE provider = 'powerchat' AND provider_ref = ?").get(ref);++++ let first;++ await check('an EXTERNAL receipt is recorded, settled and announced in Live chat as Live\'s webhook wrote it', async () => {++ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New camera', target_amount: 5000 } });++ assert.strictEqual(g.status, 201, g.text);++ first = receipt({ provider_event_id: 'ext-1', amount_cents: 1234, value_bits: 1234, donor_name: 'Generous Fan', message: 'love the stream' });++ const r = await t.deliver(first);++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ assert.strictEqual(r.json.outcome, 'recorded_external');++ const i = byRef('ext-1');++ assert.deepStrictEqual([i.creator_subject, i.kind, i.amount, i.amount_cents, i.supporter_name, i.message], [alex.subject, 'tip', 1234, 1234, 'Generous Fan', 'love the stream']);++ assert.deepStrictEqual([i.funding, i.settlement, i.origin, i.payment_state, i.billing_txn_id], ['external', 'external', 'billing-external', 'settled', null]);++ await domain.effects.drain();++ const d = live.deliveries.filter((x) => x.job.interaction.id === i.id);++ assert.strictEqual(d.length, 1);++ assert.deepStrictEqual([d[0].key, d[0].job.effect, d[0].job.test], [`${i.id}:chat_line`, 'chat_line', false]);++ assert.strictEqual(d[0].job.text, 'Generous Fan tipped 1,234 Vibes: love the stream (PowerChat)');++ assert.deepStrictEqual(d[0].job.creator, { type: 'user', id: alex.subject, handle: 'alex' });++ const effects = t.db.prepare('SELECT effect, state FROM interaction_effects WHERE interaction_id = ? ORDER BY id').all(i.id);++ assert.deepStrictEqual(effects, [{ effect: 'overlay_alert', state: 'delivered' }, { effect: 'chat_line', state: 'delivered' }]);++ const goal = domain.goals.present(domain.goals.get(g.json.goal.id));++ assert.strictEqual(goal.current_amount, 1234, 'the sole active goal advances, as on Live');++ assert.ok(t.outboxRows('tips.goal.updated').some((e) => e.payload.interaction_id === i.id));++ assert.ok(t.outboxRows('tips.interaction.ready').some((e) => e.subject.id === i.id));++ });++++ await check('a redelivery or a republish of the same payment announces nothing more', async () => {++ assert.strictEqual((await t.deliver(first)).json.duplicate, true);++ const again = await t.deliver({ ...first, event_id: ids.newId('event') });++ assert.deepStrictEqual([again.json.duplicate, again.json.outcome], [false, 'duplicate_receipt']);++ const viaApi = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.interaction.record'], body: { creator: 'alex', provider: 'powerchat', provider_ref: 'ext-1', amount_cents: 1234 } });++ assert.strictEqual(viaApi.json.duplicate, true, 'the same key as POST /interactions/external');++ await domain.effects.drain();++ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE provider_ref = 'ext-1'").get().n, 1);++ assert.strictEqual(live.deliveries.filter((x) => x.job.interaction.id === byRef('ext-1').id).length, 1);++ });++++ await check('EXTERNAL money stays out of the Billing total', async () => {++ const totals = domain.interactions.totals(alex.subject);++ assert.strictEqual(totals.external, 1234);++ assert.strictEqual(totals.settled_via_billing, 0);++ });++++ await check('the goal the tip asked for: a Tips goal id, or a Live goal id through the import map', async () => {++ const a = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 900 } })).json.goal;++ const b = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Chair', target_amount: 900 } })).json.goal;++ await t.deliver(receipt({ provider_event_id: 'ext-goal-tips', amount_cents: 100, value_bits: 100, app_purpose: `goal:${a.id}` }));++ t.db.prepare(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, run_id, created_at, updated_at)++ VALUES ('live', 'donation_goals', '12', 'goal', ?, 'imported', 'imp_test', ?, ?)`).run(b.id, new Date().toISOString(), new Date().toISOString());++ await t.deliver(receipt({ provider_event_id: 'ext-goal-live', amount_cents: 200, value_bits: 200, app_purpose: 'goal:12' }));++ await t.deliver(receipt({ provider_event_id: 'ext-goal-none', amount_cents: 300, value_bits: 300 }));++ assert.strictEqual(domain.goals.present(domain.goals.get(a.id)).current_amount, 100);++ assert.strictEqual(domain.goals.present(domain.goals.get(b.id)).current_amount, 200);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions WHERE interaction_id = ?').get(byRef('ext-goal-none').id).n, 0, 'several active goals and no pick: none (Live\'s rule)');++ });++++ await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {++ const bob = t.network.newUser('bob');++ await t.deliver(receipt({ provider_event_id: 'ext-anon', donor_name: null, anonymous: true, message: 'hi' }, { streamer: bob.subject }));++ const i = byRef('ext-anon');++ assert.deepStrictEqual([i.creator_subject, i.supporter_name], [bob.subject, 'Anonymous']);++ await domain.effects.drain();++ const d = live.deliveries.find((x) => x.job.interaction.id === i.id);++ assert.strictEqual(d.job.text, 'Anonymous tipped 500 Vibes: hi (PowerChat)');++ assert.strictEqual(d.job.creator.handle, null);++ });++++ await check('a test receipt never reaches Live chat or a goal', async () => {++ const before = live.deliveries.length;++ await t.deliver(receipt({ provider_event_id: 'ext-test', test: true }));++ await domain.effects.drain();++ const i = byRef('ext-test');++ assert.strictEqual(i.test, 1);++ assert.strictEqual(live.deliveries.length, before);++ assert.strictEqual(t.adapters.test.jobs.filter((j) => j.interaction.id === i.id).length, 1);++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions WHERE interaction_id = ?').get(i.id).n, 0);++ });++++ await check('malformed or foreign receipts are ignored, not recorded', async () => {++ const n = t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n;++ assert.strictEqual((await t.deliver(receipt({ streamer: { type: 'user', id: '42' } }))).json.outcome, 'ignored:no_streamer');++ assert.strictEqual((await t.deliver(receipt({ amount_cents: 1e20 }))).json.outcome, 'ignored:no_amount');++ assert.strictEqual((await t.deliver(receipt({ provider_event_id: '' }))).json.outcome, 'ignored:no_reference');++ assert.strictEqual((await t.deliver({ ...receipt(), source: 'live' })).json.outcome, 'ignored:source');++ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, n);++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -35,7 +35,8 @@ from *interaction delivered*.
@@ -35,7 +35,8 @@ from *interaction delivered*.
## Depends on
## Depends on
- **OpenVibe.Billing** — `POST /api/v1/intents` (checkout), `POST /api/v1/transfers` (a tip from credit),
- **OpenVibe.Billing** — `POST /api/v1/intents` (checkout), `POST /api/v1/transfers` (a tip from credit),
- `billing.transaction.settled|reversed` events
+ `billing.transaction.settled|reversed` events, and `billing.receipt.external` (a tip on a creator's own
+ PowerChat, once Billing receives the PowerChat webhook)
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
- **OpenVibe.Events** — Billing's events in (signed webhook + openvibe-sdk inbox), Tips' events out
(openvibe-sdk transactional outbox)
(openvibe-sdk transactional outbox)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (importer)
- **OpenVibe.Network** — SSO for people, service tokens, JWKS, identity resolve (importer)
@@ -49,8 +50,8 @@ from *interaction delivered*.
@@ -49,8 +50,8 @@ from *interaction delivered*.
fnm exec --using=22.22.1 npm install
fnm exec --using=22.22.1 npm install
cp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …
cp .env.example .env # OV_OAUTH_CLIENT_SECRET, TIPS_FORM_SECRET, TIPS_EVENTS_SECRET, …
npm run dev # http://localhost:4610
npm run dev # http://localhost:4610
-npm test # 6 files: stub Network/Billing/Events/Live, temp DBs, random ports
-npm run subscribe # create the billing.transaction.* subscription in OpenVibe.Events
+npm test # 7 files: stub Network/Billing/Events/Live, temp DBs, random ports
+npm run subscribe # create the billing.transaction.* and billing.receipt.* subscriptions in OpenVibe.Events
node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]
node scripts/import-live.js --live-db <live snapshot> --billing-db <billing snapshot> [--dry-run] [--json]
```
```
@@ -80,7 +81,7 @@ delivery record; a reversal before delivery cancels the queued effects (`tips.in
@@ -80,7 +81,7 @@ delivery record; a reversal before delivery cancels the queued effects (`tips.in
| `credit` | `POST /transfers {from: supporter, to: creator, kind: tip\|paid_interaction, target: {service: tips, type: interaction, id}}`, `Idempotency-Key: tips:transfer:<id>` | the response (the settled event for that transaction is then a no-op) |
| `credit` | `POST /transfers {from: supporter, to: creator, kind: tip\|paid_interaction, target: {service: tips, type: interaction, id}}`, `Idempotency-Key: tips:transfer:<id>` | the response (the settled event for that transaction is then a no-op) |
| `checkout` | `POST /intents {kind: purchase, subject: supporter, bits: amount}`, key `tips:intent:<id>` → checkout URL | the purchase's `billing.transaction.settled` (metadata.intent_id) triggers the transfer above — CREDIT becomes MONEY only by giving it (ADR-012 rule 3) |
| `checkout` | `POST /intents {kind: purchase, subject: supporter, bits: amount}`, key `tips:intent:<id>` → checkout URL | the purchase's `billing.transaction.settled` (metadata.intent_id) triggers the transfer above — CREDIT becomes MONEY only by giving it (ADR-012 rule 3) |
| `provider` / origin `billing` | nothing — a donation settled in Billing that Tips did not start (Live's donate flow, a site-routed PowerChat tip) | recorded once by its Billing transaction id; overlay + goals, no second chat line |
| `provider` / origin `billing` | nothing — a donation settled in Billing that Tips did not start (Live's donate flow, a site-routed PowerChat tip) | recorded once by its Billing transaction id; overlay + goals, no second chat line |
-| `external` | nothing — a tip on the creator's own PowerChat (ADR-012 EXTERNAL) | `POST /api/v1/interactions/external`, once per (provider, provider_ref); never in Billing totals |
+| `external` | nothing — a tip on the creator's own PowerChat (ADR-012 EXTERNAL) | `POST /api/v1/interactions/external`, or Billing's `billing.receipt.external` (origin `billing-external`: Tips posts the chat line — `… tipped N Vibes: msg (PowerChat)` — plus overlay alert and goal, as Live's webhook did); once per (provider, provider_ref); never in Billing totals |
| `none` (simulation) | nothing | at once, `test = 1`: full effect path, no goal contribution, no durable event, excluded from totals |
| `none` (simulation) | nothing | at once, `test = 1`: full effect path, no goal contribution, no durable event, excluded from totals |
**Exactly one logical interaction:** the inbox claims `(consumer, event_id)` in the same SQLite
**Exactly one logical interaction:** the inbox claims `(consumer, event_id)` in the same SQLite
@@ -137,7 +138,13 @@ Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, s
@@ -137,7 +138,13 @@ Produced through the openvibe-sdk outbox (table `event_outbox`, source `tips`, s
change; relayed when `EVENTS_URL` and the client secret are set): `tips.interaction.ready`,
change; relayed when `EVENTS_URL` and the client secret are set): `tips.interaction.ready`,
`tips.interaction.failed`, `tips.interaction.cancelled`, `tips.goal.updated`, `tips.overlay.delivered`,
`tips.interaction.failed`, `tips.interaction.cancelled`, `tips.goal.updated`, `tips.overlay.delivered`,
`tips.overlay.failed`. Simulations and Billing test money produce none. Consumed:
`tips.overlay.failed`. Simulations and Billing test money produce none. Consumed:
-`billing.transaction.settled`, `billing.transaction.reversed` (only from source `billing`).
+`billing.transaction.settled`, `billing.transaction.reversed`, `billing.receipt.external` (only from source
+`billing`). `billing.receipt.external` (payload: `streamer` SubjectRef, `amount_cents`, `value_bits`,
+`donor_name` — null when `anonymous` — `message`, `provider`, `provider_event_id`, `app_purpose`/`app_ref`,
+`test`) is sent by Billing only once it is the money authority (`BILLING_AUTHORITY=billing` in
+billing.env); before that Live's own PowerChat webhook announces those tips, so nothing is announced
+twice. A `goal:<id>` in `app_purpose`/`app_ref` picks the goal: a Tips goal id, or a Live `donation_goals`
+id mapped by the Live import; otherwise Live's rule (the only active goal).
## Overlays
## Overlays
@@ -198,6 +205,7 @@ a copy of Billing's database for the links and totals:
@@ -198,6 +205,7 @@ a copy of Billing's database for the links and totals:
| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |
| the creator can use openvibe.tips with Live offline | pages, API, overlays and settlement need no Live call (`test/pages.test.js`, `test/overlays.test.js` run with no Live); only the `live-chat` adapter talks to Live and its failure never touches payment (`test/delivery.test.js`) — **not yet demonstrated on the real host** |
| creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `test/api.test.js` (totals = the stub Billing payable) |
| creator totals reconcile exactly to Billing | `test/import.test.js` (fixtures in Live's schema and Billing's importer keys; a Billing donation Tips never saw is reported, then reconciles once its event arrives); `test/api.test.js` (totals = the stub Billing payable) |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
| simulation never counted; token revocation immediate; goals from settled only; reversal keeps the delivery record | `test/overlays.test.js`, `test/api.test.js`, `test/settlement.test.js` |
+| an EXTERNAL PowerChat tip Billing announced is celebrated once: Live chat line, overlay, goal; never Billing money | `test/external.test.js` (redelivery and republish, same key as `POST /interactions/external`, Tips and Live goal ids, anonymous, test receipts, malformed payloads) |
## What waits
## What waits
@@ -205,8 +213,11 @@ a copy of Billing's database for the links and totals:
@@ -205,8 +213,11 @@ a copy of Billing's database for the links and totals:
still happen on Live; Tips can only record them from Billing events once Live sends donations as
still happen on Live; Tips can only record them from Billing events once Live sends donations as
Billing transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs
Billing transfers. For PowerChat checkout, Billing answers with a `checkout_ref` and no URL — Tips needs
`TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.
`TIPS_POWERCHAT_LINK_TEMPLATE` or Billing returning the link.
-- **EXTERNAL PowerChat tips** reach Billing's webhook, which records them with no effect and no event;
- until Billing emits one (or Live forwards them with `tips.interaction.record`), Tips does not see them.
+- **EXTERNAL PowerChat tips** arrive as `billing.receipt.external` once Billing is the authority. They need
+ the `billing.receipt.*` subscription (`npm run subscribe`) and `TIPS_CHAT_ADAPTER=live-chat`; Live's
+ `/internal/tips/deliveries` posts the chat line and plays the alert but does not advance **Live's own**
+ `donation_goals` or send its `goal-update`/`goal-reached` frames (Tips' goals and overlay do advance).
+ PowerChat follow/host/channel-points/subscription notices are not forwarded by anyone.
- **Chat**: `live-chat` needs `TIPS_CHAT_ADAPTER=live-chat` after the cutover (Live's route is deployed); OpenVibe.Chat has no public
- **Chat**: `live-chat` needs `TIPS_CHAT_ADAPTER=live-chat` after the cutover (Live's route is deployed); OpenVibe.Chat has no public
paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by
paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by
its test.
its test.
@@ -42,7 +42,8 @@
@@ -42,7 +42,8 @@
],
],
"eventsConsumed": [
"eventsConsumed": [
"billing.transaction.settled",
"billing.transaction.settled",
- "billing.transaction.reversed"
+ "billing.transaction.reversed",
+ "billing.receipt.external"
],
],
"namespacesOwned": [
"namespacesOwned": [
"tips.*"
"tips.*"
@@ -1,10 +1,13 @@
@@ -1,10 +1,13 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
- * Create Tips' OpenVibe.Events subscription for Billing's settlement events.
+ * Create Tips' OpenVibe.Events subscriptions for Billing's events: settlements (billing.transaction.*)
+ * and EXTERNAL receipts (billing.receipt.*, tips on a creator's own PowerChat).
*
*
* node scripts/subscribe.js [--endpoint http://127.0.0.1:4610/internal/events] [--pattern 'billing.transaction.*']
* node scripts/subscribe.js [--endpoint http://127.0.0.1:4610/internal/events] [--pattern 'billing.transaction.*']
*
*
+ * Without --pattern both subscriptions are made (an existing one is reported, not duplicated).
+ *
* Reads the environment (.env or /etc/openvibe/tips.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,
* Reads the environment (.env or /etc/openvibe/tips.env): EVENTS_URL, OV_NETWORK_INTERNAL_URL,
* OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the tips principal needs events.subscription.manage for
* OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the tips principal needs events.subscription.manage for
* audience openvibe.events) and TIPS_EVENTS_SECRET — the delivery signing secret, handed to Events
* audience openvibe.events) and TIPS_EVENTS_SECRET — the delivery signing secret, handed to Events
@@ -21,7 +24,7 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?
@@ -21,7 +24,7 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?
(async () => {
(async () => {
const config = loadConfig();
const config = loadConfig();
const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
const endpoint = opt('endpoint', `http://127.0.0.1:${config.port}/internal/events`);
- const pattern = opt('pattern', 'billing.transaction.*');
+ const patterns = opt('pattern') ? [opt('pattern')] : ['billing.transaction.*', 'billing.receipt.*'];
const secret = config.events.webhookSecrets[0];
const secret = config.events.webhookSecrets[0];
if (!config.events.url) throw new Error('EVENTS_URL is not set');
if (!config.events.url) throw new Error('EVENTS_URL is not set');
if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');
if (!secret || secret.length < 32) throw new Error('TIPS_EVENTS_SECRET must be set (32+ characters) before subscribing');
@@ -30,13 +33,15 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?
@@ -30,13 +33,15 @@ const opt = (name, d) => { const i = args.indexOf(`--${name}`); return i >= 0 ?
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: 'openvibe.events', scope: 'events.subscription.manage',
audience: 'openvibe.events', scope: 'events.subscription.manage',
});
});
- const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
- body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
- });
- const body = await res.json().catch(() => ({}));
- if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); return; }
- if (!res.ok) throw new Error(`Events answered ${res.status}: ${body.code || ''} ${body.detail || ''}`.trim());
- console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);
+ for (const pattern of patterns) {
+ const res = await fetch(`${config.events.url}/api/v1/subscriptions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify({ topic_pattern: pattern, endpoint, secret }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (res.status === 409 && body.subscription_id) { console.log(`subscription exists: ${body.subscription_id} (${pattern} → ${endpoint})`); continue; }
+ if (!res.ok) throw new Error(`Events answered ${res.status} for ${pattern}: ${body.code || ''} ${body.detail || ''}`.trim());
+ console.log(`subscribed: ${body.id} (${pattern} → ${endpoint}). Replay history with POST /api/v1/deliveries/replay { subscription_id, from_seq }.`);
+ }
})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });
})().catch((err) => { console.error(`subscribe failed: ${err.message}`); process.exit(1); });
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS tip_interactions (
@@ -83,7 +83,7 @@ CREATE TABLE IF NOT EXISTS tip_interactions (
legacy_source TEXT, -- 'live:transactions:<id>' etc.
legacy_source TEXT, -- 'live:transactions:<id>' etc.
idempotency_key TEXT UNIQUE,
idempotency_key TEXT UNIQUE,
failure TEXT,
failure TEXT,
- origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | import
+ origin TEXT NOT NULL DEFAULT 'tips', -- tips | billing (settled elsewhere) | external | billing-external (announced by Billing, delivered here) | import
transfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)tried
transfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)tried
transfer_attempts INTEGER NOT NULL DEFAULT 0,
transfer_attempts INTEGER NOT NULL DEFAULT 0,
next_transfer_at INTEGER NOT NULL DEFAULT 0,
next_transfer_at INTEGER NOT NULL DEFAULT 0,
@@ -27,7 +27,8 @@ function createEffects(ctx) {
@@ -27,7 +27,8 @@ function createEffects(ctx) {
creator: { type: 'user', id: i.creator_subject, handle: profile ? profile.handle : null },
creator: { type: 'user', id: i.creator_subject, handle: profile ? profile.handle : null },
supporter: { name, subject: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null },
supporter: { name, subject: i.supporter_subject ? { type: 'user', id: i.supporter_subject } : null },
interaction: { id: i.id, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null },
interaction: { id: i.id, kind: i.kind, amount: i.amount, currency: i.currency, message: i.message || null },
- text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}`,
+ // A tip on the creator's own PowerChat reads as Live's webhook wrote it: "… (PowerChat)".
+ text: `${name} tipped ${amount} Vibes${i.message ? `: ${i.message}` : ''}${i.settlement === 'external' && i.provider === 'powerchat' ? ' (PowerChat)' : ''}`,
target: json(i.target, null),
target: json(i.target, null),
};
};
if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;
if (effect.effect === 'paid_message') job.highlight_seconds = req.highlight_seconds || 0;
@@ -21,6 +21,10 @@
@@ -21,6 +21,10 @@
* a site-routed PowerChat tip) is recorded once, by its Billing transaction id.
* a site-routed PowerChat tip) is recorded once, by its Billing transaction id.
* external a tip on the creator's own PowerChat account: no Billing liability; recorded
* external a tip on the creator's own PowerChat account: no Billing liability; recorded
* once by (provider, provider_ref); never part of the Billing reconciliation.
* once by (provider, provider_ref); never part of the Billing reconciliation.
+ * Two ways in: POST /interactions/external (a service records it), or Billing's
+ * billing.receipt.external once Billing receives the PowerChat webhook — then
+ * nobody else announces it, so Tips delivers the chat line itself (origin
+ * 'billing-external'), exactly as Live's webhook did: chat line + alert, goal.
* simulated the whole effect path with test = 1: no Billing call, no goal contribution,
* simulated the whole effect path with test = 1: no Billing call, no goal contribution,
* no durable event, excluded from every total.
* no durable event, excluded from every total.
*
*
@@ -33,6 +37,11 @@ const { VOICES } = require('./profiles');
@@ -33,6 +37,11 @@ const { VOICES } = require('./profiles');
const { BillingCallError } = require('../billing-client');
const { BillingCallError } = require('../billing-client');
const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
const KINDS = ['tip', 'paid_message', 'tts', 'media_request'];
+// Origins whose chat effects Tips delivers: its own requests, and EXTERNAL tips Billing announced
+// (their only announcement since the PowerChat webhook moved from Live to Billing).
+const ANNOUNCED_HERE = ['tips', 'billing-external'];
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_EXTERNAL_CENTS = 100_000_000; // Billing's per-receipt maximum
// Paid-message highlight time by amount (bits): the bigger the message, the longer it stays up.
// Paid-message highlight time by amount (bits): the bigger the message, the longer it stays up.
const HIGHLIGHT = [[5000, 300], [1000, 120], [500, 60], [100, 30]];
const HIGHLIGHT = [[5000, 300], [1000, 120], [500, 60], [100, 30]];
const highlightFor = (bits) => (HIGHLIGHT.find(([min]) => bits >= min) || [0, 0])[1];
const highlightFor = (bits) => (HIGHLIGHT.find(([min]) => bits >= min) || [0, 0])[1];
@@ -251,7 +260,7 @@ function createInteractions(ctx) {
@@ -251,7 +260,7 @@ function createInteractions(ctx) {
// Chat effects: never for imports (history), never twice for a donation another product already
// Chat effects: never for imports (history), never twice for a donation another product already
// announced (origin billing), never to a real chat room for a simulation.
// announced (origin billing), never to a real chat room for a simulation.
const adapter = test ? 'test' : config.chat.adapter;
const adapter = test ? 'test' : config.chat.adapter;
- if (i.origin === 'tips' && adapter !== 'none') {
+ if (ANNOUNCED_HERE.includes(i.origin) && adapter !== 'none') {
if (i.kind === 'tip') addEffect(i, 'chat_line', adapter);
if (i.kind === 'tip') addEffect(i, 'chat_line', adapter);
if (i.kind === 'paid_message') addEffect(i, 'paid_message', adapter);
if (i.kind === 'paid_message') addEffect(i, 'paid_message', adapter);
if (i.kind === 'tts') { addEffect(i, 'chat_line', adapter); addEffect(i, 'tts', adapter); }
if (i.kind === 'tts') { addEffect(i, 'chat_line', adapter); addEffect(i, 'tts', adapter); }
@@ -379,6 +388,55 @@ function createInteractions(ctx) {
@@ -379,6 +388,55 @@ function createInteractions(ctx) {
return get(id);
return get(id);
}
}
+ /**
+ * The goal a PowerChat tip asked for: its app_purpose/app_ref carries `goal:<id>` — a Tips goal id,
+ * or a Live donation_goals id (links minted by Live), which the Live import mapped to a Tips goal.
+ * goals.contribute() still checks it is the creator's and active, else falls back to Live's rule.
+ */
+ function goalFromPurpose(...refs) {
+ for (const v of refs) {
+ const m = String(v || '').match(/(?:^|[:_-])goal[:_-]?([A-Za-z0-9_]+)/i);
+ if (!m) continue;
+ if (/^\d+$/.test(m[1])) {
+ const map = db.prepare("SELECT target_id FROM migration_maps WHERE source = 'live' AND source_table = 'donation_goals' AND source_id = ? AND status = 'imported'").get(m[1]);
+ if (map && map.target_id) return map.target_id;
+ continue;
+ }
+ return m[1];
+ }
+ return null;
+ }
+
+ /**
+ * Billing announced an EXTERNAL receipt (billing.receipt.external): a tip paid on the creator's own
+ * PowerChat. Inside the inbox transaction. Recorded once by (provider, provider event id) — the same
+ * key POST /interactions/external uses — settled at once (the provider already confirmed it), and
+ * announced by Tips: chat line through the chat adapter, overlay alert, goal contribution.
+ */
+ function onBillingExternal(p) {
+ const provider = String(p.provider || '').toLowerCase();
+ const ref = String(p.provider_event_id || '').trim();
+ if (!/^[a-z][a-z0-9_-]{1,39}$/.test(provider) || !ref || ref.length > 200) return 'ignored:no_reference';
+ const creator = p.streamer && p.streamer.type === 'user' && SUBJECT_RE.test(String(p.streamer.id || '')) ? p.streamer.id : null;
+ if (!creator) return 'ignored:no_streamer';
+ const cents = Number(p.amount_cents);
+ if (!Number.isSafeInteger(cents) || cents < 1 || cents > MAX_EXTERNAL_CENTS) return 'ignored:no_amount';
+ if (db.prepare('SELECT 1 FROM tip_interactions WHERE provider = ? AND provider_ref = ?').get(provider, ref)) return 'duplicate_receipt';
+ // Goals and the chat line count Vibes: the value Billing put on the money (1 bit = 1 cent today).
+ const bits = Number(p.value_bits);
+ const amount = Math.min(Number.isSafeInteger(bits) && bits > 0 ? bits : cents, config.limits.maxBits);
+ const message = p.message ? String(p.message).replace(/\r\n?/g, '\n').trim().slice(0, config.limits.messageChars) || null : null;
+ const goalId = goalFromPurpose(p.app_purpose, p.app_ref);
+ const i = insert({
+ id: prefixedId('tint', ctx.now()), creator_subject: creator, supporter_subject: null,
+ supporter_name: p.anonymous ? 'Anonymous' : (displayName(p.donor_name) || 'Someone'), kind: 'tip', amount, amount_cents: cents,
+ message, request: goalId ? { goal_id: goalId } : {}, funding: 'external', settlement: 'external', payment_state: 'pending',
+ delivery_state: 'awaiting_payment', provider, provider_ref: ref, origin: 'billing-external', test: p.test ? 1 : 0,
+ });
+ settle(i);
+ return 'recorded_external';
+ }
+
// ── External and simulated ───────────────────────────────
// ── External and simulated ───────────────────────────────
/** A tip on the creator's own PowerChat (EXTERNAL, ADR-012): recorded once by provider ref. */
/** A tip on the creator's own PowerChat (EXTERNAL, ADR-012): recorded once by provider ref. */
function recordExternal(profile, input) {
function recordExternal(profile, input) {
@@ -489,7 +547,7 @@ function createInteractions(ctx) {
@@ -489,7 +547,7 @@ function createInteractions(ctx) {
return {
return {
get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,
get, byBillingTxn, validate, request, transfer, startCheckout, processDueTransfers, settleByTransaction, settle, failPayment,
- onBillingSettled, onBillingReversed, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,
+ onBillingSettled, onBillingReversed, onBillingExternal, cancelQueued, recomputeDelivery, recordExternal, simulate, summary, present, list, totals, insert, KINDS,
};
};
}
}
@@ -1,14 +1,18 @@
@@ -1,14 +1,18 @@
'use strict';
'use strict';
/**
/**
- * Billing → Tips: POST /internal/events, the endpoint of Tips' OpenVibe.Events subscription
- * (topic pattern `billing.transaction.*`, created by scripts/subscribe.js).
+ * Billing → Tips: POST /internal/events, the endpoint of Tips' OpenVibe.Events subscriptions
+ * (topic patterns `billing.transaction.*` and `billing.receipt.*`, created by scripts/subscribe.js).
*
*
* billing.transaction.settled a donation targeting a Tips interaction settles it; a donation Tips
* billing.transaction.settled a donation targeting a Tips interaction settles it; a donation Tips
* did not start is recorded once; a purchase that funds a Tips
* did not start is recorded once; a purchase that funds a Tips
* checkout triggers the transfer
* checkout triggers the transfer
* billing.transaction.reversed flips the interaction's payment state; undelivered effects are
* billing.transaction.reversed flips the interaction's payment state; undelivered effects are
* cancelled, delivered ones stay on record
* cancelled, delivered ones stay on record
+ * billing.receipt.external a tip on the creator's own PowerChat (EXTERNAL, no Billing money):
+ * recorded once by (provider, provider event id) and announced by Tips —
+ * chat line, overlay alert, goal. Billing sends it only once it is the
+ * money authority (while Live is, Live's webhook announces the tip)
*
*
* Exactly once, twice over: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite
* Exactly once, twice over: the openvibe-sdk inbox claims (consumer, event_id) in the same SQLite
* transaction as the change, so a redelivered event does nothing; and the Billing transaction id is
* transaction as the change, so a redelivered event does nothing; and the Billing transaction id is
@@ -37,6 +41,7 @@ function consumerRouter({ domain, config, log = console }) {
@@ -37,6 +41,7 @@ function consumerRouter({ domain, config, log = console }) {
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
if (event.event_type === 'billing.transaction.settled') return domain.interactions.onBillingSettled(payload);
if (event.event_type === 'billing.transaction.settled') return domain.interactions.onBillingSettled(payload);
if (event.event_type === 'billing.transaction.reversed') return domain.interactions.onBillingReversed(payload);
if (event.event_type === 'billing.transaction.reversed') return domain.interactions.onBillingReversed(payload);
+ if (event.event_type === 'billing.receipt.external') return domain.interactions.onBillingExternal(payload);
return 'ignored:type';
return 'ignored:type';
});
});
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+// EXTERNAL PowerChat tips announced by Billing (billing.receipt.external): once the PowerChat webhook
+// points at Billing, Live no longer celebrates a tip on a streamer's own PowerChat, so Tips does —
+// the chat line through Live's /internal/tips/deliveries (as Live's webhook wrote it), the overlay
+// alert and the goal — exactly once per provider payment, and never counted as Billing money.
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ live: true });
+ const { domain, live } = t;
+ const alex = await t.creator('alex');
+ console.log('external receipts');
+
+ /** billing.receipt.external as Billing's ops/external.js writes it. */
+ const receipt = (fields = {}, { streamer = alex.subject } = {}) => {
+ const paymentId = fields.provider_event_id || `don-${Math.random().toString(36).slice(2)}`;
+ return {
+ event_id: ids.newId('event'), event_type: 'billing.receipt.external', version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },
+ timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type: 'provider_receipt', id: `powerchat:${paymentId}` },
+ payload: {
+ classification: 'EXTERNAL', provider: 'powerchat', receipt_ref: `powerchat:${paymentId}`, provider_event_id: paymentId, delivery_id: `dlv-${paymentId}`,
+ streamer: { type: 'user', id: streamer }, receiving_account: { provider: 'powerchat', id: 'pc-1', username: 'alexpc' },
+ amount_cents: 500, currency: 'usd-cents', value_bits: 500, donor_name: 'Fan', anonymous: false, message: null,
+ app_ref: null, app_purpose: null, occurred_at: new Date().toISOString(), test: false, rates: { bits_per_usd: 100 }, ...fields,
+ },
+ };
+ };
+ const byRef = (ref) => t.db.prepare("SELECT * FROM tip_interactions WHERE provider = 'powerchat' AND provider_ref = ?").get(ref);
+
+ let first;
+ await check('an EXTERNAL receipt is recorded, settled and announced in Live chat as Live\'s webhook wrote it', async () => {
+ const g = await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'New camera', target_amount: 5000 } });
+ assert.strictEqual(g.status, 201, g.text);
+ first = receipt({ provider_event_id: 'ext-1', amount_cents: 1234, value_bits: 1234, donor_name: 'Generous Fan', message: 'love the stream' });
+ const r = await t.deliver(first);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ assert.strictEqual(r.json.outcome, 'recorded_external');
+ const i = byRef('ext-1');
+ assert.deepStrictEqual([i.creator_subject, i.kind, i.amount, i.amount_cents, i.supporter_name, i.message], [alex.subject, 'tip', 1234, 1234, 'Generous Fan', 'love the stream']);
+ assert.deepStrictEqual([i.funding, i.settlement, i.origin, i.payment_state, i.billing_txn_id], ['external', 'external', 'billing-external', 'settled', null]);
+ await domain.effects.drain();
+ const d = live.deliveries.filter((x) => x.job.interaction.id === i.id);
+ assert.strictEqual(d.length, 1);
+ assert.deepStrictEqual([d[0].key, d[0].job.effect, d[0].job.test], [`${i.id}:chat_line`, 'chat_line', false]);
+ assert.strictEqual(d[0].job.text, 'Generous Fan tipped 1,234 Vibes: love the stream (PowerChat)');
+ assert.deepStrictEqual(d[0].job.creator, { type: 'user', id: alex.subject, handle: 'alex' });
+ const effects = t.db.prepare('SELECT effect, state FROM interaction_effects WHERE interaction_id = ? ORDER BY id').all(i.id);
+ assert.deepStrictEqual(effects, [{ effect: 'overlay_alert', state: 'delivered' }, { effect: 'chat_line', state: 'delivered' }]);
+ const goal = domain.goals.present(domain.goals.get(g.json.goal.id));
+ assert.strictEqual(goal.current_amount, 1234, 'the sole active goal advances, as on Live');
+ assert.ok(t.outboxRows('tips.goal.updated').some((e) => e.payload.interaction_id === i.id));
+ assert.ok(t.outboxRows('tips.interaction.ready').some((e) => e.subject.id === i.id));
+ });
+
+ await check('a redelivery or a republish of the same payment announces nothing more', async () => {
+ assert.strictEqual((await t.deliver(first)).json.duplicate, true);
+ const again = await t.deliver({ ...first, event_id: ids.newId('event') });
+ assert.deepStrictEqual([again.json.duplicate, again.json.outcome], [false, 'duplicate_receipt']);
+ const viaApi = await t.call('POST', '/api/v1/interactions/external', { cap: ['tips.interaction.record'], body: { creator: 'alex', provider: 'powerchat', provider_ref: 'ext-1', amount_cents: 1234 } });
+ assert.strictEqual(viaApi.json.duplicate, true, 'the same key as POST /interactions/external');
+ await domain.effects.drain();
+ assert.strictEqual(t.db.prepare("SELECT COUNT(*) AS n FROM tip_interactions WHERE provider_ref = 'ext-1'").get().n, 1);
+ assert.strictEqual(live.deliveries.filter((x) => x.job.interaction.id === byRef('ext-1').id).length, 1);
+ });
+
+ await check('EXTERNAL money stays out of the Billing total', async () => {
+ const totals = domain.interactions.totals(alex.subject);
+ assert.strictEqual(totals.external, 1234);
+ assert.strictEqual(totals.settled_via_billing, 0);
+ });
+
+ await check('the goal the tip asked for: a Tips goal id, or a Live goal id through the import map', async () => {
+ const a = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 900 } })).json.goal;
+ const b = (await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Chair', target_amount: 900 } })).json.goal;
+ await t.deliver(receipt({ provider_event_id: 'ext-goal-tips', amount_cents: 100, value_bits: 100, app_purpose: `goal:${a.id}` }));
+ t.db.prepare(`INSERT INTO migration_maps (source, source_table, source_id, target_type, target_id, status, run_id, created_at, updated_at)
+ VALUES ('live', 'donation_goals', '12', 'goal', ?, 'imported', 'imp_test', ?, ?)`).run(b.id, new Date().toISOString(), new Date().toISOString());
+ await t.deliver(receipt({ provider_event_id: 'ext-goal-live', amount_cents: 200, value_bits: 200, app_purpose: 'goal:12' }));
+ await t.deliver(receipt({ provider_event_id: 'ext-goal-none', amount_cents: 300, value_bits: 300 }));
+ assert.strictEqual(domain.goals.present(domain.goals.get(a.id)).current_amount, 100);
+ assert.strictEqual(domain.goals.present(domain.goals.get(b.id)).current_amount, 200);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions WHERE interaction_id = ?').get(byRef('ext-goal-none').id).n, 0, 'several active goals and no pick: none (Live\'s rule)');
+ });
+
+ await check('anonymous: no donor name; a creator with no tip page is still announced', async () => {
+ const bob = t.network.newUser('bob');
+ await t.deliver(receipt({ provider_event_id: 'ext-anon', donor_name: null, anonymous: true, message: 'hi' }, { streamer: bob.subject }));
+ const i = byRef('ext-anon');
+ assert.deepStrictEqual([i.creator_subject, i.supporter_name], [bob.subject, 'Anonymous']);
+ await domain.effects.drain();
+ const d = live.deliveries.find((x) => x.job.interaction.id === i.id);
+ assert.strictEqual(d.job.text, 'Anonymous tipped 500 Vibes: hi (PowerChat)');
+ assert.strictEqual(d.job.creator.handle, null);
+ });
+
+ await check('a test receipt never reaches Live chat or a goal', async () => {
+ const before = live.deliveries.length;
+ await t.deliver(receipt({ provider_event_id: 'ext-test', test: true }));
+ await domain.effects.drain();
+ const i = byRef('ext-test');
+ assert.strictEqual(i.test, 1);
+ assert.strictEqual(live.deliveries.length, before);
+ assert.strictEqual(t.adapters.test.jobs.filter((j) => j.interaction.id === i.id).length, 1);
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_goal_contributions WHERE interaction_id = ?').get(i.id).n, 0);
+ });
+
+ await check('malformed or foreign receipts are ignored, not recorded', async () => {
+ const n = t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n;
+ assert.strictEqual((await t.deliver(receipt({ streamer: { type: 'user', id: '42' } }))).json.outcome, 'ignored:no_streamer');
+ assert.strictEqual((await t.deliver(receipt({ amount_cents: 1e20 }))).json.outcome, 'ignored:no_amount');
+ assert.strictEqual((await t.deliver(receipt({ provider_event_id: '' }))).json.outcome, 'ignored:no_reference');
+ assert.strictEqual((await t.deliver({ ...receipt(), source: 'live' })).json.outcome, 'ignored:source');
+ assert.strictEqual(t.db.prepare('SELECT COUNT(*) AS n FROM tip_interactions').get().n, n);
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });