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

OP OpenVibers committed 23 Sept 2026 · 6d14574503 · 8 files, +226 −25


Changes

ddiff --git a/README.md b/README.md
iindex 4a295b6..5775fec 100644
---- a/README.md
++++ b/README.md
@@@ -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 install
cp .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.Events
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
| `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`, s
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.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 as
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.
--- **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
paid-message/TTS capability yet. The Live patch's media-request path (yt-dlp/oEmbed) is not covered by
its test.
ddiff --git a/docs/service-manifest-proposal.json b/docs/service-manifest-proposal.json
iindex f047c1d..e1d3ecf 100644
---- a/docs/service-manifest-proposal.json
++++ b/docs/service-manifest-proposal.json
@@@ -42,7 +42,8 @@
],
"eventsConsumed": [
"billing.transaction.settled",
-- "billing.transaction.reversed"
++ "billing.transaction.reversed",
++ "billing.receipt.external"
],
"namespacesOwned": [
"tips.*"
ddiff --git a/scripts/subscribe.js b/scripts/subscribe.js
iindex 9bb61c2..bb341d4 100644
---- a/scripts/subscribe.js
++++ b/scripts/subscribe.js
@@@ -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); });
ddiff --git a/server/db.js b/server/db.js
iindex 9c509ff..86546ac 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -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) | import
transfer_due INTEGER NOT NULL DEFAULT 0, -- a Billing transfer still has to be (re)tried
transfer_attempts INTEGER NOT NULL DEFAULT 0,
next_transfer_at INTEGER NOT NULL DEFAULT 0,
ddiff --git a/server/domain/effects.js b/server/domain/effects.js
iindex a91e7b2..75a0b1e 100644
---- a/server/domain/effects.js
++++ b/server/domain/effects.js
@@@ -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;
ddiff --git a/server/domain/interactions.js b/server/domain/interactions.js
iindex 78fc687..3916bfa 100644
---- a/server/domain/interactions.js
++++ b/server/domain/interactions.js
@@@ -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,
};
}
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex b81bfd6..cd1833c 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -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 };
ddiff --git a/test/external.test.js b/test/external.test.js
nnew file mode 100644
iindex 0000000..a1eb55c
---- /dev/null
++++ b/test/external.test.js
@@@ -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); });