openvibers/openvibe.live Commits
Subscribe shows what Billing charges: the creator's tier-1 price when they set one on OpenVibe.VIP (Billing tiers, contracts 0.201.0), else Billing's default (#180)
Changes
@@@ -405,17 +405,26 @@ async function mySubscriptions(liveUserId) {}/** { subscribed, subscriberCount } for the channel page. */++/** What Billing charges for Live's subscription (tier 1): the creator's own price when set on VIP, else the default. */++function tierOnePrice(t) {++ if (!t) return null;++ const one = (t.tiers || []).find((x) => Number(x.tier) === 1 && x.status === 'active');++ const cents = one ? Number(one.price_cents) : Number(t.default_price_cents);++ return cents > 0 ? cents : null;++}++async function channelState(streamer, viewer) {const to = await viewerOrNull(() => subjectFor(streamer.id, { role: 'recipient' }));if (!to) return { subscribed: false, subscriberCount: 0 };const me = viewer ? await viewerOrNull(() => subjectFor(viewer.id)) : null;-- const [ent, list] = await Promise.all([++ const [ent, list, tiers] = await Promise.all([me ? await entitlementOf(me, to) : Promise.resolve(null),api.listSubscriptions({ streamer: to, status: 'active' }),++ api.tiers(to).catch(() => null),]);const now = Date.now();const count = (list.subscriptions || []).filter((s) => !s.current_period_end || Date.parse(s.current_period_end) > now).length;-- return { subscribed: !!(ent && ent.active), subscriberCount: count };++ return { subscribed: !!(ent && ent.active), subscriberCount: count, priceCents: tierOnePrice(tiers) };}async function cancelSubscription(req, { id, streamerId }) {@@@ -191,6 +191,8 @@ const api = {/** billing.entitlement.check */listSubscriptions: async (q, o) => await request('GET', `/subscriptions?${new URLSearchParams(Object.entries(q).filter(([, v]) => v != null))}`, o),entitlement: async (subject, streamer, o) => await request('GET', `/entitlements/${enc(subject)}?streamer=${enc(streamer)}`, o),++ // A creator's tier prices (contracts 0.201.0): Live sells tier 1, at the creator's price when they set one on VIP.++ tiers: async (streamer, o) => await request('GET', `/tiers/${enc(streamer)}`, o),/** billing.balance.read */balance: async (subject, o) => await request('GET', `/balances/${enc(subject)}`, o),transactions: async (subject, { limit = 50, cursor } = {}, o) => await request('GET', `/transactions?${new URLSearchParams({ subject, limit: String(limit), ...(cursor ? { cursor } : {}) })}`, o),@@@ -243,15 +243,16 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {router.get('/channel/:username', optionalAuth, async (req, res) => {const streamer = await db.getUserByUsername(req.params.username);if (!streamer) return res.status(404).json({ error: 'Not found' });-- let subscribed = false, subscriberCount = null, billingUnavailable = false;++ let subscribed = false, subscriberCount = null, billingUnavailable = false, billingPriceCents = null;if (money.onBilling()) {-- try { ({ subscribed, subscriberCount } = await billingActions.channelState(streamer, req.user || null)); }++ try { ({ subscribed, subscriberCount, priceCents: billingPriceCents } = await billingActions.channelState(streamer, req.user || null)); }catch { billingUnavailable = true; }} else {subscribed = req.user ? await db.isActiveSubscriber(req.user.id, streamer.id) : false;subscriberCount = await db.getActiveSubscriberCount(streamer.id);}-- const priceUsd = await pay._num('sub_price_usd', 4.99);++ // Under Billing the price is the creator's tier 1 (VIP tiers), which Billing charges; else the site setting.++ const priceUsd = billingPriceCents ? billingPriceCents / 100 : await pay._num('sub_price_usd', 4.99);let powerchat = { direct: false, site: false };try { powerchat = await require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }const feePct = await pay._num('sub_site_route_fee_pct', 10);@@@ -233,6 +233,12 @@ async function check(name, fn) {const ch = await call('GET', '/api/payments/channel/bob', 2);assert.strictEqual(ch.status, 200, ch.text);assert.strictEqual(ch.json.subscribed, true); assert.strictEqual(ch.json.subscriberCount, 1);++ assert.strictEqual(ch.json.priceUsd, 4.99, 'Billing\'s default tier-1 price');++ billing.state.tierOne = 300;++ const priced = await call('GET', '/api/payments/channel/bob', 2);++ assert.strictEqual(priced.json.priceUsd, 3, 'the creator\'s own tier-1 price (set on VIP), which Billing charges');++ assert.strictEqual(priced.json.powerchat.siteTotalUsd, 3.3);++ billing.state.tierOne = 0;const mine = await call('GET', '/api/payments/subscriptions/mine', 2);assert.strictEqual(mine.json.subscriptions[0].streamer_username, 'bob');const sub = billing.subs[0];@@@ -49,7 +49,7 @@ async function startNetwork() {}const CAP = [-- ['POST', /^\/intents$/, 'billing.intent.create'], ['GET', /^\/intents\/[^/]+$/, 'billing.intent.create'], ['POST', /^\/intents\/[^/]+\/capture$/, 'billing.intent.create'],++ ['POST', /^\/intents$/, 'billing.intent.create'], ['GET', /^\/intents\/[^/]+$/, 'billing.intent.create'], ['GET', /^\/tiers\/[^/]+$/, 'billing.intent.create'], ['POST', /^\/intents\/[^/]+\/capture$/, 'billing.intent.create'],['POST', /^\/transfers$/, 'billing.transfer.create'], ['POST', /^\/transfers\/[^/]+\/refund$/, 'billing.transfer.create'],['POST', /^\/recycle$/, 'billing.cashout.request'], ['POST', /^\/cashouts$/, 'billing.cashout.request'],['POST', /^\/subscriptions$/, 'billing.subscription.manage'], ['POST', /^\/subscriptions\/[^/]+\/cancel$/, 'billing.subscription.manage'],@@@ -148,6 +148,10 @@ async function startBilling({ network }) {const s = subs.find((x) => x.subscriber.id === m[1] && x.streamer.id === q.get('streamer') && x.status === 'active' && Date.parse(x.current_period_end) > Date.now());return send(res, 200, { subject: { type: 'user', id: m[1] }, streamer: { type: 'user', id: q.get('streamer') }, active: !!s, expires_at: s ? s.current_period_end : null });}++ if (method === 'GET' && (m = path.match(/^\/tiers\/([^/]+)$/))) {++ // A creator's tier prices (contracts 0.201.0): state.tierOne sets every creator's tier-1 price.++ return send(res, 200, { streamer: { type: 'user', id: m[1] }, tiers: state.tierOne ? [{ tier: 1, price_cents: state.tierOne, status: 'active' }] : [], default_price_cents: 499, min_price_cents: 100, max_price_cents: 50000, max_tiers: 10 });++ }if (method === 'GET' && (m = path.match(/^\/balances\/([^/]+)$/))) {return send(res, 200, { subject: { type: 'user', id: m[1] }, currency: 'vibes-bits', credit: bal(credit, m[1]), payable: bal(payable, m[1]), pending_payouts: 0, payable_value_cents: bal(payable, m[1]), bits_per_usd: 100 });}
@@ -405,17 +405,26 @@ async function mySubscriptions(liveUserId) {
@@ -405,17 +405,26 @@ async function mySubscriptions(liveUserId) {
}
}
/** { subscribed, subscriberCount } for the channel page. */
/** { subscribed, subscriberCount } for the channel page. */
+/** What Billing charges for Live's subscription (tier 1): the creator's own price when set on VIP, else the default. */
+function tierOnePrice(t) {
+ if (!t) return null;
+ const one = (t.tiers || []).find((x) => Number(x.tier) === 1 && x.status === 'active');
+ const cents = one ? Number(one.price_cents) : Number(t.default_price_cents);
+ return cents > 0 ? cents : null;
+}
+
async function channelState(streamer, viewer) {
async function channelState(streamer, viewer) {
const to = await viewerOrNull(() => subjectFor(streamer.id, { role: 'recipient' }));
const to = await viewerOrNull(() => subjectFor(streamer.id, { role: 'recipient' }));
if (!to) return { subscribed: false, subscriberCount: 0 };
if (!to) return { subscribed: false, subscriberCount: 0 };
const me = viewer ? await viewerOrNull(() => subjectFor(viewer.id)) : null;
const me = viewer ? await viewerOrNull(() => subjectFor(viewer.id)) : null;
- const [ent, list] = await Promise.all([
+ const [ent, list, tiers] = await Promise.all([
me ? await entitlementOf(me, to) : Promise.resolve(null),
me ? await entitlementOf(me, to) : Promise.resolve(null),
api.listSubscriptions({ streamer: to, status: 'active' }),
api.listSubscriptions({ streamer: to, status: 'active' }),
+ api.tiers(to).catch(() => null),
]);
]);
const now = Date.now();
const now = Date.now();
const count = (list.subscriptions || []).filter((s) => !s.current_period_end || Date.parse(s.current_period_end) > now).length;
const count = (list.subscriptions || []).filter((s) => !s.current_period_end || Date.parse(s.current_period_end) > now).length;
- return { subscribed: !!(ent && ent.active), subscriberCount: count };
+ return { subscribed: !!(ent && ent.active), subscriberCount: count, priceCents: tierOnePrice(tiers) };
}
}
async function cancelSubscription(req, { id, streamerId }) {
async function cancelSubscription(req, { id, streamerId }) {
@@ -191,6 +191,8 @@ const api = {
@@ -191,6 +191,8 @@ const api = {
/** billing.entitlement.check */
/** billing.entitlement.check */
listSubscriptions: async (q, o) => await request('GET', `/subscriptions?${new URLSearchParams(Object.entries(q).filter(([, v]) => v != null))}`, o),
listSubscriptions: async (q, o) => await request('GET', `/subscriptions?${new URLSearchParams(Object.entries(q).filter(([, v]) => v != null))}`, o),
entitlement: async (subject, streamer, o) => await request('GET', `/entitlements/${enc(subject)}?streamer=${enc(streamer)}`, o),
entitlement: async (subject, streamer, o) => await request('GET', `/entitlements/${enc(subject)}?streamer=${enc(streamer)}`, o),
+ // A creator's tier prices (contracts 0.201.0): Live sells tier 1, at the creator's price when they set one on VIP.
+ tiers: async (streamer, o) => await request('GET', `/tiers/${enc(streamer)}`, o),
/** billing.balance.read */
/** billing.balance.read */
balance: async (subject, o) => await request('GET', `/balances/${enc(subject)}`, o),
balance: async (subject, o) => await request('GET', `/balances/${enc(subject)}`, o),
transactions: async (subject, { limit = 50, cursor } = {}, o) => await request('GET', `/transactions?${new URLSearchParams({ subject, limit: String(limit), ...(cursor ? { cursor } : {}) })}`, o),
transactions: async (subject, { limit = 50, cursor } = {}, o) => await request('GET', `/transactions?${new URLSearchParams({ subject, limit: String(limit), ...(cursor ? { cursor } : {}) })}`, o),
@@ -243,15 +243,16 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {
@@ -243,15 +243,16 @@ router.get('/subscriptions/mine', requireAuth, async (req, res) => {
router.get('/channel/:username', optionalAuth, async (req, res) => {
router.get('/channel/:username', optionalAuth, async (req, res) => {
const streamer = await db.getUserByUsername(req.params.username);
const streamer = await db.getUserByUsername(req.params.username);
if (!streamer) return res.status(404).json({ error: 'Not found' });
if (!streamer) return res.status(404).json({ error: 'Not found' });
- let subscribed = false, subscriberCount = null, billingUnavailable = false;
+ let subscribed = false, subscriberCount = null, billingUnavailable = false, billingPriceCents = null;
if (money.onBilling()) {
if (money.onBilling()) {
- try { ({ subscribed, subscriberCount } = await billingActions.channelState(streamer, req.user || null)); }
+ try { ({ subscribed, subscriberCount, priceCents: billingPriceCents } = await billingActions.channelState(streamer, req.user || null)); }
catch { billingUnavailable = true; }
catch { billingUnavailable = true; }
} else {
} else {
subscribed = req.user ? await db.isActiveSubscriber(req.user.id, streamer.id) : false;
subscribed = req.user ? await db.isActiveSubscriber(req.user.id, streamer.id) : false;
subscriberCount = await db.getActiveSubscriberCount(streamer.id);
subscriberCount = await db.getActiveSubscriberCount(streamer.id);
}
}
- const priceUsd = await pay._num('sub_price_usd', 4.99);
+ // Under Billing the price is the creator's tier 1 (VIP tiers), which Billing charges; else the site setting.
+ const priceUsd = billingPriceCents ? billingPriceCents / 100 : await pay._num('sub_price_usd', 4.99);
let powerchat = { direct: false, site: false };
let powerchat = { direct: false, site: false };
try { powerchat = await require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }
try { powerchat = await require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }
const feePct = await pay._num('sub_site_route_fee_pct', 10);
const feePct = await pay._num('sub_site_route_fee_pct', 10);
@@ -233,6 +233,12 @@ async function check(name, fn) {
@@ -233,6 +233,12 @@ async function check(name, fn) {
const ch = await call('GET', '/api/payments/channel/bob', 2);
const ch = await call('GET', '/api/payments/channel/bob', 2);
assert.strictEqual(ch.status, 200, ch.text);
assert.strictEqual(ch.status, 200, ch.text);
assert.strictEqual(ch.json.subscribed, true); assert.strictEqual(ch.json.subscriberCount, 1);
assert.strictEqual(ch.json.subscribed, true); assert.strictEqual(ch.json.subscriberCount, 1);
+ assert.strictEqual(ch.json.priceUsd, 4.99, 'Billing\'s default tier-1 price');
+ billing.state.tierOne = 300;
+ const priced = await call('GET', '/api/payments/channel/bob', 2);
+ assert.strictEqual(priced.json.priceUsd, 3, 'the creator\'s own tier-1 price (set on VIP), which Billing charges');
+ assert.strictEqual(priced.json.powerchat.siteTotalUsd, 3.3);
+ billing.state.tierOne = 0;
const mine = await call('GET', '/api/payments/subscriptions/mine', 2);
const mine = await call('GET', '/api/payments/subscriptions/mine', 2);
assert.strictEqual(mine.json.subscriptions[0].streamer_username, 'bob');
assert.strictEqual(mine.json.subscriptions[0].streamer_username, 'bob');
const sub = billing.subs[0];
const sub = billing.subs[0];
@@ -49,7 +49,7 @@ async function startNetwork() {
@@ -49,7 +49,7 @@ async function startNetwork() {
}
}
const CAP = [
const CAP = [
- ['POST', /^\/intents$/, 'billing.intent.create'], ['GET', /^\/intents\/[^/]+$/, 'billing.intent.create'], ['POST', /^\/intents\/[^/]+\/capture$/, 'billing.intent.create'],
+ ['POST', /^\/intents$/, 'billing.intent.create'], ['GET', /^\/intents\/[^/]+$/, 'billing.intent.create'], ['GET', /^\/tiers\/[^/]+$/, 'billing.intent.create'], ['POST', /^\/intents\/[^/]+\/capture$/, 'billing.intent.create'],
['POST', /^\/transfers$/, 'billing.transfer.create'], ['POST', /^\/transfers\/[^/]+\/refund$/, 'billing.transfer.create'],
['POST', /^\/transfers$/, 'billing.transfer.create'], ['POST', /^\/transfers\/[^/]+\/refund$/, 'billing.transfer.create'],
['POST', /^\/recycle$/, 'billing.cashout.request'], ['POST', /^\/cashouts$/, 'billing.cashout.request'],
['POST', /^\/recycle$/, 'billing.cashout.request'], ['POST', /^\/cashouts$/, 'billing.cashout.request'],
['POST', /^\/subscriptions$/, 'billing.subscription.manage'], ['POST', /^\/subscriptions\/[^/]+\/cancel$/, 'billing.subscription.manage'],
['POST', /^\/subscriptions$/, 'billing.subscription.manage'], ['POST', /^\/subscriptions\/[^/]+\/cancel$/, 'billing.subscription.manage'],
@@ -148,6 +148,10 @@ async function startBilling({ network }) {
@@ -148,6 +148,10 @@ async function startBilling({ network }) {
const s = subs.find((x) => x.subscriber.id === m[1] && x.streamer.id === q.get('streamer') && x.status === 'active' && Date.parse(x.current_period_end) > Date.now());
const s = subs.find((x) => x.subscriber.id === m[1] && x.streamer.id === q.get('streamer') && x.status === 'active' && Date.parse(x.current_period_end) > Date.now());
return send(res, 200, { subject: { type: 'user', id: m[1] }, streamer: { type: 'user', id: q.get('streamer') }, active: !!s, expires_at: s ? s.current_period_end : null });
return send(res, 200, { subject: { type: 'user', id: m[1] }, streamer: { type: 'user', id: q.get('streamer') }, active: !!s, expires_at: s ? s.current_period_end : null });
}
}
+ if (method === 'GET' && (m = path.match(/^\/tiers\/([^/]+)$/))) {
+ // A creator's tier prices (contracts 0.201.0): state.tierOne sets every creator's tier-1 price.
+ return send(res, 200, { streamer: { type: 'user', id: m[1] }, tiers: state.tierOne ? [{ tier: 1, price_cents: state.tierOne, status: 'active' }] : [], default_price_cents: 499, min_price_cents: 100, max_price_cents: 50000, max_tiers: 10 });
+ }
if (method === 'GET' && (m = path.match(/^\/balances\/([^/]+)$/))) {
if (method === 'GET' && (m = path.match(/^\/balances\/([^/]+)$/))) {
return send(res, 200, { subject: { type: 'user', id: m[1] }, currency: 'vibes-bits', credit: bal(credit, m[1]), payable: bal(payable, m[1]), pending_payouts: 0, payable_value_cents: bal(payable, m[1]), bits_per_usd: 100 });
return send(res, 200, { subject: { type: 'user', id: m[1] }, currency: 'vibes-bits', credit: bal(credit, m[1]), payable: bal(payable, m[1]), pending_payouts: 0, payable_value_cents: bal(payable, m[1]), bits_per_usd: 100 });
}
}