openvibers/openvibe.live Commits
Subscriptions via PowerChat: streamer's own page (no fee, recommended) or OpenVibe's account with a platform fee; independent of the payments master switch; fee excluded from streamer share
Changes
@@@ -5055,7 +5055,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig</div></div>--<script src="/js/vibes.js?v=10" defer></script>++<script src="/js/vibes.js?v=11" defer></script><script src="/js/opencoins.js?v=6" defer></script><script src="/js/cosmetics-ui.js?v=2" defer></script><!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->@@@ -130,15 +130,27 @@ async function _initSubscribe() {const priceLine = document.getElementById('sub-price-line');if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; return; }-- if (!cfg.enabled) { box.innerHTML = '<p class="muted" style="text-align:center">Subscriptions aren’t available right now.</p>'; return; }++ const pc = info.powerchat || {};++ const price = Number(info.priceUsd || cfg.subPriceUsd || 4.99);++ const anyPowerchat = !!(pc.direct || pc.site);++ if (!cfg.enabled && !anyPowerchat) { box.innerHTML = '<p class="muted" style="text-align:center">Subscriptions aren’t available right now.</p>'; return; }let html = '';-- if (cfg.providers && cfg.providers.stripe) {-- html += `<button class="btn btn-lg btn-primary" style="width:100%;margin-top:8px;justify-content:center" onclick="doSubscribe('${username}','stripe')"><i class="fa-solid fa-credit-card"></i> Subscribe with Card (auto-renews)</button>`;++ const name = (typeof esc === 'function' ? esc(username) : String(username || ''));++ if (cfg.paymentsMaster && cfg.providers && cfg.providers.stripe) {++ html += `<button class="btn btn-lg btn-primary" style="width:100%;margin-top:8px;justify-content:center" onclick="doSubscribe('${username}','stripe')"><i class="fa-solid fa-credit-card"></i> Subscribe with Card — $${price.toFixed(2)}/mo (auto-renews)</button>`;}-- html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:var(--accent);color:#111;border:none" onclick="doSubscribe('${username}','bucks')"><i class="fa-solid fa-coins"></i> Subscribe with Vibes</button>`;-- if (cfg.providers && cfg.providers.powerchat) {-- html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:#8b5cf6;color:#fff;border:none" onclick="doSubscribe('${username}','powerchat')"><i class="fa-solid fa-bolt"></i> Subscribe with PowerChat tip</button>`;++ // PowerChat — the streamer's own page first (100% to them, no fee); OpenVibe's++ // account as the fallback/alternative with a small platform fee on top.++ if (pc.direct) {++ html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:#8b5cf6;color:#fff;border:none" onclick="doSubscribe('${username}','powerchat')"><i class="fa-solid fa-bolt"></i> Subscribe via ${name}’s PowerChat — $${price.toFixed(2)}</button>++ <div class="muted" style="font-size:0.78rem;text-align:center;margin-top:4px"><i class="fa-solid fa-star" style="color:#f59e0b"></i> Recommended — no fee, the money goes straight to ${name}.</div>`;++ }++ if (pc.site) {++ const total = Number(pc.siteTotalUsd || price);++ html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:${pc.direct ? 'var(--bg-tertiary)' : '#8b5cf6'};color:${pc.direct ? 'var(--text-primary)' : '#fff'};border:1px solid var(--border)" onclick="doSubscribe('${username}','powerchat_site')"><i class="fa-solid fa-bolt"></i> Subscribe via OpenVibe’s PowerChat — $${total.toFixed(2)}</button>++ <div class="muted" style="font-size:0.78rem;text-align:center;margin-top:4px">Includes a ${Number(pc.feePct || 0)}% platform fee${pc.direct ? ` — subscribe through ${name}’s own PowerChat above to avoid it` : ''}.</div>`;}++ html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:var(--accent);color:#111;border:none" onclick="doSubscribe('${username}','bucks')"><i class="fa-solid fa-coins"></i> Subscribe with Vibes</button>`;// Non-card methods can't be re-charged by the processor, so renewal draws from the// Vibes balance — this toggle covers the Vibes and PowerChat buttons.html += `<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:0.85rem;cursor:pointer">@@@ -1003,6 +1003,7 @@ function initDb() {['bucks_min_purchase_bucks', '100', 'Minimum Vibes purchase (bucks)', 'number'],['sub_price_usd', '4.99', 'Monthly channel subscription price in USD', 'number'],['sub_streamer_share_pct', '70', 'Percent of a subscription that goes to the streamer (as Vibes)', 'number'],++ ['sub_site_route_fee_pct', '10', 'Platform fee (%) added when someone subscribes through OpenVibe\'s PowerChat account instead of the streamer\'s own', 'number'],// PayPal (REST)['paypal_enabled', 'false', 'Enable PayPal', 'boolean'],['paypal_mode', 'sandbox', 'PayPal mode: sandbox | live', 'string'],@@@ -96,20 +96,30 @@ function buildPurchaseLink(order) {// The chosen mode (and the subscriber's auto-renew wish) is persisted on the order's// provider_ref ("direct" | "site", ":renew" suffix) — webhook fulfillment reads it to// decide the streamer share and the sub's auto_renew flag.--function buildSubscribeLink(order, streamerUserId, { autoRenew = 0 } = {}) {++// route: 'direct' (the streamer's own PowerChat — they keep 100%, no fee) or 'site'++// (OpenVibe's PowerChat account — a small platform fee is added on top of the sub price++// and the streamer receives their normal share of the BASE price). 'auto' = direct when++// the streamer has PowerChat connected, else site.++// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".++function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {const suffix = autoRenew ? ':renew' : '';-- // Sub checkouts are PINNED to the sub price (no more underpaid tips to-- // gracefully degrade) and categorized "subscription".const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };-- const direct = _checkoutConn(streamerUserId);++ const direct = route !== 'site' ? _checkoutConn(streamerUserId) : null;if (direct) {db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`, opts), mode: 'direct' };}++ if (route === 'direct') return null; // caller asked for the streamer's page and they have noneconst site = getSiteAccount();if (!site) return null;-- db.updatePaymentOrder(order.id, { provider_ref: `site${suffix}` });-- return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), mode: 'site' };++ const fee = Math.max(0, Math.round(Number(feeCents) || 0));++ db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });++ return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), mode: 'site', feeCents: fee };++}++++/** Which PowerChat subscription routes a streamer currently supports (for the UI). */++function subscribeRoutes(streamerUserId) {++ return { direct: !!_checkoutConn(streamerUserId), site: !!getSiteAccount() };}// Donation: streamer's own page when they have PowerChat (the normal@@@ -177,6 +187,7 @@ function handleAttributedDonation(receivingUserId, data) {// falling through does the right thing; site mode routes explicitly.const mode = String(order.provider_ref || 'site').split(':')[0];const autoRenew = /:renew$/.test(String(order.provider_ref || '')) ? 1 : 0;++ const feeCents = Number((String(order.provider_ref || '').match(/:fee=(\d+)/) || [])[1] || 0);if (usdCents + 1 < (order.amount_cents || 0)) {console.warn(`[PowerChat] sub order ${order.id} underpaid (${_usd(usdCents)} < ${_usd(order.amount_cents)}) — treating as a donation`);if (mode === 'site') { _creditSiteRoutedDonation(order.streamer_id, order.user_id, data); return true; }@@@ -185,7 +196,8 @@ function handleAttributedDonation(receivingUserId, data) {const pay = require('../monetization/payments');db.updatePaymentOrder(order.id, { status: 'paid' });// Streamer-direct: they already hold the cash — no cashout-Vibes share.-- pay.fulfillSubscriptionOrder(db.getPaymentOrderById(order.id), { creditShare: mode !== 'direct', autoRenew });++ // Site route: the streamer's share is computed on the sub price, not on the fee.++ pay.fulfillSubscriptionOrder(db.getPaymentOrderById(order.id), { creditShare: mode !== 'direct', autoRenew, shareBaseCents: Math.max(0, (order.amount_cents || 0) - feeCents) });_notify(order.user_id, 'Subscribed! ⭐', 'Your PowerChat tip was confirmed — your channel subscription is active.');console.log(`[PowerChat] sub order ${order.id} activated via PowerChat (${order.provider_ref}) for user ${order.user_id}`);return true;@@@ -232,7 +244,7 @@ function _notify(userId, title, message) {}module.exports = {-- isAvailable, getSiteAccount, tipLinkFor,++ isAvailable, getSiteAccount, tipLinkFor, subscribeRoutes,buildPurchaseLink, buildSubscribeLink, buildDonateLink,handleAttributedDonation,};@@@ -99,8 +99,11 @@ router.get('/paypal/return', async (req, res) => {// ── Subscriptions ────────────────────────────────────────────router.post('/subscribe', requireAuth, async (req, res) => {-- if (!pay.isEnabled()) return res.status(403).json({ error: 'Payments are not enabled' });const provider = String(req.body.provider || '').toLowerCase();++ // PowerChat subs run on PowerChat's own enablement; the payments master switch only++ // gates the card/PayPal/Vibes-purchase rails.++ const viaPowerchat = provider === 'powerchat' || provider === 'powerchat_site';++ if (!pay.isEnabled() && !viaPowerchat && provider !== 'bucks') return res.status(403).json({ error: 'Payments are not enabled' });const streamer = db.getUserByUsername(String(req.body.streamer || ''));if (!streamer) return res.status(404).json({ error: 'Streamer not found' });if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });@@@ -128,17 +131,31 @@ router.post('/subscribe', requireAuth, async (req, res) => {// holds the money and the streamer gets their normal cashout-Vibes share). The// donation.completed webhook confirms and activates the sub. Renewals come from// the Vibes balance when auto_renew is on (a tip can't be auto-charged).-- if (provider === 'powerchat') {++ if (viaPowerchat) {const autoRenew = req.body.auto_renew ? 1 : 0;++ const checkout = require('../integrations/powerchat-checkout');++ const routes = checkout.subscribeRoutes(streamer.id);++ // 'powerchat' = the streamer's own PowerChat (no fee). 'powerchat_site' = OpenVibe's++ // PowerChat account with the platform routing fee on top. A plain 'powerchat'++ // request for a streamer without PowerChat falls back to the site route (+fee) so++ // older clients keep working — the new UI always asks explicitly.++ const wantSite = provider === 'powerchat_site' || !routes.direct;++ if (wantSite && !routes.site) return res.status(400).json({ error: 'PowerChat payments are not available right now' });++ const feePct = wantSite ? pay._num('sub_site_route_fee_pct', 10) : 0;++ const feeCents = wantSite ? Math.round(amountCents * feePct / 100) : 0;++ const totalCents = amountCents + feeCents;const order = db.createPaymentOrder({user_id: req.user.id, provider: 'powerchat', kind: 'subscription',-- amount_cents: amountCents, streamer_id: streamer.id,++ amount_cents: totalCents, streamer_id: streamer.id,});-- const link = require('../integrations/powerchat-checkout').buildSubscribeLink(order, streamer.id, { autoRenew });++ const link = checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }++ const totalUsd = totalCents / 100;return res.json({-- url: link.url, powerchat: true, amountUsd: priceUsd,-- note: `Tip $${priceUsd.toFixed(2)} on PowerChat — your subscription activates automatically once the tip confirms.`,++ url: link.url, powerchat: true, route: link.mode, amountUsd: totalUsd, feeUsd: feeCents / 100,++ note: link.mode === 'direct'++ ? `Tip $${totalUsd.toFixed(2)} on ${streamer.display_name || streamer.username}'s PowerChat — your subscription activates automatically once the tip confirms.`++ : `Tip $${totalUsd.toFixed(2)} on OpenVibe's PowerChat (includes a $${(feeCents / 100).toFixed(2)} platform fee) — your subscription activates automatically once the tip confirms.`,});}@@@ -174,7 +191,15 @@ router.get('/channel/:username', optionalAuth, (req, res) => {const streamer = db.getUserByUsername(req.params.username);if (!streamer) return res.status(404).json({ error: 'Not found' });const subscribed = req.user ? db.isActiveSubscriber(req.user.id, streamer.id) : false;-- res.json({ subscribed, subscriberCount: db.getActiveSubscriberCount(streamer.id), priceUsd: pay._num('sub_price_usd', 4.99) });++ const priceUsd = pay._num('sub_price_usd', 4.99);++ let powerchat = { direct: false, site: false };++ try { powerchat = require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }++ const feePct = pay._num('sub_site_route_fee_pct', 10);++ const siteFeeUsd = Math.round(priceUsd * 100 * feePct / 100) / 100;++ res.json({++ subscribed, subscriberCount: db.getActiveSubscriberCount(streamer.id), priceUsd,++ powerchat: { ...powerchat, feePct, siteFeeUsd, siteTotalUsd: Math.round((priceUsd + siteFeeUsd) * 100) / 100 },++ });});// Cancel a subscription (Stripe: at period end; bucks: immediate)@@@ -36,8 +36,14 @@ function bucksForUsd(usd) {/** Public provider availability + pricing for the client. */function publicConfig() {const hb = require('./vibes');++ const powerchatAvailable = (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })();return {-- enabled: isEnabled(),++ // "Something can be bought here": the card/PayPal master switch OR PowerChat.++ // (The old flag hid the subscribe modal entirely whenever the master switch was++ // off — even for streamers with PowerChat fully set up.)++ enabled: isEnabled() || powerchatAvailable,++ paymentsMaster: isEnabled(),++ subSiteRouteFeePct: n('sub_site_route_fee_pct', 10),// Bit-style: 100 bucks = $1 cashout. Buy packages carry a per-buck premium.cashoutBucksPerUsd: hb.CASHOUT_BUCKS_PER_USD,packages: hb.BUCKS_PACKAGES,@@@ -52,7 +58,7 @@ function publicConfig() {// payments master switch — it has its own enablement (powerchat_enabled +// the site tips account), so Vibes can be bought via PowerChat even while// the card/PayPal rails are off. Lazy require: circular with this module.-- powerchat: (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),++ powerchat: powerchatAvailable,},stripePublishableKey: s('stripe_publishable_key'),};@@@ -254,7 +260,7 @@ function fulfillBucksOrder(order) {* creditShare=false: the streamer already received the money directly (e.g. a* PowerChat tip on their own page) — activate the sub without minting their* cashout-Vibes share on top. autoRenew: null leaves the sub's flag untouched. */--function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null, creditShare = true, autoRenew = null } = {}) {++function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null, creditShare = true, autoRenew = null, shareBaseCents = null } = {}) {if (!order || !order.streamer_id) return null;const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();// Note BEFORE the upsert whether this subscriber already had a sub row — that makes@@@ -271,7 +277,10 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null,// Pay the streamer their share as Vibes — this is income they received, so// it lands in their cashout balance (the only cashout-able balance).const sharePct = n('sub_streamer_share_pct', 70);-- const streamerBucks = creditShare ? bucksForUsd((order.amount_cents / 100) * (sharePct / 100)) : 0;++ // shareBaseCents: the sub price EXCLUDING any platform routing fee the subscriber++ // paid on top (site-routed PowerChat subs) — the fee is the site's, not shareable.++ const baseCents = shareBaseCents != null ? shareBaseCents : order.amount_cents;++ const streamerBucks = creditShare ? bucksForUsd((baseCents / 100) * (sharePct / 100)) : 0;if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);db.updatePaymentOrder(order.id, { status: 'credited' });// Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —
@@ -5055,7 +5055,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5055,7 +5055,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
</div>
</div>
</div>
-<script src="/js/vibes.js?v=10" defer></script>
+<script src="/js/vibes.js?v=11" defer></script>
<script src="/js/opencoins.js?v=6" defer></script>
<script src="/js/opencoins.js?v=6" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<script src="/js/cosmetics-ui.js?v=2" defer></script>
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
<!-- Broadcast modules — load order matters: state → devices → vods → workspace → core -->
@@ -130,15 +130,27 @@ async function _initSubscribe() {
@@ -130,15 +130,27 @@ async function _initSubscribe() {
const priceLine = document.getElementById('sub-price-line');
const priceLine = document.getElementById('sub-price-line');
if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;
if (priceLine) priceLine.textContent = `$${(info.priceUsd || cfg.subPriceUsd || 4.99).toFixed(2)}/month · ${info.subscriberCount || 0} subscriber${info.subscriberCount === 1 ? '' : 's'}`;
if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; return; }
if (info.subscribed) { box.innerHTML = '<p style="text-align:center;color:#53fc18"><i class="fa-solid fa-circle-check"></i> You’re subscribed!</p>'; return; }
- if (!cfg.enabled) { box.innerHTML = '<p class="muted" style="text-align:center">Subscriptions aren’t available right now.</p>'; return; }
+ const pc = info.powerchat || {};
+ const price = Number(info.priceUsd || cfg.subPriceUsd || 4.99);
+ const anyPowerchat = !!(pc.direct || pc.site);
+ if (!cfg.enabled && !anyPowerchat) { box.innerHTML = '<p class="muted" style="text-align:center">Subscriptions aren’t available right now.</p>'; return; }
let html = '';
let html = '';
- if (cfg.providers && cfg.providers.stripe) {
- html += `<button class="btn btn-lg btn-primary" style="width:100%;margin-top:8px;justify-content:center" onclick="doSubscribe('${username}','stripe')"><i class="fa-solid fa-credit-card"></i> Subscribe with Card (auto-renews)</button>`;
+ const name = (typeof esc === 'function' ? esc(username) : String(username || ''));
+ if (cfg.paymentsMaster && cfg.providers && cfg.providers.stripe) {
+ html += `<button class="btn btn-lg btn-primary" style="width:100%;margin-top:8px;justify-content:center" onclick="doSubscribe('${username}','stripe')"><i class="fa-solid fa-credit-card"></i> Subscribe with Card — $${price.toFixed(2)}/mo (auto-renews)</button>`;
}
}
- html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:var(--accent);color:#111;border:none" onclick="doSubscribe('${username}','bucks')"><i class="fa-solid fa-coins"></i> Subscribe with Vibes</button>`;
- if (cfg.providers && cfg.providers.powerchat) {
- html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:#8b5cf6;color:#fff;border:none" onclick="doSubscribe('${username}','powerchat')"><i class="fa-solid fa-bolt"></i> Subscribe with PowerChat tip</button>`;
+ // PowerChat — the streamer's own page first (100% to them, no fee); OpenVibe's
+ // account as the fallback/alternative with a small platform fee on top.
+ if (pc.direct) {
+ html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:#8b5cf6;color:#fff;border:none" onclick="doSubscribe('${username}','powerchat')"><i class="fa-solid fa-bolt"></i> Subscribe via ${name}’s PowerChat — $${price.toFixed(2)}</button>
+ <div class="muted" style="font-size:0.78rem;text-align:center;margin-top:4px"><i class="fa-solid fa-star" style="color:#f59e0b"></i> Recommended — no fee, the money goes straight to ${name}.</div>`;
+ }
+ if (pc.site) {
+ const total = Number(pc.siteTotalUsd || price);
+ html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:${pc.direct ? 'var(--bg-tertiary)' : '#8b5cf6'};color:${pc.direct ? 'var(--text-primary)' : '#fff'};border:1px solid var(--border)" onclick="doSubscribe('${username}','powerchat_site')"><i class="fa-solid fa-bolt"></i> Subscribe via OpenVibe’s PowerChat — $${total.toFixed(2)}</button>
+ <div class="muted" style="font-size:0.78rem;text-align:center;margin-top:4px">Includes a ${Number(pc.feePct || 0)}% platform fee${pc.direct ? ` — subscribe through ${name}’s own PowerChat above to avoid it` : ''}.</div>`;
}
}
+ html += `<button class="btn btn-lg" style="width:100%;margin-top:8px;justify-content:center;background:var(--accent);color:#111;border:none" onclick="doSubscribe('${username}','bucks')"><i class="fa-solid fa-coins"></i> Subscribe with Vibes</button>`;
// Non-card methods can't be re-charged by the processor, so renewal draws from the
// Non-card methods can't be re-charged by the processor, so renewal draws from the
// Vibes balance — this toggle covers the Vibes and PowerChat buttons.
// Vibes balance — this toggle covers the Vibes and PowerChat buttons.
html += `<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:0.85rem;cursor:pointer">
html += `<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:0.85rem;cursor:pointer">
@@ -1003,6 +1003,7 @@ function initDb() {
@@ -1003,6 +1003,7 @@ function initDb() {
['bucks_min_purchase_bucks', '100', 'Minimum Vibes purchase (bucks)', 'number'],
['bucks_min_purchase_bucks', '100', 'Minimum Vibes purchase (bucks)', 'number'],
['sub_price_usd', '4.99', 'Monthly channel subscription price in USD', 'number'],
['sub_price_usd', '4.99', 'Monthly channel subscription price in USD', 'number'],
['sub_streamer_share_pct', '70', 'Percent of a subscription that goes to the streamer (as Vibes)', 'number'],
['sub_streamer_share_pct', '70', 'Percent of a subscription that goes to the streamer (as Vibes)', 'number'],
+ ['sub_site_route_fee_pct', '10', 'Platform fee (%) added when someone subscribes through OpenVibe\'s PowerChat account instead of the streamer\'s own', 'number'],
// PayPal (REST)
// PayPal (REST)
['paypal_enabled', 'false', 'Enable PayPal', 'boolean'],
['paypal_enabled', 'false', 'Enable PayPal', 'boolean'],
['paypal_mode', 'sandbox', 'PayPal mode: sandbox | live', 'string'],
['paypal_mode', 'sandbox', 'PayPal mode: sandbox | live', 'string'],
@@ -96,20 +96,30 @@ function buildPurchaseLink(order) {
@@ -96,20 +96,30 @@ function buildPurchaseLink(order) {
// The chosen mode (and the subscriber's auto-renew wish) is persisted on the order's
// The chosen mode (and the subscriber's auto-renew wish) is persisted on the order's
// provider_ref ("direct" | "site", ":renew" suffix) — webhook fulfillment reads it to
// provider_ref ("direct" | "site", ":renew" suffix) — webhook fulfillment reads it to
// decide the streamer share and the sub's auto_renew flag.
// decide the streamer share and the sub's auto_renew flag.
-function buildSubscribeLink(order, streamerUserId, { autoRenew = 0 } = {}) {
+// route: 'direct' (the streamer's own PowerChat — they keep 100%, no fee) or 'site'
+// (OpenVibe's PowerChat account — a small platform fee is added on top of the sub price
+// and the streamer receives their normal share of the BASE price). 'auto' = direct when
+// the streamer has PowerChat connected, else site.
+// provider_ref persists the decision: "direct[:renew]" | "site[:fee=<cents>][:renew]".
+function buildSubscribeLink(order, streamerUserId, { autoRenew = 0, route = 'auto', feeCents = 0 } = {}) {
const suffix = autoRenew ? ':renew' : '';
const suffix = autoRenew ? ':renew' : '';
- // Sub checkouts are PINNED to the sub price (no more underpaid tips to
- // gracefully degrade) and categorized "subscription".
const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };
const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };
- const direct = _checkoutConn(streamerUserId);
+ const direct = route !== 'site' ? _checkoutConn(streamerUserId) : null;
if (direct) {
if (direct) {
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`, opts), mode: 'direct' };
return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`, opts), mode: 'direct' };
}
}
+ if (route === 'direct') return null; // caller asked for the streamer's page and they have none
const site = getSiteAccount();
const site = getSiteAccount();
if (!site) return null;
if (!site) return null;
- db.updatePaymentOrder(order.id, { provider_ref: `site${suffix}` });
- return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), mode: 'site' };
+ const fee = Math.max(0, Math.round(Number(feeCents) || 0));
+ db.updatePaymentOrder(order.id, { provider_ref: `site${fee ? `:fee=${fee}` : ''}${suffix}` });
+ return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), mode: 'site', feeCents: fee };
+}
+
+/** Which PowerChat subscription routes a streamer currently supports (for the UI). */
+function subscribeRoutes(streamerUserId) {
+ return { direct: !!_checkoutConn(streamerUserId), site: !!getSiteAccount() };
}
}
// Donation: streamer's own page when they have PowerChat (the normal
// Donation: streamer's own page when they have PowerChat (the normal
@@ -177,6 +187,7 @@ function handleAttributedDonation(receivingUserId, data) {
@@ -177,6 +187,7 @@ function handleAttributedDonation(receivingUserId, data) {
// falling through does the right thing; site mode routes explicitly.
// falling through does the right thing; site mode routes explicitly.
const mode = String(order.provider_ref || 'site').split(':')[0];
const mode = String(order.provider_ref || 'site').split(':')[0];
const autoRenew = /:renew$/.test(String(order.provider_ref || '')) ? 1 : 0;
const autoRenew = /:renew$/.test(String(order.provider_ref || '')) ? 1 : 0;
+ const feeCents = Number((String(order.provider_ref || '').match(/:fee=(\d+)/) || [])[1] || 0);
if (usdCents + 1 < (order.amount_cents || 0)) {
if (usdCents + 1 < (order.amount_cents || 0)) {
console.warn(`[PowerChat] sub order ${order.id} underpaid (${_usd(usdCents)} < ${_usd(order.amount_cents)}) — treating as a donation`);
console.warn(`[PowerChat] sub order ${order.id} underpaid (${_usd(usdCents)} < ${_usd(order.amount_cents)}) — treating as a donation`);
if (mode === 'site') { _creditSiteRoutedDonation(order.streamer_id, order.user_id, data); return true; }
if (mode === 'site') { _creditSiteRoutedDonation(order.streamer_id, order.user_id, data); return true; }
@@ -185,7 +196,8 @@ function handleAttributedDonation(receivingUserId, data) {
@@ -185,7 +196,8 @@ function handleAttributedDonation(receivingUserId, data) {
const pay = require('../monetization/payments');
const pay = require('../monetization/payments');
db.updatePaymentOrder(order.id, { status: 'paid' });
db.updatePaymentOrder(order.id, { status: 'paid' });
// Streamer-direct: they already hold the cash — no cashout-Vibes share.
// Streamer-direct: they already hold the cash — no cashout-Vibes share.
- pay.fulfillSubscriptionOrder(db.getPaymentOrderById(order.id), { creditShare: mode !== 'direct', autoRenew });
+ // Site route: the streamer's share is computed on the sub price, not on the fee.
+ pay.fulfillSubscriptionOrder(db.getPaymentOrderById(order.id), { creditShare: mode !== 'direct', autoRenew, shareBaseCents: Math.max(0, (order.amount_cents || 0) - feeCents) });
_notify(order.user_id, 'Subscribed! ⭐', 'Your PowerChat tip was confirmed — your channel subscription is active.');
_notify(order.user_id, 'Subscribed! ⭐', 'Your PowerChat tip was confirmed — your channel subscription is active.');
console.log(`[PowerChat] sub order ${order.id} activated via PowerChat (${order.provider_ref}) for user ${order.user_id}`);
console.log(`[PowerChat] sub order ${order.id} activated via PowerChat (${order.provider_ref}) for user ${order.user_id}`);
return true;
return true;
@@ -232,7 +244,7 @@ function _notify(userId, title, message) {
@@ -232,7 +244,7 @@ function _notify(userId, title, message) {
}
}
module.exports = {
module.exports = {
- isAvailable, getSiteAccount, tipLinkFor,
+ isAvailable, getSiteAccount, tipLinkFor, subscribeRoutes,
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
buildPurchaseLink, buildSubscribeLink, buildDonateLink,
handleAttributedDonation,
handleAttributedDonation,
};
};
@@ -99,8 +99,11 @@ router.get('/paypal/return', async (req, res) => {
@@ -99,8 +99,11 @@ router.get('/paypal/return', async (req, res) => {
// ── Subscriptions ────────────────────────────────────────────
// ── Subscriptions ────────────────────────────────────────────
router.post('/subscribe', requireAuth, async (req, res) => {
router.post('/subscribe', requireAuth, async (req, res) => {
- if (!pay.isEnabled()) return res.status(403).json({ error: 'Payments are not enabled' });
const provider = String(req.body.provider || '').toLowerCase();
const provider = String(req.body.provider || '').toLowerCase();
+ // PowerChat subs run on PowerChat's own enablement; the payments master switch only
+ // gates the card/PayPal/Vibes-purchase rails.
+ const viaPowerchat = provider === 'powerchat' || provider === 'powerchat_site';
+ if (!pay.isEnabled() && !viaPowerchat && provider !== 'bucks') return res.status(403).json({ error: 'Payments are not enabled' });
const streamer = db.getUserByUsername(String(req.body.streamer || ''));
const streamer = db.getUserByUsername(String(req.body.streamer || ''));
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });
if (streamer.id === req.user.id) return res.status(400).json({ error: 'You cannot subscribe to yourself' });
@@ -128,17 +131,31 @@ router.post('/subscribe', requireAuth, async (req, res) => {
@@ -128,17 +131,31 @@ router.post('/subscribe', requireAuth, async (req, res) => {
// holds the money and the streamer gets their normal cashout-Vibes share). The
// holds the money and the streamer gets their normal cashout-Vibes share). The
// donation.completed webhook confirms and activates the sub. Renewals come from
// donation.completed webhook confirms and activates the sub. Renewals come from
// the Vibes balance when auto_renew is on (a tip can't be auto-charged).
// the Vibes balance when auto_renew is on (a tip can't be auto-charged).
- if (provider === 'powerchat') {
+ if (viaPowerchat) {
const autoRenew = req.body.auto_renew ? 1 : 0;
const autoRenew = req.body.auto_renew ? 1 : 0;
+ const checkout = require('../integrations/powerchat-checkout');
+ const routes = checkout.subscribeRoutes(streamer.id);
+ // 'powerchat' = the streamer's own PowerChat (no fee). 'powerchat_site' = OpenVibe's
+ // PowerChat account with the platform routing fee on top. A plain 'powerchat'
+ // request for a streamer without PowerChat falls back to the site route (+fee) so
+ // older clients keep working — the new UI always asks explicitly.
+ const wantSite = provider === 'powerchat_site' || !routes.direct;
+ if (wantSite && !routes.site) return res.status(400).json({ error: 'PowerChat payments are not available right now' });
+ const feePct = wantSite ? pay._num('sub_site_route_fee_pct', 10) : 0;
+ const feeCents = wantSite ? Math.round(amountCents * feePct / 100) : 0;
+ const totalCents = amountCents + feeCents;
const order = db.createPaymentOrder({
const order = db.createPaymentOrder({
user_id: req.user.id, provider: 'powerchat', kind: 'subscription',
user_id: req.user.id, provider: 'powerchat', kind: 'subscription',
- amount_cents: amountCents, streamer_id: streamer.id,
+ amount_cents: totalCents, streamer_id: streamer.id,
});
});
- const link = require('../integrations/powerchat-checkout').buildSubscribeLink(order, streamer.id, { autoRenew });
+ const link = checkout.buildSubscribeLink(order, streamer.id, { autoRenew, route: wantSite ? 'site' : 'direct', feeCents });
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }
+ const totalUsd = totalCents / 100;
return res.json({
return res.json({
- url: link.url, powerchat: true, amountUsd: priceUsd,
- note: `Tip $${priceUsd.toFixed(2)} on PowerChat — your subscription activates automatically once the tip confirms.`,
+ url: link.url, powerchat: true, route: link.mode, amountUsd: totalUsd, feeUsd: feeCents / 100,
+ note: link.mode === 'direct'
+ ? `Tip $${totalUsd.toFixed(2)} on ${streamer.display_name || streamer.username}'s PowerChat — your subscription activates automatically once the tip confirms.`
+ : `Tip $${totalUsd.toFixed(2)} on OpenVibe's PowerChat (includes a $${(feeCents / 100).toFixed(2)} platform fee) — your subscription activates automatically once the tip confirms.`,
});
});
}
}
@@ -174,7 +191,15 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
@@ -174,7 +191,15 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
const streamer = db.getUserByUsername(req.params.username);
const streamer = 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' });
const subscribed = req.user ? db.isActiveSubscriber(req.user.id, streamer.id) : false;
const subscribed = req.user ? db.isActiveSubscriber(req.user.id, streamer.id) : false;
- res.json({ subscribed, subscriberCount: db.getActiveSubscriberCount(streamer.id), priceUsd: pay._num('sub_price_usd', 4.99) });
+ const priceUsd = pay._num('sub_price_usd', 4.99);
+ let powerchat = { direct: false, site: false };
+ try { powerchat = require('../integrations/powerchat-checkout').subscribeRoutes(streamer.id); } catch { /* */ }
+ const feePct = pay._num('sub_site_route_fee_pct', 10);
+ const siteFeeUsd = Math.round(priceUsd * 100 * feePct / 100) / 100;
+ res.json({
+ subscribed, subscriberCount: db.getActiveSubscriberCount(streamer.id), priceUsd,
+ powerchat: { ...powerchat, feePct, siteFeeUsd, siteTotalUsd: Math.round((priceUsd + siteFeeUsd) * 100) / 100 },
+ });
});
});
// Cancel a subscription (Stripe: at period end; bucks: immediate)
// Cancel a subscription (Stripe: at period end; bucks: immediate)
@@ -36,8 +36,14 @@ function bucksForUsd(usd) {
@@ -36,8 +36,14 @@ function bucksForUsd(usd) {
/** Public provider availability + pricing for the client. */
/** Public provider availability + pricing for the client. */
function publicConfig() {
function publicConfig() {
const hb = require('./vibes');
const hb = require('./vibes');
+ const powerchatAvailable = (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })();
return {
return {
- enabled: isEnabled(),
+ // "Something can be bought here": the card/PayPal master switch OR PowerChat.
+ // (The old flag hid the subscribe modal entirely whenever the master switch was
+ // off — even for streamers with PowerChat fully set up.)
+ enabled: isEnabled() || powerchatAvailable,
+ paymentsMaster: isEnabled(),
+ subSiteRouteFeePct: n('sub_site_route_fee_pct', 10),
// Bit-style: 100 bucks = $1 cashout. Buy packages carry a per-buck premium.
// Bit-style: 100 bucks = $1 cashout. Buy packages carry a per-buck premium.
cashoutBucksPerUsd: hb.CASHOUT_BUCKS_PER_USD,
cashoutBucksPerUsd: hb.CASHOUT_BUCKS_PER_USD,
packages: hb.BUCKS_PACKAGES,
packages: hb.BUCKS_PACKAGES,
@@ -52,7 +58,7 @@ function publicConfig() {
@@ -52,7 +58,7 @@ function publicConfig() {
// payments master switch — it has its own enablement (powerchat_enabled +
// payments master switch — it has its own enablement (powerchat_enabled +
// the site tips account), so Vibes can be bought via PowerChat even while
// the site tips account), so Vibes can be bought via PowerChat even while
// the card/PayPal rails are off. Lazy require: circular with this module.
// the card/PayPal rails are off. Lazy require: circular with this module.
- powerchat: (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),
+ powerchat: powerchatAvailable,
},
},
stripePublishableKey: s('stripe_publishable_key'),
stripePublishableKey: s('stripe_publishable_key'),
};
};
@@ -254,7 +260,7 @@ function fulfillBucksOrder(order) {
@@ -254,7 +260,7 @@ function fulfillBucksOrder(order) {
* creditShare=false: the streamer already received the money directly (e.g. a
* creditShare=false: the streamer already received the money directly (e.g. a
* PowerChat tip on their own page) — activate the sub without minting their
* PowerChat tip on their own page) — activate the sub without minting their
* cashout-Vibes share on top. autoRenew: null leaves the sub's flag untouched. */
* cashout-Vibes share on top. autoRenew: null leaves the sub's flag untouched. */
-function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null, creditShare = true, autoRenew = null } = {}) {
+function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null, creditShare = true, autoRenew = null, shareBaseCents = null } = {}) {
if (!order || !order.streamer_id) return null;
if (!order || !order.streamer_id) return null;
const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
const end = periodEnd || new Date(Date.now() + 31 * 24 * 3600 * 1000).toISOString();
// Note BEFORE the upsert whether this subscriber already had a sub row — that makes
// Note BEFORE the upsert whether this subscriber already had a sub row — that makes
@@ -271,7 +277,10 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null,
@@ -271,7 +277,10 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null,
// Pay the streamer their share as Vibes — this is income they received, so
// Pay the streamer their share as Vibes — this is income they received, so
// it lands in their cashout balance (the only cashout-able balance).
// it lands in their cashout balance (the only cashout-able balance).
const sharePct = n('sub_streamer_share_pct', 70);
const sharePct = n('sub_streamer_share_pct', 70);
- const streamerBucks = creditShare ? bucksForUsd((order.amount_cents / 100) * (sharePct / 100)) : 0;
+ // shareBaseCents: the sub price EXCLUDING any platform routing fee the subscriber
+ // paid on top (site-routed PowerChat subs) — the fee is the site's, not shareable.
+ const baseCents = shareBaseCents != null ? shareBaseCents : order.amount_cents;
+ const streamerBucks = creditShare ? bucksForUsd((baseCents / 100) * (sharePct / 100)) : 0;
if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);
if (streamerBucks > 0) db.addVibesCashout(order.streamer_id, streamerBucks);
db.updatePaymentOrder(order.id, { status: 'credited' });
db.updatePaymentOrder(order.id, { status: 'credited' });
// Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —
// Fire the sub on the streamer's PowerChat overlay (subscriptions:write) —