openvibers/openvibe.live Commits
PowerChat as a money entry point: buy Vibes, pay subs, tip any streamer
Changes
@@@ -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;@@@ -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,// ChatsaveChatMessage, searchChatMessages, getUserChatHistory,@@@ -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 { /* */ } };nnew file mode 100644@@@ -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,++};@@@ -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) => {@@@ -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', messagreturn { 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,++};@@@ -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).@@@ -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 = nullsubscriber_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 = nullreturn 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,// stripestripeCheckout, stripeVerify,// paypal
@@ -72,6 +72,7 @@ async function _initBuyBucks() {
@@ -72,6 +72,7 @@ async function _initBuyBucks() {
btn('paypal', 'PayPal', 'fa-brands fa-paypal', '#003087'),
btn('paypal', 'PayPal', 'fa-brands fa-paypal', '#003087'),
btn('ccbill', 'Card (CCBill)', 'fa-solid fa-credit-card', '#2a6'),
btn('ccbill', 'Card (CCBill)', 'fa-solid fa-credit-card', '#2a6'),
btn('crypto', 'Crypto', 'fa-brands fa-bitcoin', '#f7931a'),
btn('crypto', 'Crypto', 'fa-brands fa-bitcoin', '#f7931a'),
+ btn('powerchat', 'PowerChat tip', 'fa-solid fa-bolt', '#8b5cf6'),
].filter(Boolean).join('');
].filter(Boolean).join('');
box.innerHTML = buttons || '<p class="muted" style="font-size:0.85rem;text-align:center">No payment methods are enabled.</p>';
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) {
@@ -94,6 +95,14 @@ async function doPurchase(provider) {
if (!bucks || bucks < 100) return toast('Enter at least 100 Vibes', 'error');
if (!bucks || bucks < 100) return toast('Enter at least 100 Vibes', 'error');
try {
try {
const data = await api('/payments/bucks/checkout', { method: 'POST', body: { provider, bucks } });
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; }
if (data.url) { window.location.href = data.url; return; }
toast('Could not start checkout', 'error');
toast('Could not start checkout', 'error');
} catch (e) { toast(e.message || 'Purchase failed', 'error'); }
} catch (e) { toast(e.message || 'Purchase failed', 'error'); }
@@ -124,14 +133,30 @@ async function _initSubscribe() {
@@ -124,14 +133,30 @@ async function _initSubscribe() {
if (cfg.providers && cfg.providers.stripe) {
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 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;
box.innerHTML = html;
}
}
async function doSubscribe(username, provider) {
async function doSubscribe(username, provider) {
if (!currentUser) return showModal('login');
if (!currentUser) return showModal('login');
+ const autoRenew = !!document.getElementById('sub-auto-renew')?.checked;
try {
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.url) { window.location.href = data.url; return; }
if (data.ok) { toast('Subscribed! 🎉', 'success'); if (typeof loadBalance === 'function') loadBalance(); closeModal(); }
if (data.ok) { toast('Subscribed! 🎉', 'success'); if (typeof loadBalance === 'function') loadBalance(); closeModal(); }
} catch (e) { toast(e.message || 'Subscription failed', 'error'); }
} catch (e) { toast(e.message || 'Subscription failed', 'error'); }
@@ -184,9 +209,27 @@ function openvibeBucksDonateModal() {
@@ -184,9 +209,27 @@ function openvibeBucksDonateModal() {
<button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:8px">
<button class="btn btn-primary btn-lg" onclick="doDonate()" style="width:100%;margin-top:8px">
<i class="fa-solid fa-coins"></i> Donate
<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>`;
</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).
// Resolve the streamer being viewed (live stream data, else the channel page owner).
function _donateStreamerId() {
function _donateStreamerId() {
if (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.user_id) return currentStreamData.user_id;
if (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.user_id) return currentStreamData.user_id;
@@ -1057,6 +1057,11 @@ function initDb() {
@@ -1057,6 +1057,11 @@ function initDb() {
// registration has the scopes.
// 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_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'],
['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 (?, ?, ?, ?)");
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);
for (const [k, v, d, t] of powerchatSeeds) seedPc.run(k, v, d, t);
@@ -1439,6 +1444,7 @@ function initDb() {
@@ -1439,6 +1444,7 @@ function initDb() {
{ name: 'currency', def: "TEXT DEFAULT 'usd'" },
{ name: 'currency', def: "TEXT DEFAULT 'usd'" },
{ name: 'status', def: "TEXT DEFAULT 'active'" }, // active|canceled|past_due|expired
{ name: 'status', def: "TEXT DEFAULT 'active'" }, // active|canceled|past_due|expired
{ name: 'cancel_at_period_end', def: "INTEGER DEFAULT 0" },
{ 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: 'current_period_end', def: "DATETIME DEFAULT NULL" },
{ name: 'created_at', def: "DATETIME DEFAULT CURRENT_TIMESTAMP" },
{ name: 'created_at', def: "DATETIME DEFAULT CURRENT_TIMESTAMP" },
{ name: 'updated_at', def: "DATETIME DEFAULT CURRENT_TIMESTAMP" },
{ name: 'updated_at', def: "DATETIME DEFAULT CURRENT_TIMESTAMP" },
@@ -4838,21 +4844,32 @@ function updatePaymentOrder(id, fields) {
@@ -4838,21 +4844,32 @@ function updatePaymentOrder(id, fields) {
// ── Subscription helpers ─────────────────────────────────────
// ── 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.
// 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]);
const existing = get('SELECT * FROM subscriptions WHERE subscriber_id = ? AND streamer_id = ?', [subscriber_id, streamer_id]);
if (existing) {
if (existing) {
run(`UPDATE subscriptions SET tier=?, provider=?, provider_ref=COALESCE(?, provider_ref), price_cents=?, currency=?,
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]);
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]);
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) {
function getSubscriptionByProviderRef(provider, ref) {
if (!ref) return null;
if (!ref) return null;
return get('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);
return get('SELECT * FROM subscriptions WHERE provider = ? AND provider_ref = ? ORDER BY id DESC LIMIT 1', [provider, ref]);
@@ -7641,6 +7658,7 @@ module.exports = {
@@ -7641,6 +7658,7 @@ module.exports = {
createPaymentOrder, getPaymentOrderById, getPaymentOrderByRef, updatePaymentOrder,
createPaymentOrder, getPaymentOrderById, getPaymentOrderByRef, updatePaymentOrder,
upsertSubscription, getSubscriptionByProviderRef, getActiveSubscription, isActiveSubscriber,
upsertSubscription, getSubscriptionByProviderRef, getActiveSubscription, isActiveSubscriber,
getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,
getSubscriptionsByStreamer, getSubscriptionsBySubscriber, getActiveSubscriberCount, setSubscriptionStatus,
+ getSubscriptionsDueRenewal,
getRestreamDestinationsByManagedStream,
getRestreamDestinationsByManagedStream,
// Chat
// Chat
saveChatMessage, searchChatMessages, getUserChatHistory,
saveChatMessage, searchChatMessages, getUserChatHistory,
@@ -991,6 +991,8 @@ async function start() {
@@ -991,6 +991,8 @@ async function start() {
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
try { require('./integrations/powerchat-platform').startViewerCountSweeper(); } catch (e) { console.warn('[PowerChat] viewer sweeper not started:', e.message); }
// Batched channel-point earns → PowerChat leaderboard feed.
// Batched channel-point earns → PowerChat leaderboard feed.
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
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.
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
try {
try {
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -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,
+};
@@ -16,7 +16,7 @@ const crypto = require('crypto');
@@ -16,7 +16,7 @@ const crypto = require('crypto');
const router = express.Router();
const router = express.Router();
const db = require('../db/database');
const db = require('../db/database');
const config = require('../config');
const config = require('../config');
-const { requireAuth } = require('../auth/auth');
+const { requireAuth, optionalAuth } = require('../auth/auth');
const oauth = require('./powerchat-oauth');
const oauth = require('./powerchat-oauth');
const webhook = require('./powerchat-webhook');
const webhook = require('./powerchat-webhook');
@@ -199,6 +199,25 @@ router.get('/tip-link', requireAuth, (req, res) => {
@@ -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) ─
// ── 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.
// The most useful test: confirms the streamer's alert sound + chat celebration render.
router.post('/test-tip', requireAuth, async (req, res) => {
router.post('/test-tip', requireAuth, async (req, res) => {
@@ -187,6 +187,13 @@ function processEvent(envelope) {
@@ -187,6 +187,13 @@ function processEvent(envelope) {
case 'donation.completed':
case 'donation.completed':
// The authoritative money event. paid_message.created is a subset of this
// 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.
// (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);
_handleDonation(userId, data);
break;
break;
case 'subscription.created':
case 'subscription.created':
@@ -256,4 +263,9 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
@@ -256,4 +263,9 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
return { amount, live: !!streamId };
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,
+};
@@ -57,6 +57,17 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
@@ -57,6 +57,17 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
const r = await pay.cryptoCreateInvoice({ order, amountUsd, description: name });
const r = await pay.cryptoCreateInvoice({ order, amountUsd, description: name });
return res.json({ url: r.url });
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' });
return res.status(400).json({ error: 'Unknown payment provider' });
} catch (err) {
} catch (err) {
console.error('[Payments] checkout error:', err.message);
console.error('[Payments] checkout error:', err.message);
@@ -96,16 +107,37 @@ router.post('/subscribe', requireAuth, async (req, res) => {
@@ -96,16 +107,37 @@ router.post('/subscribe', requireAuth, async (req, res) => {
const priceUsd = pay._num('sub_price_usd', 4.99);
const priceUsd = pay._num('sub_price_usd', 4.99);
const amountCents = Math.round(priceUsd * 100);
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') {
if (provider === 'bucks') {
+ const autoRenew = req.body.auto_renew === undefined ? 1 : (req.body.auto_renew ? 1 : 0);
const cost = pay.bucksForUsd(priceUsd);
const cost = pay.bucksForUsd(priceUsd);
if (!db.deductVibes(req.user.id, cost)) return res.status(402).json({ error: `Not enough Vibes (need ${cost})` });
if (!db.deductVibes(req.user.id, cost)) return res.status(402).json({ error: `Not enough Vibes (need ${cost})` });
const order = db.createPaymentOrder({
const order = db.createPaymentOrder({
user_id: req.user.id, provider: 'bucks', kind: 'subscription',
user_id: req.user.id, provider: 'bucks', kind: 'subscription',
amount_cents: amountCents, streamer_id: streamer.id, status: 'paid',
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).
// Stripe recurring subscription (auto-renew monthly).
@@ -48,6 +48,10 @@ function publicConfig() {
@@ -48,6 +48,10 @@ function publicConfig() {
stripe: isEnabled() && b('stripe_enabled') && !!s('stripe_secret_key'),
stripe: isEnabled() && b('stripe_enabled') && !!s('stripe_secret_key'),
ccbill: isEnabled() && b('ccbill_enabled') && !!s('ccbill_flexform_id'),
ccbill: isEnabled() && b('ccbill_enabled') && !!s('ccbill_flexform_id'),
crypto: isEnabled() && b('crypto_enabled') && !!s('crypto_api_key'),
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'),
stripePublishableKey: s('stripe_publishable_key'),
};
};
@@ -245,8 +249,11 @@ function fulfillBucksOrder(order) {
@@ -245,8 +249,11 @@ function fulfillBucksOrder(order) {
return true;
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;
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
@@ -257,13 +264,13 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
@@ -257,13 +264,13 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
subscriber_id: order.user_id, streamer_id: order.streamer_id, tier: 1,
provider: order.provider, provider_ref: providerRef || order.provider_ref,
provider: order.provider, provider_ref: providerRef || order.provider_ref,
price_cents: order.amount_cents, currency: order.currency || 'usd',
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') {
if (order.status !== 'credited') {
// 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 = 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);
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) —
@@ -282,8 +289,69 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
@@ -282,8 +289,69 @@ function fulfillSubscriptionOrder(order, { providerRef = null, periodEnd = null
return sub;
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 = {
module.exports = {
- isEnabled, publicConfig, bucksForUsd,
+ isEnabled, publicConfig, bucksForUsd, startRenewalSweeper,
// stripe
// stripe
stripeCheckout, stripeVerify,
stripeCheckout, stripeVerify,
// paypal
// paypal