openvibers/openvibe.billing Commits

Direct PowerChat subscriptions settle only on the streamer's own account: intents need receiving_account (imported ones take it from Live's powerchat_connections); a pcsub ref on a tip to any other account (e.g. the subscriber's own) grants nothing and is held for review

OP OpenVibers committed 23 Sept 2026 · b59725e4aa · 6 files, +37 −2


Changes

ddiff --git a/docs/live-cutover.md b/docs/live-cutover.md
iindex 0a84d5a..9b60049 100644
---- a/docs/live-cutover.md
++++ b/docs/live-cutover.md
@@@ -19,7 +19,7 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
| Cashout request / recycle | Live columns | `POST /api/v1/cashouts` / `POST /api/v1/recycle` |
| Cashout approve / deny / pending list (owner) | Live | **409** — decided in Billing's **staff console** (`https://billing.openvibe.network/cashouts`; payout reference required, escrow enforced; ADR-012 rule 10) |
| Subscribe with Vibes | Live columns + `subscriptions` | entitlement check, then `POST /api/v1/subscriptions` (source `credit`) |
--| Subscribe via PowerChat / Stripe | Live order + link | `POST /api/v1/intents` (kind subscription, route `site`/`direct`) |
++| Subscribe via PowerChat / Stripe | Live order + link | `POST /api/v1/intents` (kind subscription, route `site`/`direct`; `direct` also needs `receiving_account` = the streamer's PowerChat username from `powerchat_connections`) |
| Cancel, "my subscriptions", channel "subscribed?" + count | Live `subscriptions` | Billing subscriptions/entitlements (cancel keeps the paid period) |
| Balance, history | Live columns / `transactions` | `GET /api/v1/balances/:subject`, `GET /api/v1/transactions`; **unavailable** (503) when Billing does not answer — never the legacy column |
| Subscriber perks (PowerChat overlay sub badge, AI "subscriber" flag) | `subscriptions` row | Billing entitlement, cached 60 s (served stale at most 10 min while refreshing; unknown = not a subscriber) |
ddiff --git a/server/importer/live.js b/server/importer/live.js
iindex 9f0321d..cea0d9d 100644
---- a/server/importer/live.js
++++ b/server/importer/live.js
@@@ -61,6 +61,10 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
const users = live.prepare(`SELECT id, username, COALESCE(openvibe_bucks_balance, 0) AS credit${hasCashout ? ', COALESCE(openvibe_bucks_cashout_balance, 0) AS payable' : ', 0 AS payable'} FROM users`).all();
const txns = tableExists(live, 'transactions') ? live.prepare('SELECT * FROM transactions ORDER BY id').all() : [];
const orders = tableExists(live, 'payment_orders') ? live.prepare('SELECT * FROM payment_orders ORDER BY id').all() : [];
++ // A direct subscription settles only on the streamer's own PowerChat account (providers/powerchat.js).
++ const pcAccount = new Map(tableExists(live, 'powerchat_connections')
++ ? live.prepare('SELECT user_id, powerchat_username FROM powerchat_connections WHERE powerchat_username IS NOT NULL').all().map(r => [r.user_id, String(r.powerchat_username).toLowerCase()])
++ : []);
const subCols = columns(live, 'subscriptions');
const subs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
let resetAt = null;
@@@ -119,6 +123,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
route = ref.split(':')[0];
fee = Number((ref.match(/:fee=(\d+)/) || [])[1] || 0);
renew = /:renew$/.test(ref) ? 1 : 0;
++ if (route === 'direct' && pcAccount.has(o.streamer_id)) meta.receiving_account = pcAccount.get(o.streamer_id);
ref = null;
report.route_markers++;
}
ddiff --git a/server/ops/intents.js b/server/ops/intents.js
iindex 9c2f092..e1e5e0d 100644
---- a/server/ops/intents.js
++++ b/server/ops/intents.js
@@@ -48,10 +48,18 @@ function create(ctx, input) {
row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);
row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;
row.amount_cents = base + row.fee_cents;
++ // A direct subscription is paid to the streamer's own provider account. Without naming that
++ // account, anyone's tip carrying the ref (to their OWN account) would grant the subscription.
++ if (row.route === 'direct' && provider === 'powerchat') {
++ const acct = String(input.receiving_account || '').trim().toLowerCase();
++ if (!/^[a-z0-9_.-]{1,64}$/.test(acct)) fail(422, 'billing.invalid_input', "a direct PowerChat subscription needs receiving_account (the streamer's PowerChat username)");
++ row.receiving_account = acct;
++ }
} else {
fail(422, 'billing.invalid_input', "kind must be 'purchase' or 'subscription'");
}
row.metadata = { rates: rates.snapshot({ price_tiers: kind === 'purchase' ? rates.priceTiers : undefined, sub_share_pct: rates.subSharePct, site_route_fee_pct: rates.siteRouteFeePct }) };
++ if (row.receiving_account) row.metadata.receiving_account = row.receiving_account;
db.prepare(`INSERT INTO payment_intents (id, provider, provider_ref, kind, subject, streamer_subject, amount_cents, fee_cents, bits, route,
auto_renew, status, metadata, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
ddiff --git a/server/providers/powerchat.js b/server/providers/powerchat.js
iindex 28ed8eb..ed83e85 100644
---- a/server/providers/powerchat.js
++++ b/server/providers/powerchat.js
@@@ -125,6 +125,16 @@ function createPowerchat(cfg, { network } = {}) {
if (!intent || intent.kind !== 'subscription' || !intent.streamer_subject) return { effect: 'none', reason: `subscription checkout ${ref} has no subscription intent` };
const route = intent.route === 'direct' ? 'direct' : 'site';
if (route === 'site' && !onSite) return offSite('site-routed subscription checkout');
++ if (route === 'direct') {
++ // Direct: only a tip on the streamer's own account (named when the intent was made) counts.
++ const expected = String((intent.metadata && intent.metadata.receiving_account) || '').toLowerCase();
++ if (!expected || host !== expected) {
++ return { effect: 'none', review: true, hold: true,
++ reason: expected
++ ? `direct subscription checkout ${ref} was paid to PowerChat account "${host || 'unknown'}", not the streamer's "${expected}" — nothing granted, held for review`
++ : `direct subscription checkout ${ref} names no receiving account — nothing granted, held for review` };
++ }
++ }
if (cents + 1 < intent.amount_cents && intents.settledInLive(intent)) {
return { effect: 'none', reason: `underpaid delivery for ${ref}, which Live already credited before the cutover — review`, review: true };
}
ddiff --git a/test/security.test.js b/test/security.test.js
iindex acd46d1..675e7a6 100644
---- a/test/security.test.js
++++ b/test/security.test.js
@@@ -48,6 +48,18 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(e.json.active, false);
});
++ await check('a direct pcsub needs the streamer\'s receiving account, and a tip elsewhere grants nothing', async () => {
++ const missing = await t.call('POST', '/api/v1/intents', { cap: ['billing.intent.create'], body: { provider: 'powerchat', kind: 'subscription', subject: attacker, streamer: victim, route: 'direct' } });
++ assert.strictEqual(missing.status, 422, missing.text);
++ const i = await intent({ kind: 'subscription', subject: attacker, streamer: victim, route: 'direct', receiving_account: 'victimpc' });
++ const r = await tipTo('attackerpc', { amountUsdCents: 499, appExternalRef: i.checkout_ref });
++ assert.strictEqual(r.json.result.effect, 'none', JSON.stringify(r.json));
++ const e = await t.call('GET', `/api/v1/entitlements/${attacker.id}?streamer=${victim.id}`, { cap: ['billing.entitlement.check'] });
++ assert.strictEqual(e.json.active, false, 'no subscription from a tip to the subscriber\'s own account');
++ const ok = await tipTo('VictimPC', { amountUsdCents: 499, appExternalRef: i.checkout_ref });
++ assert.strictEqual(ok.json.result.effect, 'settled', JSON.stringify(ok.json));
++ });
++
await check('the same refs paid to the site account still settle', async () => {
const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
ddiff --git a/test/webhooks.test.js b/test/webhooks.test.js
iindex 1764359..4398372 100644
---- a/test/webhooks.test.js
++++ b/test/webhooks.test.js
@@@ -80,7 +80,7 @@ const { boot, check, done } = require('./helpers/app');
await check('pcsub direct is EXTERNAL: the entitlement is granted, no money is booked', async () => {
const other = t.user(24);
-- const i = await intent({ kind: 'subscription', subject: other, streamer, route: 'direct' });
++ const i = await intent({ kind: 'subscription', subject: other, streamer, route: 'direct', receiving_account: 'StreamerPC' });
assert.strictEqual(i.amount_cents, 499);
const before = (await t.balances(streamer.id)).payable;
const r = await t.powerchat(donation({ amountUsdCents: 499, appExternalRef: i.checkout_ref }, 'streamerpc'));