Catch eligibility: catcher needs level >= the level the buyer is LEAVING, not the level being bought

Matches _payUpline (level > levelIndex, levelIndex = buyer.level-1). The old
depth+1 rule told #21 (Culmen) it could not catch #49's Apex buy; #21 is #49's
4th matrix upline and does catch it. Fixed in the owner alert, coaching scan,
dashboard pipeline, AI prompt, leader digest, chatbot and how-pay-works copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-01 10:51:30 -05:00
parent dcc8202efb
commit 747977b368
7 changed files with 23 additions and 18 deletions
+14 -10
View File
@@ -602,10 +602,12 @@ function getOwnerUpgradeNeeds(ids) {
(function walk(n, depth) {
if (!n) return;
if (depth >= 1 && (n.level || 1) === depth) {
// catcher must be AT the level being bought (depth+1), not the buyer's
// pre-upgrade level — verified on-chain: #6 was passed at exactly the
// pre-upgrade level on #8's Fabrica buy (2026-08-14)
const eligible = pQual && pLevel >= depth + 1;
// Contract: _payUpline(levelIndex = buyer.level-1) pays the first
// non-skipped upline with level > levelIndex, i.e. level >= the level
// the buyer is LEAVING (= depth). The 2026-08-14 "#6 passed on #8's
// Fabrica buy" was a structural skip (#6 is #8's gen-1), not a level
// test — the old `depth + 1` rule demanded one rung too many.
const eligible = pQual && pLevel >= depth;
if (!eligible) items.push({ memberId: n.id, depth, amount: (costs.up[n.tier === 2 ? 2 : 1] || [])[depth - 1] || 0 });
}
walk(n.left, depth + 1); walk(n.right, depth + 1);
@@ -615,7 +617,7 @@ function getOwnerUpgradeNeeds(ids) {
const atMin = items.filter(i => i.depth === minDepth);
needs.push({
id, level: pLevel, levelName: levelName(pLevel), qualified: pQual,
neededLevel: minDepth + 1, neededLevelName: levelName(minDepth + 1),
neededLevel: minDepth, neededLevelName: levelName(minDepth),
members: atMin.map(i => i.memberId),
amountAtRisk: +atMin.reduce((s, i) => s + i.amount, 0).toFixed(2),
reason: pQual ? 'upgrade' : 'qualify'
@@ -738,8 +740,10 @@ function nextOpenPosition(rootId, exclude) {
}
// Coaching radar: triage every member below `rootId` into actionable tiers.
// - atRisk: money forming in their leg that they can't catch yet (corrected
// rule: catcher must be qualified AND at the level being bought)
// - atRisk: money forming in their leg that they can't catch yet (contract
// rule: catcher must be qualified AND at or above the level the buyer is
// leaving — a member at depth D pays on their upgrade OUT of level D, so the
// catcher needs level >= D)
// - rollForward: qualified but still Scintilla — entry rewards already cover
// the Ascensus upgrade that catches their directs' first payments
// - oneAway: one direct short of qualifying (a placement fixes them)
@@ -754,15 +758,15 @@ function getCoachingScan(rootId, maxItems = 15) {
const m = state.members[id]; const lvl = m.level || 1, q = (m.directCount || 0) >= 2;
let missing = 0, minDepth = 0, fromIds = [];
(function walk(nid, depth) { const mm = state.members[nid]; if (!mm) return;
if (depth >= 1 && (mm.level || 1) === depth && !(q && lvl >= depth + 1)) {
if (depth >= 1 && (mm.level || 1) === depth && !(q && lvl >= depth)) {
const amt = (costs.up[mm.tier === 2 ? 2 : 1] || [])[depth - 1] || 0;
if (amt) { missing += amt; fromIds.push(nid); if (!minDepth || depth < minDepth) minDepth = depth; }
}
if (depth < 16) { if (mm.l) walk(mm.l, depth + 1); if (mm.r) walk(mm.r, depth + 1); } })(id, 0);
if (missing > 0) atRisk.push({ id, level: lvl, levelName: levelName(lvl), qualified: q,
directCount: m.directCount || 0, atRiskPol: +missing.toFixed(2), fromIds: fromIds.slice(0, 6),
need: q ? 'upgrade' : 'qualify', neededLevel: q ? minDepth + 1 : null,
neededLevelName: q ? levelName(minDepth + 1) : null,
need: q ? 'upgrade' : 'qualify', neededLevel: q ? minDepth : null,
neededLevelName: q ? levelName(minDepth) : null,
earnedPol: +(m.earnedPol || 0).toFixed(2),
upgradeCost: q ? +(((costs.up[m.tier === 2 ? 2 : 1] || [])[lvl - 1]) || 0).toFixed(2) : null });
if (q && lvl === 1) rollForward.push({ id, earnedPol: +(m.earnedPol || 0).toFixed(2),