Admin: payment-routing / leak map for the org

New chain.getOrgRouting(rootId, ownerIds) simulates the contract's
_payUpline routing for every member below the org root on their NEXT
upgrade, classifying where the POL lands: your positions, a teammate
in-org (healthy), an outsider above the org (true LEAK), or fees.
Surfaced on /api/admin/income and rendered as a panel in "My Positions
— Income": 4-stat summary + expandable list of payments escaping the
org. Distinguishes real leaks (money leaving the team) from teammates
earning — which the old upgrade-needs alert conflated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-15 18:35:48 -05:00
parent 0496584fb2
commit 9717ddbd85
4 changed files with 65 additions and 3 deletions
+47 -1
View File
@@ -521,6 +521,52 @@ function getOwnerUpgradeNeeds(ids) {
return { ready: true, needs }; return { ready: true, needs };
} }
// Payment-routing / leak map: simulates the contract's _payUpline routing for
// every member below `rootId` on their NEXT upgrade, classifying where the POL
// lands — a position you own, a teammate inside your org, an outsider above the
// org (a true LEAK), or fees. Answers "is money escaping my org?".
function getOrgRouting(rootId, ownerIds) {
if (!state || !state.snapshotAt || !costs) return { ready: false };
const M = state.members;
const CROOT = 1; // contract root position (#1); payments passing it go to fees
const owned = new Set((ownerIds || []).map(Number));
const lvl = id => (M[id] && M[id].level) || 1;
const catcher = (fromId, li) => {
let up = M[fromId] && M[fromId].uplineId;
for (let i = 0; i < 16; i++) {
if (!up || up === CROOT) return null;
const u = M[up]; if (!u) return null;
if (i < li) { up = u.uplineId; continue; }
if ((u.level || 1) > li && (u.directCount || 0) >= 2) return up;
up = u.uplineId;
}
return null;
};
const inOrg = id => { let up = M[id] && M[id].uplineId, g = 0; while (up && up !== CROOT && g++ < 48) { if (up === rootId) return true; up = M[up].uplineId; } return false; };
const ids = Object.keys(M).map(Number).filter(id => id !== rootId && inOrg(id));
let ownedPol = 0, teamPol = 0, leakPol = 0, feePol = 0;
const leaks = [];
for (const id of ids) {
const L = lvl(id); if (L >= 8) continue;
const li = L - 1;
const tier = (M[id].tier === 2) ? 2 : 1;
const amt = (costs.up[tier] || [])[li] || 0;
if (!amt) continue;
const c = catcher(id, li);
if (c == null) feePol += amt;
else if (owned.has(c)) ownedPol += amt;
else if (inOrg(c)) teamPol += amt;
else { leakPol += amt; leaks.push({ from: id, to: c, toLevel: lvl(c), amt: +amt.toFixed(2) }); }
}
leaks.sort((a, b) => b.amt - a.amt);
return {
ready: true, rootId, members: ids.length,
ownedPol: +ownedPol.toFixed(2), teamPol: +teamPol.toFixed(2),
leakPol: +leakPol.toFixed(2), feePol: +feePol.toFixed(2),
leaks: leaks.slice(0, 40)
};
}
// How an organization rooted at `rootId` stacks up against the whole contract — // How an organization rooted at `rootId` stacks up against the whole contract —
// share of members and share of all POL paid. Computed from in-memory state. // share of members and share of all POL paid. Computed from in-memory state.
function getOrgShare(rootId) { function getOrgShare(rootId) {
@@ -559,4 +605,4 @@ async function getIncome(id) {
}; };
} }
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgShare, getMatrixTree, isInTeam, CONTRACT }; module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getOrgRouting, getOrgShare, getMatrixTree, isInTeam, CONTRACT };
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic &amp; Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Invite views</th><th>Join clicks</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div> <div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Traffic &amp; Conversions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">First-touch source per visitor session (referring domain or utm_source). Funnel: bridge page → start page → join click.</p></div><div id="funnelStats" class="funnel"></div><div class="table-wrap"><table class="table"><thead><tr><th>Source</th><th>Bridge views</th><th>Start views</th><th>Training views</th><th>Invite views</th><th>Join clicks</th><th>Start → Join</th></tr></thead><tbody id="trafficRows"></tbody></table></div></div>
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th><th>On-chain</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div> <div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">Member ID Submissions</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">New members who confirmed their purchase on the start page. Each one was posted to your Hermes Telegram chat — add them to the rotation.</p></div><div class="table-wrap"><table class="table"><thead><tr><th>When</th><th>Name / Handle</th><th>New ID</th><th>Joined under</th><th>Source</th><th>On-chain</th></tr></thead><tbody id="submissionRows"></tbody></table></div></div>
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Your Organization vs. the Network</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">How your team — rooted at your top ID — stacks up against the entire Crypto Team Build Network smart contract. Live on-chain.</p></div><form id="orgShareForm" style="display:flex;gap:8px"><input id="orgRoot" class="input" style="max-width:110px" placeholder="21" inputmode="numeric"><button class="btn btn-secondary btn-sm">Refresh</button></form></div><div id="orgShare"><div class="empty">Reading the blockchain…</div></div></div> <div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Your Organization vs. the Network</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">How your team — rooted at your top ID — stacks up against the entire Crypto Team Build Network smart contract. Live on-chain.</p></div><form id="orgShareForm" style="display:flex;gap:8px"><input id="orgRoot" class="input" style="max-width:110px" placeholder="21" inputmode="numeric"><button class="btn btn-secondary btn-sm">Refresh</button></form></div><div id="orgShare"><div class="empty">Reading the blockchain…</div></div></div>
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">My Positions — Income</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.</p></div><form id="incomeForm" style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"><input id="incomeIds" class="input" style="max-width:260px" placeholder="21,24,25" inputmode="numeric"><button class="btn btn-teal">Load</button></form><div id="incomeAlert"></div><div id="incomeSummary" class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px"></div><div id="incomeResult"></div></div> <div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">My Positions — Income</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Every payment received by your own positions, live from the contract. Comma-separated IDs — saved for next time.</p></div><form id="incomeForm" style="display:flex;gap:8px;margin-bottom:14px;flex-wrap:wrap"><input id="incomeIds" class="input" style="max-width:260px" placeholder="21,24,25" inputmode="numeric"><button class="btn btn-teal">Load</button></form><div id="incomeAlert"></div><div id="incomeRouting" style="margin-bottom:14px"></div><div id="incomeSummary" class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:12px"></div><div id="incomeResult"></div></div>
<div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">On-Chain Member Lookup</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.</p></div><form id="lookupForm" style="display:flex;gap:10px;margin-bottom:14px"><input id="lookupId" class="input" style="max-width:220px" placeholder="Member ID e.g. 46" inputmode="numeric"><button class="btn btn-teal">Look Up</button></form><div id="lookupResult"></div></div> <div class="table-card"><div style="margin-bottom:12px"><h2 style="margin:0">On-Chain Member Lookup</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">Enter an RM Circle ID to read its registration, lineage, and every payment it has received — live from the smart contract.</p></div><form id="lookupForm" style="display:flex;gap:10px;margin-bottom:14px"><input id="lookupId" class="input" style="max-width:220px" placeholder="Member ID e.g. 46" inputmode="numeric"><button class="btn btn-teal">Look Up</button></form><div id="lookupResult"></div></div>
<div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Matrix View</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.</p></div><div style="display:flex;gap:8px"><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Matrix</button><button id="treeToggleBtn" class="btn btn-secondary btn-sm hidden">List view</button></div></div><div id="matrixNav" class="hidden" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="matrixTree"></div></div></div> <div class="table-card"><div style="display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap"><div><h2 style="margin:0">Matrix View</h2><p style="color:var(--muted);margin:4px 0 0;font-size:13px">The entire on-chain matrix — who landed where, with tier, level, directs, and earnings per position. Click a position to drill down.</p></div><div style="display:flex;gap:8px"><button id="treeLoadBtn" class="btn btn-secondary btn-sm">Load Matrix</button><button id="treeToggleBtn" class="btn btn-secondary btn-sm hidden">List view</button></div></div><div id="matrixNav" class="hidden" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px"></div><div id="matrixTree"></div></div></div>
<div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Contact email (optional)</label><input name="email" class="input" type="email" placeholder="member@example.com"></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div> <div class="stack"><div class="table-card"><h2 style="margin-top:0">Add Sponsor</h2><form id="addSponsorForm"><div class="form-grid"><div class="field"><label>ID</label><input name="id" class="input" required></div><div class="field"><label>Name</label><input name="name" class="input" required></div><div class="field"><label>Parent ID</label><input name="parentId" class="input"></div><div class="field"><label>Level</label><select name="level" class="select"><option>Scintilla</option><option>Ascensus</option><option>Fabrica</option><option>Culmen</option><option>Apex</option><option>Fastigium</option><option>Vertex</option><option>Corona</option></select></div></div><div class="field"><label>Contact email (optional)</label><input name="email" class="input" type="email" placeholder="member@example.com"></div><div class="field"><label>Notes</label><input name="notes" class="input"></div><button class="btn btn-teal" style="width:100%">Add to Queue</button></form></div>
+15
View File
@@ -88,6 +88,21 @@ async function loadIncome(){
const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—'; const date=ts=>ts?new Date(ts*1000).toISOString().replace('T',' ').slice(0,16):'—';
const needs=d.upgradeNeeds||[]; const needs=d.upgradeNeeds||[];
document.getElementById('incomeAlert').innerHTML=needs.length?needs.map(n=>`<div class="callout warning" style="margin-bottom:10px">⏫ <strong>Upgrade #${n.id} to ${esc(n.neededLevelName)}${n.reason==='qualify'?' (and get 2 directs)':''}</strong> — ${n.members.map(m=>'#'+m).join(', ')} ${n.members.length===1?'is':'are'} one upgrade from paying you ~${fmt(n.amountAtRisk)} POL, but #${n.id} (${esc(n.levelName)}) can't catch it yet. Upgrade before they do or it passes up.</div>`).join(''):''; document.getElementById('incomeAlert').innerHTML=needs.length?needs.map(n=>`<div class="callout warning" style="margin-bottom:10px">⏫ <strong>Upgrade #${n.id} to ${esc(n.neededLevelName)}${n.reason==='qualify'?' (and get 2 directs)':''}</strong> — ${n.members.map(m=>'#'+m).join(', ')} ${n.members.length===1?'is':'are'} one upgrade from paying you ~${fmt(n.amountAtRisk)} POL, but #${n.id} (${esc(n.levelName)}) can't catch it yet. Upgrade before they do or it passes up.</div>`).join(''):'';
const rt=d.routing;
document.getElementById('incomeRouting').innerHTML=(rt&&rt.ready)?(
`<div class="facts" style="grid-template-columns:repeat(4,1fr);margin-bottom:8px">`+
`<div class="fact" style="border-color:rgba(123,224,161,.4)"><small>Your positions catch</small><strong style="color:var(--ok)">${fmt(rt.ownedPol)} POL</strong></div>`+
`<div class="fact"><small>Team (in-org) catches</small><strong style="color:var(--teal)">${fmt(rt.teamPol)} POL</strong><div class="micro" style="margin-top:2px">healthy</div></div>`+
`<div class="fact" style="border-color:${rt.leakPol>0?'rgba(255,126,107,.5)':'rgba(255,255,255,.12)'}"><small>Leaking above #${rt.rootId}</small><strong style="color:${rt.leakPol>0?'#ff7e6b':'var(--muted)'}">${fmt(rt.leakPol)} POL</strong></div>`+
`<div class="fact"><small>To fees</small><strong>${fmt(rt.feePol)} POL</strong></div>`+
`</div>`+
`<p class="micro" style="margin:0 0 8px">Next-upgrade routing for ${rt.members} members below #${rt.rootId} — where each imminent upgrade payment lands. "Team" = teammates earning (good). "Leaking" = escaping to outsiders above you (often structural, not fixable by upgrading).</p>`+
(rt.leaks&&rt.leaks.length?
`<details><summary style="cursor:pointer;color:#ff7e6b;font-weight:700">⚠ ${rt.leaks.length} payment${rt.leaks.length===1?'':'s'} escaping your org — show</summary><div class="table-wrap" style="margin-top:8px"><table class="table" style="min-width:420px"><thead><tr><th>Member's next upgrade</th><th>Escapes to (outside)</th><th>Amount</th></tr></thead><tbody>`+
rt.leaks.map(l=>`<tr><td>#${l.from}</td><td><strong style="color:#ff7e6b">#${l.to}</strong> (L${l.toLevel})</td><td>${fmt(l.amt)} POL</td></tr>`).join('')+
`</tbody></table></div></details>`
:`<div class="callout" style="border-color:rgba(123,224,161,.3)">✓ Nothing is escaping your org right now — every next-upgrade payment stays with you or your team.</div>`)
):'';
sum.innerHTML=`<div class="fact" style="border-color:rgba(123,224,161,.4)"><small>Total received (all)</small><strong style="color:var(--ok);font-size:18px">${fmt(d.grandEarnedPol)} POL</strong></div>`+ sum.innerHTML=`<div class="fact" style="border-color:rgba(123,224,161,.4)"><small>Total received (all)</small><strong style="color:var(--ok);font-size:18px">${fmt(d.grandEarnedPol)} POL</strong></div>`+
d.ids.map(id=>{const p=d.perId[id]||{};return `<div class="fact"><small>#${id}${p.registered&&p.levelName?' · '+esc(p.levelName):''}</small><strong>${p.registered?fmt(p.totalEarnedPol)+' POL':'<span style=\"color:var(--danger)\">not registered</span>'}</strong>${p.registered?`<div class="micro" style="margin-top:2px">${p.count} payment${p.count===1?'':'s'}</div>`:''}</div>`}).join(''); d.ids.map(id=>{const p=d.perId[id]||{};return `<div class="fact"><small>#${id}${p.registered&&p.levelName?' · '+esc(p.levelName):''}</small><strong>${p.registered?fmt(p.totalEarnedPol)+' POL':'<span style=\"color:var(--danger)\">not registered</span>'}</strong>${p.registered?`<div class="micro" style="margin-top:2px">${p.count} payment${p.count===1?'':'s'}</div>`:''}</div>`}).join('');
out.innerHTML=d.rows.length?`<div class="table-wrap"><table class="table" style="min-width:640px"><thead><tr><th>When (UTC)</th><th>To</th><th>From</th><th>For</th><th>Amount</th></tr></thead><tbody>${d.rows.map(r=>`<tr><td>${date(r.ts)}</td><td><strong class="mt-id">#${r.toId}</strong></td><td>#${r.fromId}</td><td>${esc(r.desc||'')}</td><td><strong>${fmt(r.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div><p class="micro" style="margin:8px 0 0">${d.rows.length} payment${d.rows.length===1?'':'s'} across your positions, newest first.</p>`:'<div class="empty">No payments recorded to these positions yet.</div>'; out.innerHTML=d.rows.length?`<div class="table-wrap"><table class="table" style="min-width:640px"><thead><tr><th>When (UTC)</th><th>To</th><th>From</th><th>For</th><th>Amount</th></tr></thead><tbody>${d.rows.map(r=>`<tr><td>${date(r.ts)}</td><td><strong class="mt-id">#${r.toId}</strong></td><td>#${r.fromId}</td><td>${esc(r.desc||'')}</td><td><strong>${fmt(r.pol)} POL</strong></td></tr>`).join('')}</tbody></table></div><p class="micro" style="margin:8px 0 0">${d.rows.length} payment${d.rows.length===1?'':'s'} across your positions, newest first.</p>`:'<div class="empty">No payments recorded to these positions yet.</div>';
+2 -1
View File
@@ -457,7 +457,8 @@ async function handleApi(req,res,pathname){
} }
rows.sort((a,b)=>(b.ts||0)-(a.ts||0)); rows.sort((a,b)=>(b.ts||0)-(a.ts||0));
let upgradeNeeds=[];try{upgradeNeeds=chain.getOwnerUpgradeNeeds(ids).needs;}catch(e){} let upgradeNeeds=[];try{upgradeNeeds=chain.getOwnerUpgradeNeeds(ids).needs;}catch(e){}
return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2),upgradeNeeds}); let routing=null;try{routing=chain.getOrgRouting(Number(getConfig().orgRootId)||21,ids);}catch(e){}
return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2),upgradeNeeds,routing});
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})} }catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
} }
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){ if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){