openvibers/openvibe.live Commits

PowerChat as a money entry point: buy Vibes, pay subs, tip any streamer

OP OpenVibers committed 26 Aug 2026 · 830ce5ad57 · 8 files, +405 −18

A site-wide PowerChat account (powerchat_site_user_id, default the owner,
connected via the normal dashboard card) hosts checkout for everything the
site itself must settle; streamers with their own PowerChat are linked
directly. All flows confirm via the donation.completed webhook's
appExternalRef and are idempotent per order:
- pcorder: buy Vibes on the site account's tip page; the buyer is credited
  for the amount actually tipped.
- pcsub: subscribe by tip — the streamer's own page when connected (they
  keep the money, so no cashout-Vibes share is minted on top), else the
  site account (normal share). Underpaid tips degrade to a donation.
- pcdon: donations for PowerChat-less streamers route through the site
  account and credit the streamer the full amount as cashout-able Vibes,
  with the same on-site celebration.
Subs can also be paid directly with Vibes, and auto-renew now works for
every method that can: Stripe renews natively; everything else renews from
the subscriber's Vibes balance via an hourly sweeper (insufficient balance
expires the sub with a notification).

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

Changes

ddiff --git a/public/js/vibes.js b/public/js/vibes.js
iindex a858f55d..99fbe8d4 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -72,6 +72,7 @@ async function _initBuyBucks() {
btn('paypal', 'PayPal', 'fa-brands fa-paypal', '#003087'),
btn('ccbill', 'Card (CCBill)', 'fa-solid fa-credit-card', '#2a6'),
btn('crypto', 'Crypto', 'fa-brands fa-bitcoin', '#f7931a'),
++ btn('powerchat', 'PowerChat tip', 'fa-solid fa-bolt', '#8b5cf6'),
].filter(Boolean).join('');
box.innerHTML = buttons || '<p class="muted" style="font-size:0.85rem;text-align:center">No payment methods are enabled.</p>';
}
@@@ -94,6 +95,14 @@ async function doPurchase(provider) {
if (!bucks || bucks < 100) return toast('Enter at least 100 Vibes', 'error');
try {
const data = await api('/payments/bucks/checkout', { method: 'POST', body: { provider, bucks } });
++ if (data.powerchat && data.url) {
++ // PowerChat: the tip page opens in a new tab; Vibes are credited by webhook
++ // once the tip confirms, so the site stays open underneath.
++ window.open(data.url, '_blank', 'noopener');
++ toast(data.note || 'Complete your tip on PowerChat — Vibes are credited automatically once it confirms.', 'success');
++ closeModal();
++ return;
++ }
if (data.url) { window.location.href = data.url; return; }
toast('Could not start checkout', 'error');
} catch (e) { toast(e.message || 'Purchase failed', 'error'); }
@@@ -124,14 +133,30 @@ async function _initSubscribe() {
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>`;
}
-- 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 (30 days)</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>`;
++ }
++ // 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">
++ <input type="checkbox" id="sub-auto-renew" checked style="width:16px;height:16px;cursor:pointer">
++ Auto-renew monthly from my Vibes balance (Vibes/PowerChat)
++ </label>`;
box.innerHTML = html;
}
async function doSubscribe(username, provider) {
if (!currentUser) return showModal('login');
++ const autoRenew = !!document.getElementById('sub-auto-renew')?.checked;
try {
-- const data = await api('/payments/subscribe', { method: 'POST', body: { provider, streamer: username } });
++ const data = await api('/payments/subscribe', { method: 'POST', body: { provider, streamer: username, auto_renew: autoRenew } });
++ if (data.powerchat && data.url) {
++ window.open(data.url, '_blank', 'noopener');
++ toast(data.note || 'Complete your tip on PowerChat — your subscription activates automatically once it confirms.', 'success');
++ closeModal();
++ return;
++ }
if (data.url) { window.location.href = data.url; return; }
if (data.ok) { toast('Subscribed! 🎉', 'success'); if (typeof loadBalance === 'function') loadBalance(); closeModal(); }
} catch (e) { toast(e.message || 'Subscription failed', 'error'); }
@@@ -184,9 +209,27 @@ function openvibeBucksDonateModal() {
<button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:8px">
<i class="fa-solid fa-coins"></i> Donate
++ </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>`;
}
++// 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).
++async function doPowerchatTip() {
++ const streamerId = _donateStreamerId();
++ if (!streamerId) return toast('No streamer selected', 'error');
++ try {
++ const data = await api(`/powerchat/donate-link?streamer_id=${streamerId}`);
++ if (!data.url) throw new Error('unavailable');
++ window.open(data.url, '_blank', 'noopener');
++ toast('Complete your tip on PowerChat — it lands in this channel automatically once confirmed. ⚡', 'success');
++ closeModal();
++ } catch (e) { toast('PowerChat tips aren’t available for this channel.', 'error'); }
++}
++
// Resolve the streamer being viewed (live stream data, else the channel page owner).
function _donateStreamerId() {
if (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.user_id) return currentStreamData.user_id;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 599b870b..120c9e04 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1057,6 +1057,11 @@ function initDb() {
// registration has the scopes.
['powerchat_scopes', 'profile:read webhooks:events checkout:attribute paid_messages:read alerts:trigger chat:write viewcount:write subscriptions:write follows:write currency:write tips:write', 'OAuth scopes requested from each streamer (space-delimited)', 'string'],
['powerchat_sandbox_username', 'alex', 'Sandbox streamer username the app can act on until approved (the app owner’s PowerChat username)', 'string'],
++ // Site-wide fallback PowerChat account: the OpenVibe user id whose connected
++ // PowerChat receives tips when a streamer has no PowerChat of their own, and
++ // hosts all Vibes purchases. That user connects PowerChat from their normal
++ // dashboard card; this setting just points at them (default: the owner).
++ ['powerchat_site_user_id', '1', 'OpenVibe user id whose PowerChat connection is the site-wide tip/purchase account (fallback when a streamer has no PowerChat)', 'number'],
];
const seedPc = database.prepare("INSERT OR IGNORE INTO site_settings (key, value, description, type) VALUES (?, ?, ?, ?)");
for (const [k, v, d, t] of powerchatSeeds) seedPc.run(k, v, d, t);
@@@ -1439,6 +1444,7 @@ function initDb() {
{ name: 'currency', def: "TEXT DEFAULT 'usd'" },
{ name: 'status', def: "TEXT DEFAULT 'active'" }, // active|canceled|past_due|expired
{ name: 'cancel_at_period_end', def: "INTEGER DEFAULT 0" },
++ { name: 'auto_renew', def: "INTEGER DEFAULT 0" }, // renew from Vibes balance at period end (non-Stripe)
{ name: 'current_period_end', def: "DATETIME DEFAULT NULL" },
{ name: 'created_at', def: "DATETIME DEFAULT CURRENT_TIMESTAMP" },
{ name: 'updated_at', def: "DATETIME DEFAULT CURRENT_TIMESTAMP" },
@@@ -4838,21 +4844,32 @@ function updatePaymentOrder(id, fields) {
// ── Subscription helpers ─────────────────────────────────────
--function upsertSubscription({ subscriber_id, streamer_id, tier = 1, provider = null, provider_ref = null, price_cents = 0, currency = 'usd', status = 'active', current_period_end = null }) {
++function upsertSubscription({ subscriber_id, streamer_id, tier = 1, provider = null, provider_ref = null, price_cents = 0, currency = 'usd', status = 'active', current_period_end = null, auto_renew = null }) {
// Reuse an existing (subscriber,streamer) row if present, else insert.
++ // auto_renew: null = leave as-is on update (0 on insert); 0/1 = set explicitly.
const existing = get('SELECT * FROM subscriptions WHERE subscriber_id = ? AND streamer_id = ?', [subscriber_id, streamer_id]);
if (existing) {
run(`UPDATE subscriptions SET tier=?, provider=?, provider_ref=COALESCE(?, provider_ref), price_cents=?, currency=?,
-- status=?, is_active=?, current_period_end=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
-- [tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end, existing.id]);
++ status=?, is_active=?, current_period_end=?, auto_renew=COALESCE(?, auto_renew),
++ cancel_at_period_end=0, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
++ [tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end,
++ auto_renew === null ? null : (auto_renew ? 1 : 0), existing.id]);
return get('SELECT * FROM subscriptions WHERE id = ?', [existing.id]);
}
-- const res = run(`INSERT INTO subscriptions (subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, is_active, current_period_end)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
-- [subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end]);
++ const res = run(`INSERT INTO subscriptions (subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, is_active, current_period_end, auto_renew)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [subscriber_id, streamer_id, tier, provider, provider_ref, price_cents, currency, status, status === 'active' ? 1 : 0, current_period_end, auto_renew ? 1 : 0]);
return get('SELECT * FROM subscriptions WHERE id = ?', [res.lastInsertRowid]);
}
++// Active subs whose paid period has lapsed — the renewal sweeper's work list.
++function getSubscriptionsDueRenewal(limit = 50) {
++ return all(`SELECT * FROM subscriptions
++ WHERE status = 'active' AND current_period_end IS NOT NULL
++ AND datetime(current_period_end) <= CURRENT_TIMESTAMP
++ ORDER BY datetime(current_period_end) ASC LIMIT ?`, [limit]);
++}
++
function getSubscriptionByProviderRef(provider, ref) {
if (!ref) return null;
return get('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);
@@@ -7641,6 +7658,7 @@ module.exports = {
createPaymentOrder, getPaymentOrderById, getPaymentOrderByRef, updatePaymentOrder,
upsertSubscription, getSubscriptionByProviderRef, getActiveSubscription, isActiveSubscriber,
getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,
++ getSubscriptionsDueRenewal,
getRestreamDestinationsByManagedStream,
// Chat
saveChatMessage, searchChatMessages, getUserChatHistory,
ddiff --git a/server/index.js b/server/index.js
iindex cc4ef4bf..549891d5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -991,6 +991,8 @@ async function start() {
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
// Batched channel-point earns → PowerChat leaderboard feed.
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
++ // Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
++ try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
try {
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
nnew file mode 100644
iindex 00000000..7569db3d
---- /dev/null
++++ b/server/integrations/powerchat-checkout.js
@@@ -0,0 +1,193 @@
++/**
++ * powerchat-checkout.js — PowerChat as a real-money entry point into the site economy.
++ *
++ * Three attributed checkout flows, all confirmed by the donation.completed webhook
++ * (appExternalRef carries our reference; only our app ever sees its own refs):
++ *
++ * pcorder:<orderId> Buy Vibes. Always hosted on the SITE account's tip
++ * page (the site receives the money, so it can mint
++ * spendable Vibes for the buyer).
++ * pcsub:<orderId> Channel subscription paid by PowerChat tip. Hosted on
++ * the STREAMER's own tip page when they have PowerChat
++ * (they keep the money → no Vibes share credited), else
++ * on the site account (site keeps the money → streamer
++ * gets the normal cashout-able share).
++ * pcdon:<streamerId>:<donorId> Donation to a streamer WITHOUT PowerChat, routed
++ * through the site account. The streamer is credited
++ * the full amount as cashout-able Vibes (1¢ = 1 Vibe)
++ * and the normal donation celebration fires. Streamers
++ * WITH PowerChat never get pcdon refs — viewers tip
++ * their page directly and the plain webhook flow runs.
++ *
++ * The SITE account is an ordinary streamer connection (dashboard OAuth card) pointed
++ * at by the powerchat_site_user_id admin setting — the owner's own PowerChat.
++ */
++'use strict';
++
++const db = require('../db/database');
++const oauth = require('./powerchat-oauth');
++
++// ── Site account resolution ──────────────────────────────────
++function siteAccountUserId() {
++ const v = parseInt(String(db.getSetting('powerchat_site_user_id') || ''), 10);
++ return Number.isFinite(v) && v > 0 ? v : null;
++}
++// A usable checkout host = connected via app OAuth AND granted checkout:attribute
++// (attribution refs only echo back for grants that carry the scope).
++function _checkoutConn(userId) {
++ if (!userId) return null;
++ try {
++ if (!oauth.getConfig().enabled) return null;
++ const conn = db.getPowerchatConnection(userId);
++ if (!conn || !conn.access_token || !conn.refresh_token || !conn.powerchat_username) return null;
++ if (conn.scope && !String(conn.scope).split(/\s+/).includes('checkout:attribute')) return null;
++ return conn;
++ } catch { return null; }
++}
++function getSiteAccount() {
++ const userId = siteAccountUserId();
++ const conn = _checkoutConn(userId);
++ return conn ? { userId, username: conn.powerchat_username } : null;
++}
++function isAvailable() { return !!getSiteAccount(); }
++
++// Canonical attribution link (documented stable URL shape — no API round-trip needed).
++function tipLinkFor(pcUsername, ref) {
++ const c = oauth.getConfig();
++ const params = new URLSearchParams({ app_client_id: c.clientId });
++ if (ref) params.set('app_ref', ref);
++ return `${c.baseUrl}/${encodeURIComponent(pcUsername)}/tip?${params.toString()}`;
++}
++
++// ── Link builders ────────────────────────────────────────────
++// Buy Vibes: always the site account (the site must receive the money it mints against).
++function buildPurchaseLink(order) {
++ const site = getSiteAccount();
++ if (!site) return null;
++ return { url: tipLinkFor(site.username, `pcorder:${order.id}`), mode: 'site' };
++}
++
++// Subscription: streamer-direct when possible, site fallback otherwise.
++// 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 } = {}) {
++ const suffix = autoRenew ? ':renew' : '';
++ const direct = _checkoutConn(streamerUserId);
++ if (direct) {
++ db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
++ return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`), mode: 'direct' };
++ }
++ const site = getSiteAccount();
++ if (!site) return null;
++ db.updatePaymentOrder(order.id, { provider_ref: `site${suffix}` });
++ return { url: tipLinkFor(site.username, `pcsub:${order.id}`), mode: 'site' };
++}
++
++// Donation: streamer's own page when they have PowerChat (plain link — the normal
++// donation.completed flow handles it, optionally with a goal: ref added by the caller),
++// else the site account with a routing ref.
++function buildDonateLink(streamerUserId, donorUserId) {
++ const direct = _checkoutConn(streamerUserId);
++ if (direct) return { url: tipLinkFor(direct.powerchat_username, ''), mode: 'direct' };
++ const site = getSiteAccount();
++ if (!site) return null;
++ return { url: tipLinkFor(site.username, `pcdon:${streamerUserId}:${donorUserId || 0}`), mode: 'site' };
++}
++
++// ── Webhook fulfillment ──────────────────────────────────────
++// Called by the webhook processor for donation.completed events whose appExternalRef
++// matches one of our prefixes. Returns true when the event was CONSUMED (the normal
++// donation pipeline for the receiving account must then be skipped), false to fall
++// through to plain donation handling.
++function handleAttributedDonation(receivingUserId, data) {
++ const ref = String(data.appExternalRef || '');
++ const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));
++ try {
++ // Buy Vibes ---------------------------------------------------------
++ let m = ref.match(/^pcorder:(\d+)$/);
++ if (m) {
++ const order = db.getPaymentOrderById(Number(m[1]));
++ if (!order || order.kind !== 'bucks') return true; // ours but unusable — never double-handle
++ if (order.status === 'credited') return true; // at-least-once redelivery
++ if (usdCents < 1) return true;
++ // The tip page can't enforce an amount — credit what was ACTUALLY paid.
++ const pay = require('../monetization/payments');
++ const bucks = pay.bucksForUsd(usdCents / 100);
++ db.updatePaymentOrder(order.id, { amount_cents: usdCents, bucks, status: 'paid' });
++ pay.fulfillBucksOrder(db.getPaymentOrderById(order.id));
++ _notify(order.user_id, 'Vibes credited 🎉', `Your PowerChat tip of ${_usd(usdCents)} was confirmed — ${bucks.toLocaleString()} Vibes added to your balance.`);
++ console.log(`[PowerChat] purchase order ${order.id}: ${_usd(usdCents)} → ${bucks} Vibes for user ${order.user_id}`);
++ return true;
++ }
++ // Subscription ------------------------------------------------------
++ m = ref.match(/^pcsub:(\d+)$/);
++ if (m) {
++ const order = db.getPaymentOrderById(Number(m[1]));
++ if (!order || order.kind !== 'subscription' || !order.streamer_id) return true;
++ if (order.status === 'credited') return true;
++ // Underpaid (tip page can't enforce amounts): treat the tip as a plain
++ // donation to the order's streamer rather than silently activating a
++ // discounted sub. Direct mode = the receiving account IS the streamer, so
++ // 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;
++ 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; }
++ return false;
++ }
++ 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 });
++ _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;
++ }
++ // Site-routed donation ----------------------------------------------
++ m = ref.match(/^pcdon:(\d+):(\d+)$/);
++ if (m) {
++ _creditSiteRoutedDonation(Number(m[1]), Number(m[2]) || null, data);
++ return true;
++ }
++ } catch (e) {
++ console.warn('[PowerChat] attributed-donation fulfillment failed:', e.message);
++ return true; // never fall through to crediting the receiving account on our refs
++ }
++ return false; // not one of our checkout refs (e.g. goal:12) — normal handling
++}
++
++// A tip that landed on the SITE account on behalf of a PowerChat-less streamer:
++// full amount becomes the streamer's cashout-able Vibes (the site holds the cash),
++// plus the normal on-site donation celebration/goal credit.
++function _creditSiteRoutedDonation(streamerUserId, donorUserId, data) {
++ const usdCents = Math.max(0, Math.round(Number(data.amountUsdCents || 0)));
++ if (!streamerUserId || usdCents < 1) return;
++ if (!db.getUserById(streamerUserId)) { console.warn(`[PowerChat] pcdon for unknown streamer ${streamerUserId}`); return; }
++ db.addVibesCashout(streamerUserId, usdCents); // 1¢ = 1 Vibe, same rate as on-site donations
++ try {
++ db.createTransaction({
++ from_user_id: donorUserId || null, to_user_id: streamerUserId, amount: usdCents,
++ type: 'donation', status: 'completed',
++ message: `PowerChat tip via site account (${_usd(usdCents)})`,
++ });
++ } catch { /* ledger best-effort */ }
++ // Same celebration pipeline a direct-connected streamer gets from their own webhook.
++ try { require('./powerchat-webhook').creditDonationPipeline(streamerUserId, data); } catch (e) { console.warn('[PowerChat] pcdon pipeline:', e.message); }
++ console.log(`[PowerChat] site-routed donation: ${_usd(usdCents)} → ${usdCents} cashout Vibes for streamer ${streamerUserId}`);
++}
++
++function _usd(cents) { return `$${(Math.round(cents) / 100).toFixed(2)}`; }
++function _notify(userId, title, message) {
++ try {
++ const { pushNotification } = require('../utils/notify');
++ pushNotification({ user_id: userId, type: 'PAYMENT', title, message, url: '/' });
++ } catch { /* optional */ }
++}
++
++module.exports = {
++ isAvailable, getSiteAccount, tipLinkFor,
++ buildPurchaseLink, buildSubscribeLink, buildDonateLink,
++ handleAttributedDonation,
++};
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex 180115b2..137b1eeb 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -16,7 +16,7 @@ const crypto = require('crypto');
const router = express.Router();
const db = require('../db/database');
const config = require('../config');
--const { requireAuth } = require('../auth/auth');
++const { requireAuth, optionalAuth } = require('../auth/auth');
const oauth = require('./powerchat-oauth');
const webhook = require('./powerchat-webhook');
@@@ -199,6 +199,25 @@ 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.
++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' });
++ const link = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null);
++ if (!link) return res.status(404).json({ error: 'PowerChat tips are not available for this channel' });
++ res.json({ url: link.url, mode: link.mode });
++ } catch (err) {
++ res.status(500).json({ error: 'Failed to build tip link' });
++ }
++});
++
// ── POST /test-tip — simulate a tip through the LOCAL pipeline (no PowerChat call) ─
// The most useful test: confirms the streamer's alert sound + chat celebration render.
router.post('/test-tip', requireAuth, async (req, res) => {
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 8f21bb68..0c02d94b 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -187,6 +187,13 @@ function processEvent(envelope) {
case 'donation.completed':
// The authoritative money event. paid_message.created is a subset of this
// (a tip that carried a message) — we DON'T credit on it to avoid double-count.
++ // Checkout-attributed tips (Vibes purchases, PowerChat-paid subs, and
++ // site-routed donations for PowerChat-less streamers) are fulfilled by the
++ // checkout module; when it consumes the event, the receiving account's
++ // normal donation pipeline must NOT also run.
++ if (data.appExternalRef && /^(pcorder|pcsub|pcdon):/.test(String(data.appExternalRef))) {
++ if (require('./powerchat-checkout').handleAttributedDonation(userId, data)) break;
++ }
_handleDonation(userId, data);
break;
case 'subscription.created':
@@@ -256,4 +263,9 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
return { amount, live: !!streamId };
}
--module.exports = { verifySignature, processEvent, publicGoal, simulateDonation };
++module.exports = {
++ verifySignature, processEvent, publicGoal, simulateDonation,
++ // The full on-site donation celebration (chat event, alert sound, goal credit) for
++ // a given streamer — reused by the checkout module for site-routed donations.
++ creditDonationPipeline: _handleDonation,
++};
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex 43fc44df..abe98ac5 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -57,6 +57,17 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
const r = await pay.cryptoCreateInvoice({ order, amountUsd, description: name });
return res.json({ url: r.url });
}
++ if (provider === 'powerchat') {
++ // Tip the site's PowerChat account; the donation.completed webhook credits
++ // the buyer with Vibes for the amount ACTUALLY tipped (the tip page can't
++ // enforce an amount, so we tell the buyer what to tip and settle on actuals).
++ const link = require('../integrations/powerchat-checkout').buildPurchaseLink(order);
++ if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
++ return res.json({
++ url: link.url, powerchat: true, amountUsd,
++ note: `Tip $${amountUsd.toFixed(2)} on PowerChat — your Vibes are credited automatically once the tip confirms (any amount you tip converts to Vibes).`,
++ });
++ }
return res.status(400).json({ error: 'Unknown payment provider' });
} catch (err) {
console.error('[Payments] checkout error:', err.message);
@@@ -96,16 +107,37 @@ router.post('/subscribe', requireAuth, async (req, res) => {
const priceUsd = pay._num('sub_price_usd', 4.99);
const amountCents = Math.round(priceUsd * 100);
-- // Pay with Vibes — instant 30-day sub, no auto-renew.
++ // Pay with Vibes — instant 30-day sub; auto-renews from the Vibes balance unless
++ // the subscriber opted out.
if (provider === 'bucks') {
++ const autoRenew = req.body.auto_renew === undefined ? 1 : (req.body.auto_renew ? 1 : 0);
const cost = pay.bucksForUsd(priceUsd);
if (!db.deductVibes(req.user.id, cost)) return res.status(402).json({ error: `Not enough Vibes (need ${cost})` });
const order = db.createPaymentOrder({
user_id: req.user.id, provider: 'bucks', kind: 'subscription',
amount_cents: amountCents, streamer_id: streamer.id, status: 'paid',
});
-- const sub = pay.fulfillSubscriptionOrder(order);
-- return res.json({ ok: true, subscription: { streamer: streamer.username, current_period_end: sub.current_period_end } });
++ const sub = pay.fulfillSubscriptionOrder(order, { autoRenew });
++ return res.json({ ok: true, subscription: { streamer: streamer.username, current_period_end: sub.current_period_end, auto_renew: !!autoRenew } });
++ }
++
++ // Pay with a PowerChat tip — streamer's own tip page when they have PowerChat
++ // (they keep the money directly), else the site-wide PowerChat account (the site
++ // 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') {
++ const autoRenew = req.body.auto_renew ? 1 : 0;
++ const order = db.createPaymentOrder({
++ user_id: req.user.id, provider: 'powerchat', kind: 'subscription',
++ amount_cents: amountCents, streamer_id: streamer.id,
++ });
++ const link = require('../integrations/powerchat-checkout').buildSubscribeLink(order, streamer.id, { autoRenew });
++ if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat payments are not available right now' }); }
++ 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.`,
++ });
}
// Stripe recurring subscription (auto-renew monthly).
ddiff --git a/server/monetization/payments.js b/server/monetization/payments.js
iindex 02717f87..e6566d2f 100644
---- a/server/monetization/payments.js
++++ b/server/monetization/payments.js
@@@ -48,6 +48,10 @@ 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; } })(),
},
stripePublishableKey: s('stripe_publishable_key'),
};
@@@ -245,8 +249,11 @@ function fulfillBucksOrder(order) {
return true;
}
--/** Activate/extend a subscription from a paid order (idempotent-ish). */
--function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null } = {}) {
++/** Activate/extend a subscription from a paid order (idempotent-ish).
++ * 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 } = {}) {
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
@@@ -257,13 +264,13 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
provider: order.provider, provider_ref: providerRef || order.provider_ref,
price_cents: order.amount_cents, currency: order.currency || 'usd',
-- status: 'active', current_period_end: end,
++ status: 'active', current_period_end: end, auto_renew: autoRenew,
});
if (order.status !== 'credited') {
// 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 = bucksForUsd((order.amount_cents / 100) * (sharePct / 100));
++ const streamerBucks = creditShare ? bucksForUsd((order.amount_cents / 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) —
@@@ -282,8 +289,69 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
return sub;
}
++// ── Subscription renewal sweeper ─────────────────────────────
++// Auto-renew for every method that CAN renew:
++// - Stripe subs renew natively (invoice.paid extends the period). The sweeper only
++// expires them after a 3-day grace window with no renewal — Stripe stopped billing.
++// - Everything else (vibes/paypal/ccbill/crypto/powerchat) is a one-time charge the
++// processor can't repeat, so auto_renew=1 renews from the subscriber's VIBES
++// balance — the universal wallet every method can top up. Insufficient balance
++// (or auto_renew off / cancel-at-period-end) → the sub expires with a notification.
++const STRIPE_GRACE_MS = 3 * 24 * 3600 * 1000;
++function _sweepRenewals() {
++ const due = db.getSubscriptionsDueRenewal(50) || [];
++ for (const sub of due) {
++ try {
++ const endMs = Date.parse(sub.current_period_end) || 0;
++ if (sub.provider === 'stripe') {
++ if (Date.now() - endMs < STRIPE_GRACE_MS) continue; // let Stripe's invoice.paid land
++ db.setSubscriptionStatus(sub.id, 'expired');
++ _renewNotify(sub, 'Your card subscription was not renewed by Stripe and has ended.');
++ continue;
++ }
++ if (sub.cancel_at_period_end || !sub.auto_renew) {
++ db.setSubscriptionStatus(sub.id, sub.cancel_at_period_end ? 'canceled' : 'expired');
++ if (!sub.cancel_at_period_end) _renewNotify(sub, 'Your channel subscription has ended. Resubscribe any time!');
++ continue;
++ }
++ const priceCents = sub.price_cents || Math.round(n('sub_price_usd', 4.99) * 100);
++ const cost = bucksForUsd(priceCents / 100);
++ if (!db.deductVibes(sub.subscriber_id, cost)) {
++ db.setSubscriptionStatus(sub.id, 'expired');
++ _renewNotify(sub, `Your subscription could not auto-renew (${cost.toLocaleString()} Vibes needed). Top up and resubscribe!`);
++ continue;
++ }
++ const order = db.createPaymentOrder({
++ user_id: sub.subscriber_id, provider: 'bucks', kind: 'subscription',
++ amount_cents: priceCents, streamer_id: sub.streamer_id, status: 'paid',
++ });
++ fulfillSubscriptionOrder(order, { autoRenew: 1 });
++ console.log(`[Payments] auto-renewed sub ${sub.id} (user ${sub.subscriber_id} → streamer ${sub.streamer_id}) from Vibes balance`);
++ } catch (e) { console.warn(`[Payments] renewal sweep sub ${sub.id}:`, e.message); }
++ }
++}
++function _renewNotify(sub, message) {
++ try {
++ const { pushNotification } = require('../utils/notify');
++ const streamer = db.getUserById(sub.streamer_id);
++ pushNotification({
++ user_id: sub.subscriber_id, type: 'PAYMENT', title: 'Subscription update',
++ message: `${streamer ? (streamer.display_name || streamer.username) + ': ' : ''}${message}`,
++ url: streamer ? `/${streamer.username}` : '/',
++ });
++ } catch { /* optional */ }
++}
++let _renewTimer = null;
++function startRenewalSweeper() {
++ if (_renewTimer) return;
++ _sweepRenewals(); // catch up immediately on boot
++ _renewTimer = setInterval(_sweepRenewals, 60 * 60 * 1000);
++ if (_renewTimer.unref) _renewTimer.unref();
++ console.log('[Payments] subscription renewal sweeper started (hourly)');
++}
++
module.exports = {
-- isEnabled, publicConfig, bucksForUsd,
++ isEnabled, publicConfig, bucksForUsd, startRenewalSweeper,
// stripe
stripeCheckout, stripeVerify,
// paypal