openvibers/openvibe.live Commits

Vibes/PowerChat money flows redesigned: balance-first donate, buy anywhere

OP OpenVibers committed 27 Aug 2026 · 5eadceaaab · 8 files, +87 −32

The donate button is now Vibes-only and balance-first: opening it with an
empty balance sends the viewer straight to Buy Vibes (with a toast), and
the modal always shows the balance plus a Buy Vibes shortcut. Buying
Vibes works on ANY channel and the PowerChat purchase rail runs on its
own enablement — no longer hidden behind the payments master switch that
gates the card/PayPal rails. Site-account tips NEVER credit a streamer
directly any more (pcdon links stop being minted; the webhook handler
stays only for links in flight): money through the site account is always
a Vibes purchase for the tipper, who then donates from their balance.
Direct PowerChat tipping (skipping Vibes entirely) is a separate,
clearly-labeled option that only appears when THE streamer has their own
PowerChat connected — and those links now always carry an app_ref, since
attribution fields (including the goal pick riding in app_purpose) only
echo back for real checkout links; a purpose-only link could lose the
goal. The goal picker notes it applies to both rails.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/index.html b/public/index.html
iindex 3b3411fc..5e54f658 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4995,7 +4995,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=125" defer></script>
++<script src="/js/app.js?v=126" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@@ -5051,7 +5051,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
</div>
</div>
--<script src="/js/vibes.js?v=8" defer></script>
++<script src="/js/vibes.js?v=9" 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/app.js b/public/js/app.js
iindex 7d1fbf6b..16939c9f 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -654,6 +654,8 @@ function showModal(id) {
overlay.classList.add('show');
if (id === 'buy-funds' && typeof _initBuyBucks === 'function') _initBuyBucks();
if (id === 'donate' && typeof _loadDonateGoals === 'function') _loadDonateGoals();
++ // Balance-first donate flow + the streamer's direct-PowerChat option.
++ if (id === 'donate' && typeof _initDonateModal === 'function') _initDonateModal();
}
function closeModal() {
ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex 1ea46785..dcd0bf3d 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -47,7 +47,9 @@ async function _initBuyBucks() {
if (!box) return;
let cfg;
try { cfg = await api('/payments/config'); } catch { cfg = null; }
-- if (!cfg || !cfg.enabled) {
++ // PowerChat purchases have their own enablement — the buy modal stays usable on
++ // every channel even while the card/PayPal rails (payments master switch) are off.
++ if (!cfg || (!cfg.enabled && !(cfg.providers && cfg.providers.powerchat))) {
box.innerHTML = '<p class="muted" style="font-size:0.85rem;text-align:center">Purchases aren’t available right now.</p>';
const pkgBox = document.getElementById('buy-packages'); if (pkgBox) pkgBox.innerHTML = '';
return;
@@@ -208,19 +210,54 @@ function openvibeBucksDonateModal() {
<div class="form-group" id="donate-goal-wrap" style="display:none">
<label>Put it toward a goal</label>
<select id="modal-donate-goal" class="form-input"></select>
++ <div class="muted" style="font-size:0.75rem;margin-top:3px">Applies to Vibes donations AND PowerChat tips.</div>
</div>
-- <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:8px">
-- <i class="fa-solid fa-coins"></i> Donate
++ <div class="muted" id="donate-balance-line" style="font-size:0.82rem;margin:2px 0 6px"></div>
++ <button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:4px">
++ <i class="fa-solid fa-coins"></i> Donate Vibes
</button>
-- <button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;margin-top:8px;background:#8b5cf6;color:#fff;border:none">
-- <i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
-- </button>`;
++ <button class="btn btn-outline" onclick="showModal('buy-funds')" style="width:100%;margin-top:8px">
++ <i class="fa-solid fa-cart-plus"></i> Buy Vibes
++ </button>
++ <!-- Only shown when THIS streamer has their own PowerChat (money goes to them
++ directly, no Vibes involved). Populated by _initDonatePowerchat. -->
++ <div id="donate-powerchat-direct" style="display:none">
++ <div class="muted" style="text-align:center;font-size:0.75rem;margin:10px 0 4px">— or skip Vibes entirely —</div>
++ <button class="btn btn-lg" onclick="doPowerchatTip()" style="width:100%;background:#8b5cf6;color:#fff;border:none">
++ <i class="fa-solid fa-bolt"></i> Tip real money via PowerChat
++ </button>
++ </div>`;
++}
++
++// Donate modal boot: balance-first. No Vibes → send the viewer straight to Buy Vibes
++// (works on every channel; includes the PowerChat purchase rail); with a balance,
++// show it. Separately, streamers with their OWN PowerChat get the direct-tip option.
++async function _initDonateModal() {
++ try {
++ const data = await api('/funds/balance');
++ const bal = Math.round(data.balance || 0);
++ const line = document.getElementById('donate-balance-line');
++ if (line) line.innerHTML = `Your balance: <strong>${bal.toLocaleString()}</strong> Vibes`;
++ if (bal < 1) {
++ toast('You don’t have any Vibes yet — grab some first, then come back to donate! 🛒', 'info');
++ showModal('buy-funds');
++ return;
++ }
++ } catch { /* not logged in / balance unavailable — leave the form as-is */ }
++ _initDonatePowerchat();
++}
++async function _initDonatePowerchat() {
++ const box = document.getElementById('donate-powerchat-direct');
++ const streamerId = _donateStreamerId();
++ if (!box || !streamerId) return;
++ try {
++ const d = await api(`/powerchat/donate-link?streamer_id=${streamerId}`);
++ if (d && d.mode === 'direct') box.style.display = '';
++ } catch { /* streamer has no PowerChat — keep hidden */ }
}
--// Real-money tip through PowerChat: the streamer's own tip page when they have
--// PowerChat connected, otherwise the site's PowerChat account (the streamer is then
--// credited the full amount as cashout-able Vibes once the tip confirms).
++// DIRECT real-money tip — only offered when the streamer has their own PowerChat.
async function doPowerchatTip() {
const streamerId = _donateStreamerId();
if (!streamerId) return toast('No streamer selected', 'error');
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 80fb8588..d2c649ef 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -226,11 +226,11 @@
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
-- <script src="/js/app.js?v=125"></script>
++ <script src="/js/app.js?v=126"></script>
<script src="/js/emotes.js?v=8"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/channel-uploads.js?v=12"></script>
-- <script src="/js/vibes.js?v=8"></script>
++ <script src="/js/vibes.js?v=9"></script>
<script src="/js/opencoins.js?v=6"></script>
<script src="/js/cosmetics-ui.js?v=2"></script>
<script src="/js/chat.js?v=75"></script>
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex 3dcd6464..d68b90ea 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -123,11 +123,21 @@ function buildDonateLink(streamerUserId, donorUserId, { goalId = null } = {}) {
// checkout too — without app_redirect_uri PowerChat only offers a plain
// "Return to app" link and viewers just sat on the tip page.
const opts = { purpose, returnTo: true };
++ // Direct ONLY: money through PowerChat goes to a streamer exclusively via THEIR
++ // OWN connected account. Streamers without PowerChat don't get site-routed tips
++ // any more — the viewer buys Vibes through the site account instead (pcorder;
++ // see /donate-link) and donates Vibes from their balance. The pcdon webhook
++ // handler stays only to honor links already in flight.
++ // ALWAYS send an app_ref: attribution fields (appPurpose included — that's how
++ // the goal pick travels) only echo back for tips through our checkout link, and a
++ // ref-less link may not count as one. "dontip:" refs fall through to the normal
++ // donation pipeline on the webhook side.
const direct = _checkoutConn(streamerUserId);
-- if (direct) return { url: tipLinkFor(direct.powerchat_username, '', opts), mode: 'direct' };
-- const site = getSiteAccount();
-- if (!site) return null;
-- return { url: tipLinkFor(site.username, `pcdon:${streamerUserId}:${donorUserId || 0}`, opts), mode: 'site' };
++ if (direct) {
++ const ref = `dontip:${streamerUserId}:${donorUserId || 0}`;
++ return { url: tipLinkFor(direct.powerchat_username, ref, opts), mode: 'direct' };
++ }
++ return null;
}
// ── Webhook fulfillment ──────────────────────────────────────
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex 0f7687d7..f7c96d50 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -215,27 +215,30 @@ router.get('/tip-link', requireAuth, (req, res) => {
}
});
--// ── GET /donate-link — PowerChat tip link for ANY streamer (viewer-facing) ────
--// ?streamer=<username>. Streamers with PowerChat get their own tip page (they keep
--// the money; the webhook fires their celebration). Streamers without PowerChat get
--// the site-wide account's page with a routing ref — the webhook then credits them
--// the full amount as cashout-able Vibes and fires the same celebration.
++// ── GET /donate-link — DIRECT PowerChat tip link (viewer-facing) ──────────────
++// ?streamer=<username> or ?streamer_id=<id>, optional &goal_id. Only streamers with
++// their OWN PowerChat connected get a link — money goes to them directly, their
++// webhook fires the celebration, the site never mints anything. Streamers without
++// PowerChat 404 here: viewers buy Vibes instead (Buy Vibes modal, any channel) and
++// donate from their balance.
router.get('/donate-link', optionalAuth, (req, res) => {
try {
const streamer = req.query.streamer_id
? db.getUserById(parseInt(req.query.streamer_id, 10))
: db.getUserByUsername(String(req.query.streamer || ''));
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
-- // Optional goal pick — rides in app_purpose ("goal:<id>") so the webhook
-- // credits that exact goal. Only the streamer's own ACTIVE goals qualify.
++
++ // Optional goal pick — rides in app_purpose ("goal:<id>") so the streamer's
++ // webhook credits that exact goal.
let goalId = null;
if (req.query.goal_id) {
const g = db.getDonationGoalById(parseInt(req.query.goal_id, 10));
if (g && Number(g.user_id) === Number(streamer.id) && g.is_active) goalId = g.id;
}
-- const link = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });
-- if (!link) return res.status(404).json({ error: 'PowerChat tips are not available for this channel' });
-- res.json({ url: link.url, mode: link.mode, goal_id: goalId });
++
++ const direct = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });
++ if (!direct) return res.status(404).json({ error: 'This channel does not take PowerChat tips directly', direct: false });
++ res.json({ url: direct.url, mode: 'direct', goal_id: goalId });
} catch (err) {
res.status(500).json({ error: 'Failed to build tip link' });
}
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex e70a0bc8..9ffa60e3 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -22,8 +22,10 @@ router.get('/config', (req, res) => {
// ── Buy Vibes ───────────────────────────────────────────
router.post('/bucks/checkout', 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 purchases run on their own enablement (powerchat_enabled + site tips
++ // account) — the master payments switch only gates the card/PayPal/crypto rails.
++ if (!pay.isEnabled() && provider !== 'powerchat') return res.status(403).json({ error: 'Payments are not enabled' });
// The client picks a Vibes amount (bit-style); the USD price is derived from the
// volume-discount tiers so bigger buys are cheaper per buck and the platform keeps the spread.
let bucks;
ddiff --git a/server/monetization/payments.js b/server/monetization/payments.js
iindex e6566d2f..c60d1e77 100644
---- a/server/monetization/payments.js
++++ b/server/monetization/payments.js
@@@ -48,10 +48,11 @@ function publicConfig() {
stripe: isEnabled() && b('stripe_enabled') && !!s('stripe_secret_key'),
ccbill: isEnabled() && b('ccbill_enabled') && !!s('ccbill_flexform_id'),
crypto: isEnabled() && b('crypto_enabled') && !!s('crypto_api_key'),
-- // PowerChat tips as a money entry point — available once the site-wide
-- // PowerChat account (powerchat_site_user_id) is connected with checkout
-- // attribution. Lazy require: the checkout module requires this one back.
-- powerchat: isEnabled() && (() => { try { return require('../integrations/powerchat-checkout').isAvailable(); } catch { return false; } })(),
++ // PowerChat tips as a money entry point. Deliberately INDEPENDENT of the
++ // 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; } })(),
},
stripePublishableKey: s('stripe_publishable_key'),
};