Sponsor hold: a named sponsor that cannot be paid (payouts off, chain lookup failed, unknown) blocks the first purchase and activation with a message and alerts the admin, instead of silently crediting the company

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-14 21:00:27 -05:00
parent 1533d377cc
commit f155a8cc24
4 changed files with 37 additions and 10 deletions
+26 -9
View File
@@ -514,14 +514,28 @@ async function payoutChainBlocked(id, hops) {
for (let i = 0; i <= hops && cur; i++) { if (bad.has(cur)) return cur; let m = null; try { m = await chain.member(cur); } catch (e) { break; } cur = m ? Number(m.sponsorId) || 0 : 0; }
return 0;
}
async function resolveSponsorToken(tok) {
// Why a sponsor token did not resolve matters (vladz79 → #24 locked under the company on 2026-09-13 because a
// lookup came back empty and the code fell back silently): 'ok' | 'none' (no token) | 'unknown' (no such account)
// | 'notActivated' (sponsor has no wallet / payouts off) | 'rpc' (chain lookup failed right now).
async function resolveSponsorDetailed(tok) {
const t = String(tok || '').trim().toLowerCase();
if (!t) return 0;
if (/^\d+$/.test(t)) return Number(t);
if (!t) return { id: 0, reason: 'none' };
if (/^\d+$/.test(t)) return { id: Number(t), reason: 'ok' };
let acct = await accounts.byCode(t);
if (!acct) acct = await accounts.byUsername(t); // vanity links: /join/<username>
if (!acct || !acct.address) return 0;
try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; }
if (!acct) return { id: 0, reason: 'unknown', name: t };
if (!acct.address) return { id: 0, reason: 'notActivated', name: acct.username ? '@' + acct.username : t };
try { const id = await chain.memberIdByAccount(acct.address); return { id, reason: id ? 'ok' : 'notActivated', name: acct.username ? '@' + acct.username : t }; }
catch (e) { return { id: 0, reason: 'rpc', name: acct.username ? '@' + acct.username : t }; }
}
async function resolveSponsorToken(tok) { return (await resolveSponsorDetailed(tok)).id; }
const sponsorAlertLast = new Map(); // email -> ts (one alert per member per hour)
function sponsorBlockedAlert(who, r) {
const k = String(who || '?'); if (Date.now() - (sponsorAlertLast.get(k) || 0) < 3600000) return; sponsorAlertLast.set(k, Date.now());
const text = '\u26A0\uFE0F InstantAdPay: purchase held for ' + k.replace(/^(.{2}).*(@.*)$/, '$1***$2') + '. Their sponsor ' + (r.name || r.tok || '?') + ' could not be resolved (' + r.reason + '), so the buy was blocked instead of crediting the company. ' + (r.reason === 'notActivated' ? 'The sponsor needs to switch on payouts.' : r.reason === 'rpc' ? 'Chain lookup failed; they can retry.' : 'Check the sponsor field in Admin > Members.');
const sc = siteConfig();
if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {});
else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: purchase held, sponsor unresolved', text).catch(() => {});
}
// The moment someone joins through a code, nudge its owner to activate.
// Email a member's sponsor the moment they get a new referral (free OR paid).
@@ -847,7 +861,9 @@ const server = http.createServer(async (req, res) => {
const s = await auth.fromRequest(req);
const acct = s && s.email ? await accounts.byEmail(s.email) : null;
const tok = (acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor'] || '';
let sponsorId = await resolveSponsorToken(tok);
const spd = await resolveSponsorDetailed(tok); let sponsorId = spd.id;
let sponsorBlocked = null; // set when the account itself names a sponsor that cannot be paid right now: the client refuses the transaction
if (acct && acct.sponsorRef && !sponsorId && spd.reason !== 'none') { sponsorBlocked = spd.reason; sponsorBlockedAlert(acct.email, Object.assign({ tok }, spd)); }
if (sponsorId && await payoutChainBlocked(sponsorId, 2)) { console.log('sponsor routed away from no-payout chain', sponsorId); sponsorId = 0; }
// orphan fallback: an unresolvable/absent sponsor (dead link, no link) lands
// the new member under the configured catch position (#1) instead of root
@@ -864,7 +880,7 @@ const server = http.createServer(async (req, res) => {
if (!a && /^\d+$/.test(nameTok)) a = await accounts.byMemberId(Number(nameTok));
if (a) { name = a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : null); avatarUrl = a.avatarUrl || null; own = !!(acct && a.email === acct.email); var bio = null, cobrand = false; try { cobrand = (await ads.milestonesOf(a.email)).includes('level3'); if (cobrand) bio = a.bio ? String(a.bio).slice(0, 220) : null; } catch (e) {} }
}
return json(res, 200, { ref: tok, sponsorId, invited: !!(tok || showTok), name, avatarUrl, own, bio: typeof bio === 'undefined' ? null : bio, cobrand: typeof cobrand === 'undefined' ? false : cobrand });
return json(res, 200, { ref: tok, sponsorId, sponsorBlocked, sponsorName: spd.name || null, invited: !!(tok || showTok), name, avatarUrl, own, bio: typeof bio === 'undefined' ? null : bio, cobrand: typeof cobrand === 'undefined' ? false : cobrand });
}
if (p === '/api/stats' && req.method === 'GET') {
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
@@ -1032,12 +1048,13 @@ const server = http.createServer(async (req, res) => {
if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
let sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
const spdMe = await resolveSponsorDetailed((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']); let sponsorId = spdMe.id;
const sponsorBlocked = (acct && acct.sponsorRef && !sponsorId && spdMe.reason !== 'none') ? spdMe.reason : null;
if (sponsorId && await payoutChainBlocked(sponsorId, 2)) sponsorId = 0;
const _defSpon = Number(siteConfig().defaultSponsorId) || 1;
if (!sponsorId && memberId !== _defSpon) sponsorId = _defSpon; // orphan fallback → #1 (never self-sponsor)
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
const out = { signedIn: true, sponsorBlocked, sponsorName: spdMe.name || null, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, sponsorId,