openvibers/openvibe.live Commits

Billing's settlements reach people once Billing is the ledger: Vibes credited, Subscribed! (with the PowerChat sub alert), a site-routed PowerChat tip's celebration, and the renewal notices Live's sweeper sent (POST /internal/billing-events, subscribe --billing) (#171)

OP OpenVibers committed 27 days ago · e3b8ca8203 · 5 files, +290 −4

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 5b945165..c65fb78c 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -229,6 +229,13 @@ through the SDK inbox (`idempotency_receipts`):
|---|---|---|---|
| `openre.session.*` | `/internal/openre-events` | `OPENRE_EVENTS_SECRET` | OpenRestream's `scripts/subscribe-live-events.js` |
| `media.vod.*`, `media.clip.*`, `media.storage.*` | `/internal/media-events` | `MEDIA_EVENTS_SECRET` | `scripts/subscribe-media-events.js` |
++| `network.user.*`, `network.follow.*`, `network.subject.merged`, `network.account.*` | `/internal/network-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --network` |
++| `billing.transaction.settled`, `billing.entitlement.changed` | `/internal/billing-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --billing` |
++
++Billing's events act only under `BILLING_AUTHORITY=billing` ([server/monetization/billing-events.js](../server/monetization/billing-events.js)):
++"Vibes credited" to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's
++PowerChat overlay when PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the
++subscription notices Live's renewal sweeper sent in `live` mode (could not auto-renew, renewal failed, ended).
#### Media outcomes over Events
ddiff --git a/scripts/subscribe-media-events.js b/scripts/subscribe-media-events.js
iindex 89b0e293..cecce41e 100644
---- a/scripts/subscribe-media-events.js
++++ b/scripts/subscribe-media-events.js
@@@ -16,6 +16,10 @@
* network.user.token_valid_after and network.user.updated → /internal/network-events (server/auth/network-events.js;
* signed with LIVE_EVENTS_SECRET when set, else MEDIA_EVENTS_SECRET); combines with the flags above
*
++ * sudo node /opt/openvibe.live/current/scripts/subscribe-media-events.js --billing # Billing's
++ * billing.transaction.settled and billing.entitlement.changed → /internal/billing-events
++ * (server/monetization/billing-events.js: notices once Billing is the ledger; LIVE_EVENTS_SECRET as for --network)
++ *
* [--live-env /etc/openvibe/live.env] [--endpoint http://127.0.0.1:3000/internal/media-events]
*
* Env names used (from the Live env file): OV_OAUTH_CLIENT_ID (default live), OV_OAUTH_CLIENT_SECRET,
@@@ -32,6 +36,8 @@ const TOPICS = ['media.vod.*', 'media.clip.*', 'media.storage.*'];
const DEFAULT_ENDPOINT = 'http://127.0.0.1:3000/internal/media-events';
const NETWORK_TOPICS = ['network.user.token_valid_after', 'network.user.updated', 'network.follow.created', 'network.follow.deleted', 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted'];
const NETWORK_ENDPOINT = 'http://127.0.0.1:3000/internal/network-events';
++const BILLING_TOPICS = ['billing.transaction.settled', 'billing.entitlement.changed'];
++const BILLING_ENDPOINT = 'http://127.0.0.1:3000/internal/billing-events';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
function parseArgs(argv) {
@@@ -44,9 +50,10 @@ function parseArgs(argv) {
else if (a === '--live-env') o.liveEnv = argv[++i];
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--network') o.topics = NETWORK_TOPICS;
++ else if (a === '--billing') o.topics = BILLING_TOPICS;
else throw new Error(`unknown argument ${a}`);
}
-- if (o.endpoint === null) o.endpoint = o.topics === NETWORK_TOPICS ? NETWORK_ENDPOINT : DEFAULT_ENDPOINT;
++ if (o.endpoint === null) o.endpoint = o.topics === NETWORK_TOPICS ? NETWORK_ENDPOINT : o.topics === BILLING_TOPICS ? BILLING_ENDPOINT : DEFAULT_ENDPOINT;
if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
return o;
}
@@@ -83,7 +90,8 @@ async function liveToken({ env, fetchImpl }) {
async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, topics = TOPICS, env, fetchImpl = globalThis.fetch, log = console.log }) {
const events = String(env.EVENTS_URL || 'http://127.0.0.1:4300').replace(/\/+$/, '');
// Network events are verified with LIVE_EVENTS_SECRET when set (server/auth/network-events.js).
-- const secret = (topics === NETWORK_TOPICS && env.LIVE_EVENTS_SECRET) || env.MEDIA_EVENTS_SECRET || '';
++ const liveSecret = topics === NETWORK_TOPICS || topics === BILLING_TOPICS;
++ const secret = (liveSecret && env.LIVE_EVENTS_SECRET) || env.MEDIA_EVENTS_SECRET || '';
if (!env.OV_OAUTH_CLIENT_SECRET) throw new Error('OV_OAUTH_CLIENT_SECRET is not set in the Live env file');
if (action === 'create' && secret.length < 32) throw new Error('MEDIA_EVENTS_SECRET must be set in the Live env file first (32+ characters: openssl rand -hex 32)');
@@@ -131,7 +139,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, topics = TO
continue;
}
if (!r.ok) throw new Error(`Events answered ${r.status} for ${topic}: ${b.code || ''} ${b.detail || ''}`.trim());
-- log(`subscribed: ${b.id} (${topic} → ${endpoint}), signed with Live's ${topics === NETWORK_TOPICS && env.LIVE_EVENTS_SECRET ? 'LIVE' : 'MEDIA'}_EVENTS_SECRET`);
++ log(`subscribed: ${b.id} (${topic} → ${endpoint}), signed with Live's ${liveSecret && env.LIVE_EVENTS_SECRET ? 'LIVE' : 'MEDIA'}_EVENTS_SECRET`);
out.push({ topic, subscription_id: b.id, existed: false, created: true });
}
return { action, subscriptions: out };
@@@ -144,4 +152,4 @@ if (require.main === module) {
run({ action: o.action, endpoint: o.endpoint, topics: o.topics, env }).then(() => process.exit(0), (err) => { console.error(`subscribe-media-events: ${err.message}`); process.exit(1); });
}
--module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT, NETWORK_TOPICS, NETWORK_ENDPOINT };
++module.exports = { run, parseArgs, TOPICS, DEFAULT_ENDPOINT, NETWORK_TOPICS, NETWORK_ENDPOINT, BILLING_TOPICS, BILLING_ENDPOINT };
ddiff --git a/server/index.js b/server/index.js
iindex ffc2fc10..b712c6c1 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -676,6 +676,9 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
app.post('/internal/media-events', require('./media-proxy/media-events').handler);
// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.
app.post('/internal/network-events', require('./auth/network-events').handler);
++// OpenVibe.Billing's settlements and subscription changes, told to the people they concern once Billing is the
++// ledger (server/monetization/billing-events.js; LIVE_EVENTS_SECRET, before /internal like the others).
++app.post('/internal/billing-events', require('./monetization/billing-events').handler);
// OpenVibe.Events → Live: OpenRestream session lifecycle mirrored into `streams` (signed delivery,
// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
ddiff --git a/server/monetization/billing-events.js b/server/monetization/billing-events.js
nnew file mode 100644
iindex 00000000..8a46f1eb
---- /dev/null
++++ b/server/monetization/billing-events.js
@@@ -0,0 +1,146 @@
++'use strict';
++/**
++ * OpenVibe.Events → Live: what OpenVibe.Billing settled, told to the people it concerns, once Billing is the ledger
++ * (BILLING_AUTHORITY=billing). In `live` mode Live's own checkout, webhook and renewal sweeper send all of this, so
++ * every event is acknowledged and left alone. Closes items 2 and 5 of Billing's cutover list (docs/live-cutover.md).
++ *
++ * POST /internal/billing-events Live's subscription to billing.transaction.settled and billing.entitlement.changed
++ * (scripts/subscribe-media-events.js --billing), signed with LIVE_EVENTS_SECRET
++ * (else MEDIA_EVENTS_SECRET), signature v2 only.
++ *
++ * settled purchase "Vibes credited" to the buyer
++ * settled subscription "Subscribed!" to the subscriber when a provider paid it (a Vibes-paid one was bought
++ * on the page, which said so); a PowerChat-paid one also fires the sub alert on the
++ * streamer's PowerChat overlay (Live's subscribe route does that for Vibes-paid ones)
++ * settled site-routed tip a PowerChat tip paid to the site account for a creator: the channel's celebration
++ * (chat line, sound, goal, tip history), as integrations/powerchat-webhook.js does
++ * entitlement renewal_insufficient_credit / renewal_failed / expired / grace_ended / stripe_not_renewed
++ * the subscriber's "Subscription update", in the words Live's sweeper used
++ *
++ * Each event acts once: an inbox receipt (consumer live-billing-notices) commits before anything is sent, and a test
++ * transaction never notifies.
++ */
++const db = require('../db/database');
++const money = require('./money-authority');
++
++const CONSUMER = 'live-billing-notices';
++const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const stats = { received: 0, applied: 0, duplicate: 0, ignored: 0, refused: 0 };
++let inbox = null;
++
++function ensureInbox() {
++ if (!inbox) inbox = require('openvibe-sdk/events').createPgInbox(db.getDb());
++ return inbox;
++}
++
++const subjectId = (s) => (s && typeof s === 'object' ? s.id : s);
++
++/** A Network subject → the Live user it signed in as (null when it never did). */
++async function liveUser(subject) {
++ const id = subjectId(subject);
++ if (!SUBJECT_RE.test(String(id || ''))) return null;
++ const row = await db.get("SELECT user_id FROM linked_accounts WHERE service = 'network' AND subject_id = ? ORDER BY id DESC LIMIT 1", [id]);
++ return row ? await db.getUserById(row.user_id) : null;
++}
++
++const nameOf = (u) => (u ? u.display_name || u.username : null);
++const usd = (cents) => `$${(Math.round(Number(cents) || 0) / 100).toFixed(2)}`;
++const day = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };
++
++function notify(user, title, message, url = '/') {
++ const { pushNotification } = require('../utils/notify');
++ pushNotification({ user_id: user.id, type: 'PAYMENT', title, message, url });
++}
++
++/** What to do for one envelope: an async function to run after the receipt commits, or null. */
++async function plan(ev) {
++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
++ if (ev.event_type === 'billing.transaction.settled') {
++ if (p.test) return null;
++ const meta = p.metadata && typeof p.metadata === 'object' ? p.metadata : {};
++ if (p.type === 'purchase') {
++ const buyer = await liveUser(p.to_subject);
++ const bits = Number(meta.bits) || 0;
++ if (!buyer || bits < 1) return null;
++ return async () => notify(buyer, 'Vibes credited 🎉', `${bits.toLocaleString()} Vibes were added to your balance${meta.paid_cents ? ` (${usd(meta.paid_cents)})` : ''}.`);
++ }
++ if (p.type === 'subscription') {
++ if (!p.provider) return null;
++ const [subscriber, streamer] = [await liveUser(p.from_subject), await liveUser(p.to_subject)];
++ if (!subscriber || !streamer) return null;
++ const until = day(meta.period_end);
++ return async () => {
++ if (!meta.renewal) {
++ notify(subscriber, 'Subscribed! ⭐', `Your subscription to ${nameOf(streamer)} is active${until ? ` until ${until}` : ''}.`, `/@${encodeURIComponent(streamer.username)}`);
++ }
++ if (p.provider === 'powerchat') {
++ await require('../integrations/powerchat-platform').forwardSubscription(streamer.id, {
++ subscriberName: nameOf(subscriber), externalId: `billing:${p.transaction_id}`, isResub: !!meta.renewal,
++ });
++ }
++ };
++ }
++ if (p.type === 'donation' && p.provider === 'powerchat' && meta.route === 'site') {
++ const [creator, donor] = [await liveUser(p.to_subject), await liveUser(p.from_subject)];
++ const bits = Number(meta.amount_bits) || 0;
++ if (!creator || bits < 1) return null;
++ return async () => require('../integrations/powerchat-webhook').creditDonationPipeline(creator.id, {
++ eventId: `billing:${p.transaction_id}`, amountUsdCents: bits, donorName: meta.donor_name || nameOf(donor) || 'Someone', message: meta.message || '',
++ }, { fromUserId: donor ? donor.id : null });
++ }
++ return null;
++ }
++ if (ev.event_type === 'billing.entitlement.changed') {
++ const sub = p.subscription && typeof p.subscription === 'object' ? p.subscription : {};
++ const message = {
++ renewal_insufficient_credit: 'Your subscription could not auto-renew: not enough Vibes. Top up and resubscribe!',
++ renewal_failed: `A renewal payment failed${day(p.grace_until) ? `; it is tried again until ${day(p.grace_until)}` : ''}. Your subscription is paused until it goes through.`,
++ stripe_not_renewed: 'Your card subscription was not renewed by Stripe and has ended.',
++ // Someone who cancelled already knows it ends.
++ expired: sub.cancel_at_period_end ? null : 'Your channel subscription has ended. Resubscribe any time!',
++ grace_ended: 'Your channel subscription has ended: the renewal never went through. Resubscribe any time!',
++ }[p.reason];
++ if (!message) return null;
++ const [subscriber, streamer] = [await liveUser(p.subject), await liveUser(p.streamer)];
++ if (!subscriber) return null;
++ return async () => notify(subscriber, 'Subscription update', `${streamer ? nameOf(streamer) + ': ' : ''}${message}`,
++ streamer ? `/@${encodeURIComponent(streamer.username)}` : '/');
++ }
++ return null;
++}
++
++/** Apply one envelope at most once. Returns 'applied' | 'duplicate' | 'ignored:<why>'. */
++async function apply(ev) {
++ if (!ev || !EVENT_ID_RE.test(String(ev.event_id || ''))) return 'ignored:envelope';
++ if (ev.source !== 'billing') return 'ignored:source';
++ if (ev.event_type !== 'billing.transaction.settled' && ev.event_type !== 'billing.entitlement.changed') return 'ignored:type';
++ if (!money.onBilling()) return 'ignored:live-ledger';
++ let after = null;
++ const out = await ensureInbox().once(CONSUMER, `billing:${ev.event_id}`, async () => { after = await plan(ev); return after ? 'notify' : 'nothing'; });
++ if (out.duplicate) return 'duplicate';
++ if (after) { try { await after(); } catch (e) { console.warn(`[BillingEvents] ${ev.event_type}:`, e.message); } }
++ return 'applied';
++}
++
++/** Express handler (needs req.rawBody from the express.json verify hook). */
++async function handler(req, res) {
++ const key = require('../auth/network-events').secret();
++ if (key.length < 32) return res.status(503).json({ error: 'LIVE_EVENTS_SECRET is not set' });
++ const { parseDelivery } = require('openvibe-sdk/events');
++ const delivery = parseDelivery(req.rawBody, req.headers, key, { requireV2: true });
++ if (!delivery) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
++ stats.received++;
++ try {
++ const out = await apply(delivery.event);
++ if (out === 'applied') stats.applied++; else if (out === 'duplicate') stats.duplicate++; else stats.ignored++;
++ return res.status(204).end();
++ } catch (e) {
++ console.error(`[BillingEvents] ${delivery.event && delivery.event.event_type} failed:`, e.message);
++ return res.status(500).json({ error: 'not applied; retry' });
++ }
++}
++
++function _reset() { inbox = null; }
++
++module.exports = { handler, apply, stats, CONSUMER, _reset };
ddiff --git a/test/billing-events.test.js b/test/billing-events.test.js
nnew file mode 100644
iindex 00000000..03203442
---- /dev/null
++++ b/test/billing-events.test.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * Billing's settlements told to people once Billing is the ledger (server/monetization/billing-events.js, items 2 and
++ * 5 of OpenVibe.Billing's cutover list): "Vibes credited", "Subscribed!" with the PowerChat sub alert, the channel's
++ * celebration for a site-routed PowerChat tip, and the subscription notices Live's renewal sweeper used to send. In
++ * `live` mode Live sends those itself, so the events are acknowledged and left alone; a redelivery sends nothing twice.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/billing-events.test.js
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++
++process.env.NODE_ENV = 'test';
++delete process.env.BILLING_AUTHORITY;
++process.env.LIVE_EVENTS_SECRET = crypto.randomBytes(32).toString('hex');
++const quiet = console.log;
++console.log = () => {};
++console.warn = () => {};
++
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const ulid = () => Array.from(crypto.randomBytes(26), (b) => CROCKFORD[b % 32]).join('');
++const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1', bob: 'usr_01JAB2C3D4E5F6G7H8J9K0MNB2' };
++
++(async () => {
++ const db = require('../server/db/database');
++ await db.initDb();
++ const raw = db.getDb();
++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE
++ VALUES (2, 'ann', 'Ann', 'ann@x', 'x', 'user'), (3, 'bob', 'Bob', 'bob@x', 'x', 'streamer')`).run();
++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (2, 'network', '102', ?), (3, 'network', '103', ?)").run(SID.ann, SID.bob);
++
++ const sent = [];
++ require('../server/utils/notify').pushNotification = (n) => { sent.push(n); };
++ const subAlerts = [];
++ require('../server/integrations/powerchat-platform').forwardSubscription = async (streamerId, o) => { subAlerts.push([streamerId, o]); };
++ const lines = [];
++ const delivery = require('../server/chat/chat-delivery');
++ delivery.message = async (m) => { lines.push(m); return 1; };
++ delivery.event = async () => {};
++
++ const express = require('express');
++ const app = express();
++ app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));
++ const events = require('../server/monetization/billing-events');
++ app.post('/internal/billing-events', events.handler);
++ const server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
++ const { signDeliveryHeaders } = require('openvibe-sdk/events');
++ const post = async (envelope, secret = process.env.LIVE_EVENTS_SECRET) => {
++ const body = JSON.stringify({ event: envelope });
++ const res = await fetch(`http://127.0.0.1:${server.address().port}/internal/billing-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret) }, body });
++ return res.status;
++ };
++ const envelope = (type, payload, extra = {}) => ({
++ event_id: `evt_${ulid()}`, event_type: type, version: 1, source: 'billing', actor: { type: 'service', id: 'billing' },
++ timestamp: new Date().toISOString(), priority: 'important', visibility: 'internal', subject: { type: 'transaction', id: 'txn_x' }, payload, ...extra,
++ });
++ const settled = (type, extra = {}) => envelope('billing.transaction.settled', {
++ transaction_id: `txn_${ulid()}`, type, test: false, from_subject: null, to_subject: SID.ann, provider: 'powerchat', metadata: {}, ...extra,
++ });
++ const entitlement = (reason, extra = {}) => envelope('billing.entitlement.changed', {
++ subject: { type: 'user', id: SID.ann }, streamer: { type: 'user', id: SID.bob }, kind: 'channel_subscription', active: false, expires_at: null,
++ subscription: { id: `sub_${ulid()}`, status: 'expired', auto_renew: 1, cancel_at_period_end: 0, provider: null }, reason, ...extra,
++ });
++
++ // Live is the ledger: its own checkout and sweeper notify, so Billing's events change nothing.
++ const purchase = settled('purchase', { metadata: { bits: 500, paid_cents: 500 } });
++ assert.strictEqual(await post(purchase), 204);
++ assert.strictEqual(await events.apply(purchase), 'ignored:live-ledger');
++ assert.deepStrictEqual(sent, []);
++
++ process.env.BILLING_AUTHORITY = 'billing';
++ assert.strictEqual(await post(purchase, 'x'.repeat(64)), 401, 'a delivery signed with another secret is refused');
++ assert.strictEqual(await post(purchase), 204);
++ assert.deepStrictEqual(sent.map((n) => [n.user_id, n.title, n.message]), [[2, 'Vibes credited 🎉', '500 Vibes were added to your balance ($5.00).']]);
++ assert.strictEqual(await post(purchase), 204);
++ assert.strictEqual(sent.length, 1, 'a redelivery sends nothing again');
++ assert.strictEqual(await post(settled('purchase', { test: true, metadata: { bits: 100 } })), 204);
++ assert.strictEqual(sent.length, 1, 'a test purchase notifies nobody');
++ assert.strictEqual(await events.apply({ ...settled('purchase', { metadata: { bits: 100 } }), source: 'live' }), 'ignored:source');
++
++ // A subscription PowerChat paid: "Subscribed!" and the sub alert on Bob's PowerChat overlay; a renewal only the alert.
++ sent.length = 0;
++ const sub = settled('subscription', { from_subject: SID.ann, to_subject: SID.bob, metadata: { renewal: false, period_end: '2026-11-16T12:00:00Z' } });
++ await post(sub);
++ assert.deepStrictEqual(sent.map((n) => [n.user_id, n.title, n.message, n.url]), [[2, 'Subscribed! ⭐', 'Your subscription to Bob is active until 2026-11-16.', '/@bob']]);
++ assert.deepStrictEqual(subAlerts, [[3, { subscriberName: 'Ann', externalId: `billing:${sub.payload.transaction_id}`, isResub: false }]]);
++ await post(settled('subscription', { from_subject: SID.ann, to_subject: SID.bob, metadata: { renewal: true } }));
++ assert.strictEqual(sent.length, 1, 'a renewal is not announced to the subscriber');
++ assert.strictEqual(subAlerts.length, 2);
++ assert.strictEqual(subAlerts[1][1].isResub, true);
++ await post(settled('subscription', { from_subject: SID.ann, to_subject: SID.bob, provider: null, metadata: { renewal: false } }));
++ assert.strictEqual(sent.length, 1, 'a Vibes-paid subscription was bought on the page, which said so');
++ assert.strictEqual(subAlerts.length, 2, 'and Live\'s subscribe route fired its alert');
++
++ // A PowerChat tip paid to the site account for Bob: Bob's channel celebrates it and keeps it in its tip history.
++ const tip = settled('donation', { from_subject: SID.ann, to_subject: SID.bob, metadata: { kind: 'tip', route: 'site', amount_bits: 300, donor_name: 'Ann' } });
++ await post(tip);
++ await post(tip);
++ assert.strictEqual(lines.filter((l) => l.message_type === 'donation').length, 1, 'one chat line, however often it is delivered');
++ assert.match(lines[0].message, /^Ann tipped 300 Vibes/);
++ assert.deepStrictEqual(await raw.prepare('SELECT to_user_id, from_user_id, amount, source FROM channel_tips WHERE ref = ?').get(`powerchat:billing:${tip.payload.transaction_id}`),
++ { to_user_id: 3, from_user_id: 2, amount: 300, source: 'powerchat' });
++ await post(settled('donation', { from_subject: SID.ann, to_subject: SID.bob, provider: null, metadata: { amount_bits: 50 } }));
++ assert.strictEqual(lines.length, 1, 'an on-site Vibes donation was celebrated by the donate route already');
++
++ // Subscription notices, in the words Live's sweeper used.
++ sent.length = 0;
++ await post(entitlement('renewal_insufficient_credit'));
++ await post(entitlement('expired', { subscription: { id: `sub_${ulid()}`, status: 'expired', auto_renew: 0, cancel_at_period_end: 1, provider: null } }));
++ await post(entitlement('expired'));
++ await post(entitlement('renewal_failed', { grace_until: '2026-10-20T00:00:00Z' }));
++ await post(entitlement('granted', { active: true }));
++ assert.deepStrictEqual(sent.map((n) => [n.user_id, n.title, n.message, n.url]), [
++ [2, 'Subscription update', 'Bob: Your subscription could not auto-renew: not enough Vibes. Top up and resubscribe!', '/@bob'],
++ [2, 'Subscription update', 'Bob: Your channel subscription has ended. Resubscribe any time!', '/@bob'],
++ [2, 'Subscription update', 'Bob: A renewal payment failed; it is tried again until 2026-10-20. Your subscription is paused until it goes through.', '/@bob'],
++ ], 'someone who cancelled is not told it ended, and a granted period needs no notice');
++
++ server.close();
++ quiet('billing events: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });