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:
martbost
2026-09-16 12:24:53 -05:00
parent dffcaa113b
commit a727ab1ce5
6 changed files with 145 additions and 2 deletions
+25
View File
@@ -364,6 +364,31 @@
+ (d.campaigns.length ? d.campaigns.map(c => '<tr><td>' + c.id + '</td><td>' + esc(c.type) + '</td><td>' + esc(c.status) + '</td><td>' + Number(c.budget || 0).toLocaleString() + '</td><td>' + Number(c.spent || 0).toLocaleString() + '</td><td>' + Number(c.views || 0).toLocaleString() + '</td><td>' + Number(c.clicks || 0).toLocaleString() + '</td><td class="small muted">' + when(c.created) + '</td></tr>').join('') : '<tr><td colspan="8" class="muted">No campaigns.</td></tr>') + '</table></div>';
$('mcBody').innerHTML = h;
if (location.hash !== '#members') history.replaceState(null, '', '#members');
loadTrace(d);
}
// payment trace under the member card (2026-09-16): per purchase, who was paid / skipped and why
async function loadTrace(d) {
const ch = d.chain || {}; const mid = ch.memberId || (d.account && d.account.memberId) || 0;
const box = document.createElement('div'); box.id = 'mcTrace'; box.innerHTML = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small muted">Reading the chain…</p>';
$('mcBody').appendChild(box);
if (!mid) { box.innerHTML = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small muted">Not activated on the chain, nothing to trace.</p>'; return; }
try {
const r = await api('/api/admin/trace?who=' + mid);
const nm = id => id ? ('#' + id + (r.names[id] ? ' @' + esc(r.names[id]) : '')) : 'nobody';
let h = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small muted" style="margin:0 0 6px">' + r.buyersNow + ' qualifying buyer' + (r.buyersNow === 1 ? '' : 's') + ' · every purchase this member was part of, newest first.</p>';
if (!r.purchases.length) h += '<p class="small muted">No purchases involving them yet.</p>';
for (const p of r.purchases) {
h += '<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:8px 10px;margin:0 0 6px"><div class="small"><b>' + when(p.ts) + '</b> · ' + nm(p.buyerId) + ' bought $' + (p.priceCents / 100).toFixed(0) + ' (' + polOf(p.paidWei) + ' POL)' + (p.sponsorId ? ' · sponsor ' + nm(p.sponsorId) : '') + ' · <span class="muted">' + esc(p.tx.slice(0, 12)) + '…</span></div>';
for (const t of p.tiers) {
let line = '<b>L' + t.tier + ' ' + t.pct + '%</b>: ';
for (const x of t.skipped) line += '<span style="color:#f0a742">' + nm(x.id) + ' skipped (' + (x.reason === 'unqualified' ? 'had ' + x.buyersThen + ' of ' + (t.tier === 2 ? 2 : 5) + ' buyers then' : esc(x.reason)) + ')</span> → ';
line += t.paidTo ? '<span style="color:' + (t.paidTo === mid ? '#3ecf7a' : 'inherit') + '">' + nm(t.paidTo) + ' ' + polOf(t.amountWei) + ' POL</span>' : '<span class="muted">no catcher → company</span>';
h += '<div class="small" style="margin-top:3px">' + line + '</div>';
}
h += '<div class="small muted" style="margin-top:3px">platform ' + polOf(p.adminWei) + ' POL</div></div>';
}
box.innerHTML = h;
} catch (e) { box.innerHTML = '<h4 style="margin:18px 0 6px">Payment trace</h4><p class="small bad">' + esc(e.message) + '</p>'; }
}
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
// live matches while typing: any part of the username, email, member #, share code or wallet
+39
View File
@@ -777,6 +777,45 @@
fill('refFeed', a.referrals, 'No referral activity yet. Share your invite link.');
fill('buyFeed', a.purchases, 'No purchases from your wallet yet.');
} catch (e) {}
initTrace();
}
// payment trace (2026-09-16): who was paid, who was skipped and why, per purchase
let traceInit = false;
function initTrace() {
if (traceInit || !$('trGo')) return; traceInit = true;
const run = async () => {
const who = ($('trWho').value || '').trim(); const out = $('trOut'); const msg = $('trMsg');
msg.textContent = 'Reading the chain…'; out.innerHTML = '';
try {
const c = await IAP.getConfig();
const r = await (await fetch('/api/my/trace?who=' + encodeURIComponent(who))).json();
if (r.error) { msg.textContent = r.error; return; }
msg.textContent = '';
const nm = id => id ? ('#' + id + (r.names[id] ? ' @' + esc(r.names[id]) : '')) : 'nobody';
const pol = w => IAP.fmtPol(w) + ' POL';
const when = ts => new Date(ts).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
let h = '<p class="small" style="margin:0 0 8px"><b>' + nm(r.memberId) + '</b> · ' + (r.activated ? 'activated under ' + nm(r.sponsorId) : 'not activated on the chain yet') + ' · ' + r.buyersNow + ' qualifying buyer' + (r.buyersNow === 1 ? '' : 's') + ' (level 2 needs 2, level 3 needs 5)</p>';
if (!r.purchases.length) { out.innerHTML = h + '<p class="muted small">No purchases involving ' + nm(r.memberId) + ' yet.</p>'; return; }
for (const p of r.purchases) {
const isBuyer = p.buyerId === r.memberId;
h += '<div class="card" style="padding:10px 12px;margin:0 0 8px">'
+ '<div class="small"><b>' + when(p.ts) + '</b> · ' + nm(p.buyerId) + ' bought a $' + (p.priceCents / 100).toFixed(0) + ' package (' + pol(p.paidWei) + ')' + (p.sponsorId ? ' · sponsor ' + nm(p.sponsorId) : '')
+ (c.explorer ? ' · <a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + p.tx + '">verify ↗</a>' : '') + '</div>';
for (const t of p.tiers) {
const me = t.paidTo === r.memberId, skippedMe = t.skipped.some(x => x.id === r.memberId);
let line = '<b>Level ' + t.tier + ' (' + t.pct + '%)</b>: ';
for (const x of t.skipped) line += '<span style="color:#f0a742">' + nm(x.id) + ' skipped (' + (x.reason === 'unqualified' ? 'not qualified: had ' + x.buyersThen + ' of ' + (t.tier === 2 ? 2 : 5) + ' qualifying buyers then' : esc(x.reason)) + ')</span> → ';
line += t.paidTo ? '<span style="color:' + (me ? '#3ecf7a' : 'inherit') + '">' + nm(t.paidTo) + ' paid ' + pol(t.amountWei) + '</span>' : '<span class="muted">no eligible catcher within reach → company</span>';
h += '<div class="small" style="margin-top:4px' + (me || skippedMe ? ';background:rgba(255,255,255,.04);border-radius:6px;padding:2px 6px' : '') + '">' + line + '</div>';
}
h += '<div class="small muted" style="margin-top:4px">Platform (20%): ' + pol(p.adminWei) + (isBuyer ? '' : '') + '</div></div>';
}
h += '<p class="muted small" style="margin:6px 0 0">A skipped level means that person had not unlocked it when the purchase settled. Qualification never applies backwards; the next purchase after they qualify pays them.</p>';
out.innerHTML = h;
} catch (e) { msg.textContent = 'Could not read the chain just now. Try again in a minute.'; }
};
$('trGo').addEventListener('click', run);
$('trWho').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); run(); } });
}
// credit activity ledger under the campaign table (2026-09-16): every earn and spend with its reason