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
+1
View File
@@ -85,6 +85,7 @@ FACTS:
- PROMO TOOLKIT BY BADGE + AI COPY ENGINE (2026-09-14): Promo tools > AI Copy Engine shows the ladder: Free (links, posts, swipes, banners, wall, objections, shorts, badge pages), Spark (payouts on: one-tap campaign templates aimed at the member's link, printable handout with their QR at /handout/<username>), Surge (first qualifying buyer; the AI Copy Engine is live: posts, DMs, follow-ups, objection replies, emails, story posts, team broadcasts in the member's name with their link, honesty rules built in; 20 free generations a month), Circuit (60 free; Video Maker renders every promo video and short with the member's own end card and QR, hosted for download; split tester compares join angles by views, joins, buyers), Nexus (150 free; Leader Ops: three-level team triage with stalled flags and one-click nudges, AI-drafted team broadcasts, credit grants from the leader's earned pool to anyone in their line, co-branded join page showing the sponsor's bio, and the member's own partner code: welcome credits funded from the leader's pool at each redemption, plus a partner kit page /partners?ref=<username>&promo=CODE). After the free allowance each generation costs 10 ad credits from the earned pool. Credits are advertising, never money.
- MEMBER UPDATE EMAILS (2026-09-14, admin only): Admin > Releases > Email an update to members: pick release notes, add an intro, choose an audience (newsletter opt-ins, opt-ins active in the last 30 days, or everyone including those who declined), preview, send a test to the admin, then send; plain text from no-reply@instantadpay.com with an opt-out link; the log shows sent/opted out/failed.
- LINKED POSITIONS AND BADGES (2026-09-14): qualifying buyers on a member's linked positions count toward their achievement badges (Spark/Surge/Circuit/Nexus) and light their chip on their sponsor's line, and a badge once earned never regresses. The contract still pays levels 2 and 3 to each position based on that position's OWN qualifying buyers, so the Qualifying buyers tile shows the main position's count with the linked positions' count underneath.
- SPONSOR HOLD (2026-09-14): if a member's account names a sponsor that cannot be paid right now (sponsor has not switched on payouts, or the chain lookup failed), the first purchase and the payouts activation are held with a message instead of silently crediting the company. Fix: the sponsor switches on payouts, or the member retries; the admin gets a Telegram alert.
- NO-PAYOUT POSITIONS (2026-09-14): admin can list member ids (Settings > No-payout positions) whose wallets must never be paid again (e.g. a compromised key). Those positions stay linked for history and counting only: the buy-from picker disables them, a purchase from them is refused, and invite links that would place a new member under them (or under their upline chain) route to the fallback sponsor instead. The contract itself cannot change a member's wallet.
- LINE AT A GLANCE, ALL LEVELS (2026-09-14): a gold chip on any level means that person made their $20+ buy (a qualifying buyer for their own sponsor); a small number on the chip is how many qualifying buyers of their own they have. My line rows say the same in words. Leaders use it to see who is one buyer short and encourage them.
- MY LINE SPONSOR LINE (2026-09-14): every row on My line, all three levels, shows who sponsored that person ("sponsored by @name", or "you" for directs).
+9
View File
@@ -1933,6 +1933,7 @@
if (cur !== wantAddr) throw new Error('Your wallet connected as ' + cur.slice(0, 6) + '…' + cur.slice(-4) + ' but you chose ' + wantAddr.slice(0, 6) + '…' + wantAddr.slice(-4) + '. Switch accounts in your wallet app and try again.');
}
const spNow = fromPos ? { sponsorId: meNow.memberId } : await jretry('/api/sponsor');
if (!fromPos && !meNow.memberId && spNow.sponsorBlocked) { IAP.status(sponsorHoldText(spNow), 'bad'); return; } // first activation must not hand a sponsored member to the company
// pre-flight: stop early if the POL is not there. Trust Wallet also hard-blocks any
// transaction that spends most of the balance ("drain your wallet"), so Trust users
// get a heads-up first; other wallets go straight to the confirmation.
@@ -2169,8 +2170,16 @@
IAP.status('Disconnecting — reloading…', 'ok');
setTimeout(() => location.reload(), 900);
}));
// a named sponsor that cannot be paid right now: hold the transaction and say why (never credit the company by default)
function sponsorHoldText(sp) {
const who = sp.sponsorName || 'your sponsor';
if (sp.sponsorBlocked === 'notActivated') return who + ' has not switched on payouts yet, so this purchase would credit the company instead of them. Ask them to switch on payouts (Wallet tab, one free transaction), then try again.';
if (sp.sponsorBlocked === 'rpc') return 'Could not confirm your sponsor on the chain just now. Try again in a minute; nothing was charged.';
return 'Your sponsor link could not be matched to a member. Message support before buying so ' + who + ' gets credit.';
}
$('activateBtn').addEventListener('click', busy($('activateBtn'), async () => {
const me = await (await fetch('/api/me')).json();
if (me.sponsorBlocked) { IAP.status(sponsorHoldText(me), 'bad'); return; }
IAP.status('Confirm the free activation in your wallet…');
const r = await IAPWallet.activate(me.sponsorId || 0);
if (r.receipt.status !== '0x1') throw new Error('Transaction reverted. Check the explorer.');
+1 -1
View File
@@ -984,7 +984,7 @@
<script src="/assets/common.js?v=20260914a"></script>
<script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260914l"></script>
<script src="/assets/my.js?v=20260914m"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+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,