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

OP OpenVibers committed 27 Aug 2026 · 45fca534e3 · 6 files, +85 −26


Changes

ddiff --git a/public/index.html b/public/index.html
iindex 3ffae2a8..5370e8b0 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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 -->
ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex dbf40797..a2c0f88f 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -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">
ddiff --git a/server/db/database.js b/server/db/database.js
iindex c8ba1674..57d411f1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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'],
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex d68b90ea..38e3defc 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -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 none
const 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,
};
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex 9ffa60e3..99d60cdc 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -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)
ddiff --git a/server/monetization/payments.js b/server/monetization/payments.js
iindex c60d1e77..3631e389 100644
---- a/server/monetization/payments.js
++++ b/server/monetization/payments.js
@@@ -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) —