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
Changes
@@@ -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) |@@@ -61,6 +61,10 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = cconst 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 = croute = 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++;}@@@ -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,@@@ -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 };}@@@ -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));@@@ -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'));
@@ -19,7 +19,7 @@ default**: applied and deployed with `BILLING_AUTHORITY` unset, Live behaves exa
@@ -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 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) |
| 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 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) |
| 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 |
| 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) |
| 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) |
@@ -61,6 +61,10 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -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 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 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() : [];
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 subCols = columns(live, 'subscriptions');
const subs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
const subs = subCols.length ? live.prepare('SELECT * FROM subscriptions ORDER BY id').all() : [];
let resetAt = null;
let resetAt = null;
@@ -119,6 +123,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
@@ -119,6 +123,7 @@ async function importLive(ctx, { live, resolveLiveUsers, dryRun = false, log = c
route = ref.split(':')[0];
route = ref.split(':')[0];
fee = Number((ref.match(/:fee=(\d+)/) || [])[1] || 0);
fee = Number((ref.match(/:fee=(\d+)/) || [])[1] || 0);
renew = /:renew$/.test(ref) ? 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;
ref = null;
report.route_markers++;
report.route_markers++;
}
}
@@ -48,10 +48,18 @@ function create(ctx, input) {
@@ -48,10 +48,18 @@ function create(ctx, input) {
row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);
row.route = input.route === 'direct' ? 'direct' : (provider === 'powerchat' ? 'site' : null);
row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;
row.fee_cents = row.route === 'site' ? rates.siteFeeCents(base) : 0;
row.amount_cents = base + row.fee_cents;
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 {
} else {
fail(422, 'billing.invalid_input', "kind must be 'purchase' or 'subscription'");
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 }) };
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,
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)
auto_renew, status, metadata, created_at, updated_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)`).run(row.id, provider, kind, subject, row.streamer_subject,
@@ -125,6 +125,16 @@ function createPowerchat(cfg, { network } = {}) {
@@ -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` };
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';
const route = intent.route === 'direct' ? 'direct' : 'site';
if (route === 'site' && !onSite) return offSite('site-routed subscription checkout');
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)) {
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 };
return { effect: 'none', reason: `underpaid delivery for ${ref}, which Live already credited before the cutover — review`, review: true };
}
}
@@ -48,6 +48,18 @@ const { boot, check, done } = require('./helpers/app');
@@ -48,6 +48,18 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(e.json.active, false);
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 () => {
await check('the same refs paid to the site account still settle', async () => {
const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
const r = await tipTo('openvibe', { amountUsdCents: 250, appExternalRef: `pcdon:${attackerLiveId}:0` });
assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
assert.strictEqual(r.json.result.effect, 'settled', JSON.stringify(r.json));
@@ -80,7 +80,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 () => {
await check('pcsub direct is EXTERNAL: the entitlement is granted, no money is booked', async () => {
const other = t.user(24);
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);
assert.strictEqual(i.amount_cents, 499);
const before = (await t.balances(streamer.id)).payable;
const before = (await t.balances(streamer.id)).payable;
const r = await t.powerchat(donation({ amountUsdCents: 499, appExternalRef: i.checkout_ref }, 'streamerpc'));
const r = await t.powerchat(donation({ amountUsdCents: 499, appExternalRef: i.checkout_ref }, 'streamerpc'));