openvibers/openvibe.live Commits

PowerChat checkout: pinned amounts, purpose keys, goal targeting, return page

OP OpenVibers committed 26 Aug 2026 · 79e7dc01a7 · 6 files, +116 −29

Adopt the new attribution parameters: purchases and sub payments now PIN
the checkout amount (app_amount_cents locks the tip page's picker and the
server rejects mismatched submits — no more settling on actuals), every
link carries an app_purpose category ("vibes" / "subscription" /
"goal:<id>" / "donation") echoed back as appPurpose, and the donate
modal's goal pick rides that purpose so the webhook credits the exact goal
the viewer chose (app_ref goal encoding stays as legacy fallback).
Checkouts also send the viewer back to our registered redirect URI, which
now renders a "tip received" page that nudges open tabs to refresh
balances over BroadcastChannel — UX only, the signed webhook remains the
sole confirmation. Per the clarified isTest semantics (isTest = no money
moved), fulfillment drops test deliveries unless the new
powerchat_allow_test_fulfillment admin toggle is on (dev/sandbox, where a
PowerChat with no payout provider delivers everything as a test).

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 8e9e97b6..1ea46785 100644
---- a/public/js/vibes.js
++++ b/public/js/vibes.js
@@@ -225,7 +225,11 @@ 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}`);
++ // Carry the donor's goal pick over — it rides in app_purpose and the webhook
++ // credits that exact goal when the tip confirms.
++ const goalSel = document.getElementById('modal-donate-goal');
++ const goalId = goalSel && goalSel.value ? parseInt(goalSel.value, 10) : null;
++ const data = await api(`/powerchat/donate-link?streamer_id=${streamerId}${goalId ? `&goal_id=${goalId}` : ''}`);
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');
@@@ -233,6 +237,19 @@ async function doPowerchatTip() {
} catch (e) { toast('PowerChat tips aren’t available for this channel.', 'error'); }
}
++// The checkout return page broadcasts completion — refresh balances so purchased
++// Vibes/subs appear without a manual reload. (Webhook-confirmed server-side; this
++// only refreshes the UI.)
++try {
++ const _pcCheckoutBc = new BroadcastChannel('powerchat-checkout');
++ _pcCheckoutBc.onmessage = (e) => {
++ if (!e.data || e.data.status !== 'completed') return;
++ toast('PowerChat payment received — updating your balance… ⚡', 'success');
++ // The webhook usually lands within a couple of seconds of the redirect.
++ setTimeout(() => { if (typeof loadBalance === 'function') loadBalance(); }, 2500);
++ };
++} catch { /* BroadcastChannel unsupported — balance updates on next reload */ }
++
// 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 b0020a6f..d2a92ae0 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1063,6 +1063,10 @@ function initDb() {
// the app credentials above handle attribution, and that PowerChat account
// must have the app connected on PowerChat's side so webhooks fire for it.
['powerchat_site_tip_username', '', 'PowerChat username whose tip page receives site purchases + fallback donations (that account must have the app connected on PowerChat)', 'string'],
++ // isTest deliveries = NO money moved. OFF in production; ON only for
++ // dev/sandbox where PowerChat has no payout provider and every checkout
++ // delivers as a test — otherwise test tips would mint real Vibes/subs.
++ ['powerchat_allow_test_fulfillment', 'false', 'Fulfill PowerChat checkouts from isTest webhook deliveries (dev/sandbox only — test tips move no money)', 'boolean'],
];
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);
ddiff --git a/server/integrations/powerchat-checkout.js b/server/integrations/powerchat-checkout.js
iindex 84ae04f4..a47221bd 100644
---- a/server/integrations/powerchat-checkout.js
++++ b/server/integrations/powerchat-checkout.js
@@@ -58,19 +58,38 @@ function _checkoutConn(userId) {
}
// Canonical attribution link (documented stable URL shape — no API round-trip needed).
--function tipLinkFor(pcUsername, ref) {
++// ref → app_ref: per-TRANSACTION correlation id (which order to mark paid)
++// purpose → app_purpose: CATEGORY slug, stable across purchases of the same thing
++// ("vibes", "subscription", "goal:<id>"); echoed back as appPurpose
++// amountCents → app_amount_cents: pins the checkout to exactly this USD amount — the
++// tip page locks the picker and the server rejects mismatched submits.
++// Valid range 50–1000000; below the floor we leave the amount free.
++// returnTo → app_redirect_uri: send the viewer back to our REGISTERED redirect URI
++// after checkout (UX + correlation only — the webhook stays the ONLY
++// authoritative confirmation).
++function tipLinkFor(pcUsername, ref, { purpose = null, amountCents = null, returnTo = false } = {}) {
const c = oauth.getConfig();
const params = new URLSearchParams({ app_client_id: c.clientId });
if (ref) params.set('app_ref', ref);
++ if (purpose) params.set('app_purpose', String(purpose).slice(0, 64));
++ const cents = Math.round(Number(amountCents) || 0);
++ if (cents >= 50 && cents <= 1000000) params.set('app_amount_cents', String(cents));
++ if (returnTo) params.set('app_redirect_uri', oauth.redirectUri());
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).
++// Buy Vibes: always the site account (the site must receive the money it mints
++// against). The checkout is PINNED to the package price and categorized "vibes".
function buildPurchaseLink(order) {
const site = getSiteAccount();
if (!site) return null;
-- return { url: tipLinkFor(site.username, `pcorder:${order.id}`), mode: 'site' };
++ return {
++ url: tipLinkFor(site.username, `pcorder:${order.id}`, {
++ purpose: 'vibes', amountCents: order.amount_cents, returnTo: true,
++ }),
++ mode: 'site',
++ };
}
// Subscription: streamer-direct when possible, site fallback otherwise.
@@@ -79,26 +98,32 @@ function buildPurchaseLink(order) {
// decide the streamer share and the sub's auto_renew flag.
function buildSubscribeLink(order, streamerUserId, { autoRenew = 0 } = {}) {
const suffix = autoRenew ? ':renew' : '';
++ // Sub checkouts are PINNED to the sub price (no more underpaid tips to
++ // gracefully degrade) and categorized "subscription".
++ const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };
const direct = _checkoutConn(streamerUserId);
if (direct) {
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
-- return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`), mode: 'direct' };
++ return { url: tipLinkFor(direct.powerchat_username, `pcsub:${order.id}`, opts), 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' };
++ return { url: tipLinkFor(site.username, `pcsub:${order.id}`, opts), 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) {
++// Donation: streamer's own page when they have PowerChat (the normal
++// donation.completed flow handles it), else the site account with a routing ref.
++// When the viewer picked a donation goal before heading over, the goal rides in
++// app_purpose ("goal:<id>") and the webhook credits that exact goal. Amount stays
++// the viewer's free choice — donations are never pinned.
++function buildDonateLink(streamerUserId, donorUserId, { goalId = null } = {}) {
++ const purpose = goalId ? `goal:${goalId}` : 'donation';
const direct = _checkoutConn(streamerUserId);
-- if (direct) return { url: tipLinkFor(direct.powerchat_username, ''), mode: 'direct' };
++ if (direct) return { url: tipLinkFor(direct.powerchat_username, '', { purpose }), mode: 'direct' };
const site = getSiteAccount();
if (!site) return null;
-- return { url: tipLinkFor(site.username, `pcdon:${streamerUserId}:${donorUserId || 0}`), mode: 'site' };
++ return { url: tipLinkFor(site.username, `pcdon:${streamerUserId}:${donorUserId || 0}`, { purpose }), mode: 'site' };
}
// ── Webhook fulfillment ──────────────────────────────────────
ddiff --git a/server/integrations/powerchat-routes.js b/server/integrations/powerchat-routes.js
iindex 137b1eeb..0f7687d7 100644
---- a/server/integrations/powerchat-routes.js
++++ b/server/integrations/powerchat-routes.js
@@@ -111,6 +111,22 @@ router.get('/oauth/callback', async (req, res) => {
const send = (p) => res.set('Content-Type', 'text/html').send(resultPage(p));
try {
const { code, state, error, error_description } = req.query;
++
++ // Checkout RETURN redirect (app_redirect_uri points at this registered URI):
++ // PowerChat sends the viewer back here after a tip with powerchat_status +
++ // powerchat_event_id + our app_ref. This is UX + correlation ONLY — the signed
++ // webhook is the sole authoritative confirmation, so this page never credits
++ // anything; it just tells the viewer and nudges open tabs to refresh balances.
++ if (req.query.powerchat_status) {
++ const ok = String(req.query.powerchat_status) === 'completed';
++ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${ok ? 'Payment received' : 'Checkout'}</title>
++<style>:root{color-scheme:dark}body{font-family:system-ui,sans-serif;background:#0c0c11;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center}
++h2{color:${ok ? '#53fc18' : '#f0a742'};margin-bottom:6px}p{color:#aaa;max-width:360px;line-height:1.5}</style></head>
++<body><div><h2>${ok ? '✓ Tip received!' : 'Checkout not completed'}</h2>
++<p>${ok ? 'Thanks! Your purchase is confirmed automatically within a few seconds — you can close this window.' : 'The tip wasn\'t completed. You can close this window and try again.'}</p></div>
++<script>try{var bc=new BroadcastChannel('powerchat-checkout');bc.postMessage({status:${JSON.stringify(String(req.query.powerchat_status))},ref:${JSON.stringify(String(req.query.app_ref || ''))}});setTimeout(function(){bc.close();window.close();},1800);}catch(e){}</script></body></html>`;
++ return res.set('Content-Type', 'text/html').send(html);
++ }
// Surface PowerChat's full RFC-6749 error: error_description carries the actionable
// diagnosis (e.g. "Not registered for this app: follows:write … add them to the app
// registration first"), which the bare error code alone hides.
@@@ -210,9 +226,16 @@ router.get('/donate-link', optionalAuth, (req, res) => {
? 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);
++ // Optional goal pick — rides in app_purpose ("goal:<id>") so the webhook
++ // credits that exact goal. Only the streamer's own ACTIVE goals qualify.
++ let goalId = null;
++ if (req.query.goal_id) {
++ const g = db.getDonationGoalById(parseInt(req.query.goal_id, 10));
++ if (g && Number(g.user_id) === Number(streamer.id) && g.is_active) goalId = g.id;
++ }
++ const link = require('./powerchat-checkout').buildDonateLink(streamer.id, req.user ? req.user.id : null, { goalId });
if (!link) return res.status(404).json({ error: 'PowerChat tips are not available for this channel' });
-- res.json({ url: link.url, mode: link.mode });
++ res.json({ url: link.url, mode: link.mode, goal_id: goalId });
} catch (err) {
res.status(500).json({ error: 'Failed to build tip link' });
}
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 6f177008..75f7be8d 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -67,11 +67,26 @@ function _resolveStreamerUserId(streamer) {
return conn ? conn.user_id : null;
}
--// app_ref may encode a target goal, e.g. "goal:12" (see the checkout-attribution link).
--function _goalIdFromRef(ref) {
-- if (!ref || typeof ref !== 'string') return null;
-- const m = ref.match(/(?:^|[:_-])goal[:_-]?(\d+)/i);
-- return m ? Number(m[1]) : null;
++// The viewer's chosen donation goal rides in app_purpose ("goal:12" — the purpose
++// key is the category vocabulary; see the checkout-attribution docs). Older links
++// encoded it in app_ref, so that stays as a fallback.
++function _goalIdFromDonation(data) {
++ for (const v of [data && data.appPurpose, data && data.appExternalRef]) {
++ if (!v || typeof v !== 'string') continue;
++ const m = v.match(/(?:^|[:_-])goal[:_-]?(\d+)/i);
++ if (m) return Number(m[1]);
++ }
++ return null;
++}
++
++// data.isTest = NO money moved (free test method, no payout provider connected,
++// dashboard test fires). Honoring those in production would grant Vibes/subs for
++// tips that charged nothing — so they're dropped unless the owner explicitly flips
++// powerchat_allow_test_fulfillment on (for dev/sandbox, where a PowerChat with no
++// payout provider delivers EVERYTHING as isTest).
++function _testFulfillmentAllowed() {
++ try { const v = db.getSetting('powerchat_allow_test_fulfillment'); return v === true || v === 'true' || v === 1 || v === '1'; }
++ catch { return false; }
}
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
@@@ -86,7 +101,7 @@ function _handleDonation(userId, data) {
const amount = Math.max(0, Math.round(cents)); // Vibes (= USD cents)
const donor = String(data.donorName || 'Someone').slice(0, 80);
const message = String(data.message || '').slice(0, 500);
-- const goalId = _goalIdFromRef(data.appExternalRef);
++ const goalId = _goalIdFromDonation(data);
const ts = new Date().toISOString();
// If the streamer is live, attach to the live session so it lands in that slot too.
@@@ -178,7 +193,9 @@ function processEvent(envelope) {
const siteName = String(db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
const from = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
if (siteName && from === siteName) {
-- if (envelope.type === 'donation.completed' && data.appExternalRef && !data.isTest && data.source !== 'developer_app') {
++ if (envelope.type === 'donation.completed' && data.appExternalRef
++ && (!data.isTest || _testFulfillmentAllowed()) && data.source !== 'developer_app') {
++ if (data.isTest) console.warn(`[PowerChat] fulfilling TEST checkout ${data.appExternalRef} (powerchat_allow_test_fulfillment is ON — no money moved)`);
try { require('./powerchat-checkout').handleAttributedDonation(null, data); } catch (e) { console.warn('[PowerChat] site-account fulfillment:', e.message); }
}
return;
@@@ -186,10 +203,11 @@ function processEvent(envelope) {
console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer);
return;
}
-- // Dashboard "Send test webhook" deliveries carry data.isTest — they exist to verify
-- // the receiver (signature/dedupe/wiring), and must NEVER credit goals or fire the
-- // real celebration pipeline.
-- if (data.isTest) { console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)`); return; }
++ // data.isTest = no money moved (dashboard test fires, free test method, no payout
++ // provider). Never credit those — unless the owner explicitly enabled test
++ // fulfillment for dev/sandbox, where every delivery is isTest.
++ if (data.isTest && !_testFulfillmentAllowed()) { console.log(`[PowerChat] test webhook ${envelope.type} verified OK (not credited)`); return; }
++ if (data.isTest) console.warn(`[PowerChat] processing TEST ${envelope.type} (powerchat_allow_test_fulfillment is ON — no money moved)`);
// App-sourced events are OUR OWN pushes (tips/subs/follows/redemptions we forwarded
// via the platform scopes) echoing back through the webhook — they already happened
// locally, so replaying them would double-count/duplicate every one.
ddiff --git a/server/monetization/payments-routes.js b/server/monetization/payments-routes.js
iindex abe98ac5..e70a0bc8 100644
---- a/server/monetization/payments-routes.js
++++ b/server/monetization/payments-routes.js
@@@ -58,14 +58,14 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
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).
++ // Tip the site's PowerChat account; the checkout is PINNED to the package
++ // price (app_amount_cents locks the tip page's amount picker) and the
++ // donation.completed webhook credits the buyer once it confirms.
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).`,
++ note: `Complete the $${amountUsd.toFixed(2)} tip on PowerChat — your Vibes are credited automatically once it confirms.`,
});
}
return res.status(400).json({ error: 'Unknown payment provider' });