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)
Changes
@@@ -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@@@ -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 = TOcontinue;}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 };@@@ -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);nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -229,6 +229,13 @@ through the SDK inbox (`idempotency_receipts`):
@@ -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` |
| `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` |
| `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
#### Media outcomes over Events
@@ -16,6 +16,10 @@
@@ -16,6 +16,10 @@
* network.user.token_valid_after and network.user.updated → /internal/network-events (server/auth/network-events.js;
* 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
* 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]
* [--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,
* 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.*'];
@@ -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 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_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 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';
const DEFAULT_LIVE_ENV = '/etc/openvibe/live.env';
function parseArgs(argv) {
function parseArgs(argv) {
@@ -44,9 +50,10 @@ function parseArgs(argv) {
@@ -44,9 +50,10 @@ function parseArgs(argv) {
else if (a === '--live-env') o.liveEnv = argv[++i];
else if (a === '--live-env') o.liveEnv = argv[++i];
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--endpoint') o.endpoint = argv[++i];
else if (a === '--network') o.topics = NETWORK_TOPICS;
else if (a === '--network') o.topics = NETWORK_TOPICS;
+ else if (a === '--billing') o.topics = BILLING_TOPICS;
else throw new Error(`unknown argument ${a}`);
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');
if (!o.liveEnv || !o.endpoint) throw new Error('--live-env and --endpoint need a value');
return o;
return o;
}
}
@@ -83,7 +90,8 @@ async function liveToken({ env, fetchImpl }) {
@@ -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 }) {
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(/\/+$/, '');
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).
// 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 (!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)');
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
@@ -131,7 +139,7 @@ async function run({ action = 'create', endpoint = DEFAULT_ENDPOINT, topics = TO
continue;
continue;
}
}
if (!r.ok) throw new Error(`Events answered ${r.status} for ${topic}: ${b.code || ''} ${b.detail || ''}`.trim());
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 });
out.push({ topic, subscription_id: b.id, existed: false, created: true });
}
}
return { action, subscriptions: out };
return { action, subscriptions: out };
@@ -144,4 +152,4 @@ if (require.main === module) {
@@ -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); });
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 };
@@ -676,6 +676,9 @@ app.use('/data/arena', express.static(paths.dir('ARENA_IMAGE_PATH', 'arena'), {
@@ -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);
app.post('/internal/media-events', require('./media-proxy/media-events').handler);
// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.
// Network identity events (signed out everywhere, password changed, banned): Live refuses older tokens.
app.post('/internal/network-events', require('./auth/network-events').handler);
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,
// 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).
// OPENRE_EVENTS_SECRET; server/openre/mirror.js). Also before /internal (no X-Internal-Key).
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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); });