openvibers/openvibe.live Commits
PowerChat checkout: pinned amounts, purpose keys, goal targeting, return page
Changes
@@@ -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;@@@ -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);@@@ -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 ──────────────────────────────────────@@@ -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' });}@@@ -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.@@@ -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' });
@@ -225,7 +225,11 @@ async function doPowerchatTip() {
@@ -225,7 +225,11 @@ async function doPowerchatTip() {
const streamerId = _donateStreamerId();
const streamerId = _donateStreamerId();
if (!streamerId) return toast('No streamer selected', 'error');
if (!streamerId) return toast('No streamer selected', 'error');
try {
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');
if (!data.url) throw new Error('unavailable');
window.open(data.url, '_blank', 'noopener');
window.open(data.url, '_blank', 'noopener');
toast('Complete your tip on PowerChat — it lands in this channel automatically once confirmed. ⚡', 'success');
toast('Complete your tip on PowerChat — it lands in this channel automatically once confirmed. ⚡', 'success');
@@ -233,6 +237,19 @@ async function doPowerchatTip() {
@@ -233,6 +237,19 @@ async function doPowerchatTip() {
} catch (e) { toast('PowerChat tips aren’t available for this channel.', 'error'); }
} 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).
// 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;
@@ -1063,6 +1063,10 @@ function initDb() {
@@ -1063,6 +1063,10 @@ function initDb() {
// the app credentials above handle attribution, and that PowerChat account
// the app credentials above handle attribution, and that PowerChat account
// must have the app connected on PowerChat's side so webhooks fire for it.
// 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'],
['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 (?, ?, ?, ?)");
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);
@@ -58,19 +58,38 @@ function _checkoutConn(userId) {
@@ -58,19 +58,38 @@ function _checkoutConn(userId) {
}
}
// Canonical attribution link (documented stable URL shape — no API round-trip needed).
// 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 c = oauth.getConfig();
const params = new URLSearchParams({ app_client_id: c.clientId });
const params = new URLSearchParams({ app_client_id: c.clientId });
if (ref) params.set('app_ref', ref);
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()}`;
return `${c.baseUrl}/${encodeURIComponent(pcUsername)}/tip?${params.toString()}`;
}
}
// ── Link builders ────────────────────────────────────────────
// ── 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) {
function buildPurchaseLink(order) {
const site = getSiteAccount();
const site = getSiteAccount();
if (!site) return null;
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.
// Subscription: streamer-direct when possible, site fallback otherwise.
@@ -79,26 +98,32 @@ function buildPurchaseLink(order) {
@@ -79,26 +98,32 @@ function buildPurchaseLink(order) {
// decide the streamer share and the sub's auto_renew flag.
// decide the streamer share and the sub's auto_renew flag.
function buildSubscribeLink(order, streamerUserId, { autoRenew = 0 } = {}) {
function buildSubscribeLink(order, streamerUserId, { autoRenew = 0 } = {}) {
const suffix = autoRenew ? ':renew' : '';
const suffix = autoRenew ? ':renew' : '';
+ // Sub checkouts are PINNED to the sub price (no more underpaid tips to
+ // gracefully degrade) and categorized "subscription".
+ const opts = { purpose: 'subscription', amountCents: order.amount_cents, returnTo: true };
const direct = _checkoutConn(streamerUserId);
const direct = _checkoutConn(streamerUserId);
if (direct) {
if (direct) {
db.updatePaymentOrder(order.id, { provider_ref: `direct${suffix}` });
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();
const site = getSiteAccount();
if (!site) return null;
if (!site) return null;
db.updatePaymentOrder(order.id, { provider_ref: `site${suffix}` });
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);
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();
const site = getSiteAccount();
if (!site) return null;
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 ──────────────────────────────────────
// ── Webhook fulfillment ──────────────────────────────────────
@@ -111,6 +111,22 @@ router.get('/oauth/callback', async (req, res) => {
@@ -111,6 +111,22 @@ router.get('/oauth/callback', async (req, res) => {
const send = (p) => res.set('Content-Type', 'text/html').send(resultPage(p));
const send = (p) => res.set('Content-Type', 'text/html').send(resultPage(p));
try {
try {
const { code, state, error, error_description } = req.query;
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
// 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
// diagnosis (e.g. "Not registered for this app: follows:write … add them to the app
// registration first"), which the bare error code alone hides.
// registration first"), which the bare error code alone hides.
@@ -210,9 +226,16 @@ router.get('/donate-link', optionalAuth, (req, res) => {
@@ -210,9 +226,16 @@ router.get('/donate-link', optionalAuth, (req, res) => {
? db.getUserById(parseInt(req.query.streamer_id, 10))
? db.getUserById(parseInt(req.query.streamer_id, 10))
: db.getUserByUsername(String(req.query.streamer || ''));
: db.getUserByUsername(String(req.query.streamer || ''));
if (!streamer) return res.status(404).json({ error: 'Streamer not found' });
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' });
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) {
} catch (err) {
res.status(500).json({ error: 'Failed to build tip link' });
res.status(500).json({ error: 'Failed to build tip link' });
}
}
@@ -67,11 +67,26 @@ function _resolveStreamerUserId(streamer) {
@@ -67,11 +67,26 @@ function _resolveStreamerUserId(streamer) {
return conn ? conn.user_id : null;
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 ───────────────────────
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
@@ -86,7 +101,7 @@ function _handleDonation(userId, data) {
@@ -86,7 +101,7 @@ function _handleDonation(userId, data) {
const amount = Math.max(0, Math.round(cents)); // Vibes (= USD cents)
const amount = Math.max(0, Math.round(cents)); // Vibes (= USD cents)
const donor = String(data.donorName || 'Someone').slice(0, 80);
const donor = String(data.donorName || 'Someone').slice(0, 80);
const message = String(data.message || '').slice(0, 500);
const message = String(data.message || '').slice(0, 500);
- const goalId = _goalIdFromRef(data.appExternalRef);
+ const goalId = _goalIdFromDonation(data);
const ts = new Date().toISOString();
const ts = new Date().toISOString();
// If the streamer is live, attach to the live session so it lands in that slot too.
// If the streamer is live, attach to the live session so it lands in that slot too.
@@ -178,7 +193,9 @@ function processEvent(envelope) {
@@ -178,7 +193,9 @@ function processEvent(envelope) {
const siteName = String(db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
const siteName = String(db.getSetting('powerchat_site_tip_username') || '').trim().toLowerCase();
const from = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
const from = String((envelope.streamer && envelope.streamer.username) || '').toLowerCase();
if (siteName && from === siteName) {
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); }
try { require('./powerchat-checkout').handleAttributedDonation(null, data); } catch (e) { console.warn('[PowerChat] site-account fulfillment:', e.message); }
}
}
return;
return;
@@ -186,10 +203,11 @@ function processEvent(envelope) {
@@ -186,10 +203,11 @@ function processEvent(envelope) {
console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer);
console.warn(`[PowerChat] webhook ${envelope.type} for unknown streamer`, envelope.streamer);
return;
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
// 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
// via the platform scopes) echoing back through the webhook — they already happened
// locally, so replaying them would double-count/duplicate every one.
// locally, so replaying them would double-count/duplicate every one.
@@ -58,14 +58,14 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
@@ -58,14 +58,14 @@ router.post('/bucks/checkout', requireAuth, async (req, res) => {
return res.json({ url: r.url });
return res.json({ url: r.url });
}
}
if (provider === 'powerchat') {
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);
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' }); }
if (!link) { db.updatePaymentOrder(order.id, { status: 'failed' }); return res.status(400).json({ error: 'PowerChat purchases are not available right now' }); }
return res.json({
return res.json({
url: link.url, powerchat: true, amountUsd,
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' });
return res.status(400).json({ error: 'Unknown payment provider' });