Alert when an owned position needs upgrading to catch incoming pay

New owner upgrade watcher: every 5 min it checks config.ownerIds against
the chain state and, when a leg member is ONE upgrade from paying a
position that isn't eligible yet (below the required level, or not
qualified), emails config.ownerAlertEmail + pings Telegram — "upgrade
#24 to Fabrica, #61 is one upgrade from paying you 2,486 POL." Deduped
per (position, level) in owner-alerts.json, re-fires if the situation
recurs. Same warning renders inline in the admin "My Positions" panel
(upgradeNeeds in the income endpoint). New ownerAlertEmail settings
field. Detection logic unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-14 14:27:50 -05:00
parent 81d90fbe8e
commit 8b3e82d960
4 changed files with 80 additions and 6 deletions
+38 -1
View File
@@ -477,6 +477,43 @@ async function memberPublic(id) {
return out;
}
// For owned positions: detect when a leg member is ONE upgrade away from paying
// the position, but the position isn't eligible yet (not qualified, or below the
// required level) — i.e. "upgrade now or the payment passes you". Computed from
// in-memory state, no RPC. A member M at depth D pays this position on M's
// upgrade OUT of level D, so the trigger is M.level === D with the owner ineligible.
function getOwnerUpgradeNeeds(ids) {
if (!state || !state.snapshotAt || !costs) return { ready: false, needs: [] };
const needs = [];
for (const id of ids) {
const p = state.members[id];
const root = getSubtree(id, 99);
if (!p || !root) continue;
const pLevel = p.level || 1, pQual = (p.directCount || 0) >= 2;
const items = [];
(function walk(n, depth) {
if (!n) return;
if (depth >= 1 && (n.level || 1) === depth) {
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);
})(root, 0);
if (items.length) {
const minDepth = Math.min(...items.map(i => i.depth));
const atMin = items.filter(i => i.depth === minDepth);
needs.push({
id, level: pLevel, levelName: levelName(pLevel), qualified: pQual,
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'
});
}
}
return { ready: true, needs };
}
// focused income read for one position — for the admin "my positions" income view
async function getIncome(id) {
const m = await fetchMember(id);
@@ -491,4 +528,4 @@ async function getIncome(id) {
};
}
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getMatrixTree, isInTeam, CONTRACT };
module.exports = { startIndexer, getPayoutsPublic, verifyMember, memberLookup, memberPublic, getIncome, getOwnerUpgradeNeeds, getMatrixTree, isInTeam, CONTRACT };