Payment trace: self-serve "who was paid, who was skipped and why" per purchase (Earnings tab + admin member card)
Marty, 2026-09-16, after the third "why didn't my sponsor get paid" thread of the day (livedreams / gracie25,
Morten / Terry's linked wallets). GET /api/my/trace?who=<#|username> (yourself, anyone up to 3 levels
below you, or your own 3 uplines) and GET /api/admin/trace?who= list every purchase the member was
part of, newest first: buyer, sponsor, package, then levels 1-3 with the recipient, the skipped
positions and the reason ("not qualified: had N of 2 qualifying buyers then"), the platform share, and
a verify link. Linked extra wallets are named after their owner. Earnings tab card "Trace a payment";
admin member card gets a "Payment trace" section; chatbot canned answer + AI prompt route the
question there.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -502,6 +502,49 @@ function isAdmin(req) {
|
||||
return !!adminFromRequest(req); // /admin portal session
|
||||
}
|
||||
// attach a memberId->username map to events so activity shows real people
|
||||
// Payment trace (Marty, 2026-09-16): every purchase a member is involved in, tier by tier — who was
|
||||
// paid, who was skipped and why (with how many qualifying buyers the skipped person had at that block)
|
||||
// — so "why didn't X get paid on Y's buy" becomes a self-serve lookup instead of a support thread.
|
||||
async function nameForMember(id) {
|
||||
try { const a = await accounts.byMemberId(id); if (a && a.username) return a.username; } catch (e) {}
|
||||
if (db.enabled()) { // a linked extra wallet: name it after its owner
|
||||
try { const r = await db.q('SELECT email FROM positions WHERE member_id=? LIMIT 1', [Number(id)]); if (r.length) { const a = await accounts.byEmail(r[0].email); if (a && a.username) return a.username + ' (linked wallet)'; } } catch (e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function sponsorMapFromIndex(evs) { const m = new Map(); for (const e of evs) if (e.type === 'MemberActivated' && e.id) m.set(e.id, Number(e.sponsorId) || 0); return m; }
|
||||
function uplineIds(map, id, hops) { const out = []; let cur = map.get(Number(id)) || 0; for (let i = 0; i < hops && cur; i++) { out.push(cur); cur = map.get(cur) || 0; } return out; }
|
||||
async function tracePurchases(targetId, limit) {
|
||||
const id = Number(targetId) || 0; if (!id) return { error: 'Which member?' };
|
||||
const evs = chain.recentEvents(1e9); // whole history, newest first
|
||||
const byTx = new Map();
|
||||
for (const e of evs) { if (!e.tx) continue; let g = byTx.get(e.tx); if (!g) { g = []; byTx.set(e.tx, g); } g.push(e); }
|
||||
const counted = evs.filter(e => e.type === 'BuyerCounted');
|
||||
const buyersBefore = (sid, block) => counted.filter(e => e.sponsorId === sid && e.block < block).length;
|
||||
const out = [];
|
||||
for (const e of evs) {
|
||||
if (e.type !== 'Purchase') continue;
|
||||
const g = byTx.get(e.tx) || [];
|
||||
const paid = g.filter(x => x.type === 'TierPaid' && x.buyerId === e.buyerId);
|
||||
const skipped = g.filter(x => x.type === 'PassedUp' && x.buyerId === e.buyerId);
|
||||
const act = g.find(x => x.type === 'MemberActivated' && x.id === e.buyerId);
|
||||
const admin = g.find(x => x.type === 'AdminPaid' && x.buyerId === e.buyerId);
|
||||
const involved = e.buyerId === id || paid.some(x => x.recipientId === id) || skipped.some(x => x.skippedId === id) || (act && act.sponsorId === id);
|
||||
if (!involved) continue;
|
||||
const tiers = [1, 2, 3].map(t => {
|
||||
const p = paid.find(x => x.tier === t); const sk = skipped.filter(x => x.tier === t);
|
||||
return { tier: t, pct: [50, 20, 10][t - 1], paidTo: p ? p.recipientId : 0, amountWei: p ? p.amountWei : '0', hops: p ? p.hops : 0,
|
||||
skipped: sk.map(x => ({ id: x.skippedId, reason: x.reason, buyersThen: x.reason === 'unqualified' ? buyersBefore(x.skippedId, e.block) : null })) };
|
||||
});
|
||||
out.push({ ts: e.ts, block: e.block, tx: e.tx, buyerId: e.buyerId, sponsorId: act ? act.sponsorId : null, priceCents: e.priceCents, paidWei: e.paidWei, tiers, adminWei: admin ? admin.amountWei : '0' });
|
||||
if (out.length >= (limit || 30)) break;
|
||||
}
|
||||
const ids = new Set([id]);
|
||||
for (const p of out) { ids.add(p.buyerId); if (p.sponsorId) ids.add(p.sponsorId); for (const t of p.tiers) { if (t.paidTo) ids.add(t.paidTo); for (const x of t.skipped) ids.add(x.id); } }
|
||||
const names = {}; for (const i of ids) { const n = await nameForMember(i); if (n) names[i] = n; }
|
||||
const smap = sponsorMapFromIndex(evs);
|
||||
return { memberId: id, name: names[id] || null, activated: smap.has(id), sponsorId: smap.get(id) || 0, buyersNow: counted.filter(e => e.sponsorId === id).length, purchases: out, names };
|
||||
}
|
||||
async function attachNames(evts) {
|
||||
try {
|
||||
const ids = [];
|
||||
@@ -2491,6 +2534,21 @@ const server = http.createServer(async (req, res) => {
|
||||
res.writeHead(302, baseHeaders({ Location: target }));
|
||||
return res.end();
|
||||
}
|
||||
if (p === '/api/my/trace' && req.method === 'GET') { // payment trace for yourself or anyone within 3 levels of you, either direction
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const mine = (await myMemberIds(s)).ids.filter(Boolean);
|
||||
const who = String(u.searchParams.get('who') || '').trim().replace(/^[@#]/, '').toLowerCase();
|
||||
let target = /^\d+$/.test(who) ? Number(who) : 0;
|
||||
if (!target && who) { let a = await accounts.byUsername(who); if (!a) a = await accounts.byCode(who); if (a && a.memberId) target = a.memberId; }
|
||||
if (!target && !who && mine.length) target = mine[0];
|
||||
if (!target) return json(res, 400, { error: who ? 'No activated member called "' + who.slice(0, 40) + '". Use a member number or a username of someone who has bought a package.' : 'Enter a member number or username.' });
|
||||
if (!mine.length) return json(res, 403, { error: 'Link a wallet and buy a package first; the trace works on chain positions.' });
|
||||
const smap = sponsorMapFromIndex(chain.recentEvents(1e9));
|
||||
const ok = mine.includes(target) || uplineIds(smap, target, 3).some(x => mine.includes(x)) || mine.some(m => uplineIds(smap, m, 3).includes(target));
|
||||
if (!ok) return json(res, 403, { error: 'You can trace yourself, anyone up to 3 levels below you, and your own 3 uplines.' });
|
||||
return json(res, 200, await tracePurchases(target, 30));
|
||||
}
|
||||
if (p === '/api/my/credits/activity' && req.method === 'GET') { // credit ledger: every earn and spend with a reason, newest first
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
@@ -2673,6 +2731,14 @@ const server = http.createServer(async (req, res) => {
|
||||
const walk = detailed.reason === 'notActivated' ? await walkUpActivatedSponsor(ref) : null;
|
||||
return json(res, 200, { ref, detailed, walk, catchId: Number(siteConfig().defaultSponsorId) || 1 });
|
||||
}
|
||||
if (p === '/api/admin/trace' && req.method === 'GET') { // payment trace for any member
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
const who = String(u.searchParams.get('who') || '').trim().replace(/^[@#]/, '').toLowerCase();
|
||||
let target = /^\d+$/.test(who) ? Number(who) : 0;
|
||||
if (!target && who) { let a = await accounts.byUsername(who); if (!a) a = await accounts.byCode(who); if (!a && who.includes('@')) a = await accounts.byEmail(who); if (a && a.memberId) target = a.memberId; }
|
||||
if (!target) return json(res, 400, { error: 'No activated member matches "' + who.slice(0, 40) + '".' });
|
||||
return json(res, 200, await tracePurchases(target, 60));
|
||||
}
|
||||
if (p === '/api/admin/chain/rescan' && req.method === 'POST') { // rebuild the full event history from the deploy block
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, await chain.rescan());
|
||||
|
||||
Reference in New Issue
Block a user