Linked positions (Qualified Start): extra wallets on one account, credits pooled, buy-from picker

- accounts/db: positions table + JSON store (add/list/owner/remove; main-wallet and cross-account guards)
- auth verify: asPosition links a second wallet without touching the session; position wallets can't mint a session
- /api/my/positions (chain-refreshed member ids, buyer counts, credits) + remove
- credits pooled across main + positions on /api/me, dashboard, campaigns; campaign charged to the best-funded position
- My line: own positions listed on level 1 as 'You · position N'
- Buy pane: Qualified Start card + Add a position flow + Buy-from picker with connected-wallet guard
- Wallet pane: Your positions card; chatbot answer updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 17:18:47 -05:00
parent b86dbfd9e9
commit 046464b83e
9 changed files with 327 additions and 14 deletions
+74 -4
View File
@@ -87,6 +87,14 @@ async function uplineSlides(email, depth = 3) {
// thresholds that open payout levels 2 and 3). Until then, or while an unlocked
// slot is empty, the slot shows an upline's banner, then a house ad.
const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1);
// every on-chain member id this session controls: the main wallet plus linked
// positions (Qualified Start). Credits pool across them on the dashboard.
async function myMemberIds(s) {
const main = await auth.refreshMemberId(s);
const ids = main ? [main] : [];
if (s && s.email) for (const p of await accounts.positions(s.email)) if (p.memberId && !ids.includes(p.memberId)) ids.push(p.memberId);
return { main, ids };
}
function parseWallOffers(a) {
try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; }
}
@@ -653,6 +661,15 @@ const server = http.createServer(async (req, res) => {
let memberId = 0;
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
const s = await auth.fromRequest(req);
if (s && s.email && b.asPosition) {
// Qualified Start: a second (third…) wallet on the same account. It becomes
// its own on-chain member under this member's id when it buys; the session
// stays on the main wallet.
const pr = await accounts.addPosition(s.email, r.address);
if (pr.error) return json(res, 400, pr);
if (memberId) await accounts.setPositionMember(r.address, memberId);
return json(res, 200, { ok: true, position: true, address: r.address, memberId });
}
if (s && s.email) {
const lr = await accounts.linkWallet(s.email, r.address);
if (lr.error) return json(res, 400, lr);
@@ -660,6 +677,8 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
}
const acct = await accounts.byAddress(r.address);
if (!acct && await accounts.positionOwner(r.address))
return json(res, 400, { error: 'That wallet is a linked position on an account. Sign in with that account\'s email instead.' });
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
return json(res, 200, { ok: true, address: r.address, memberId },
{ 'Set-Cookie': auth.sessionCookie(token) });
@@ -696,7 +715,7 @@ const server = http.createServer(async (req, res) => {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.onchainSponsorId = mm.sponsorId;
out.credits = await chain.creditBalance(memberId, 0);
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
} catch (e) { out.chainReadError = true; }
}
return json(res, 200, out);
@@ -745,7 +764,7 @@ const server = http.createServer(async (req, res) => {
try {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.credits = await ads.availableCredits(memberId);
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
} catch (e) { out.chainReadError = true; }
let earned = 0n, n = 0;
for (const ev of chain.recentEvents(600)) {
@@ -784,6 +803,38 @@ const server = http.createServer(async (req, res) => {
out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own
return json(res, 200, out);
}
// -- linked positions (Qualified Start): list, refresh from chain, unlink
if (p === '/api/my/positions' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const acct = await accounts.byEmail(s.email);
const mainId = await auth.refreshMemberId(s);
const list = await accounts.positions(s.email);
const out = [];
for (const pos of list) {
let id = pos.memberId;
if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} }
const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created };
if (id) {
try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {}
try { row.credits = await ads.availableCredits(id); } catch (e) {}
}
out.push(row);
}
const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 };
if (mainId) {
try { main.credits = await ads.availableCredits(mainId); } catch (e) {}
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
}
return json(res, 200, { main, positions: out, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) });
}
if (p === '/api/my/positions/remove' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const r = await accounts.removePosition(s.email, b.address);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/profile' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
@@ -898,6 +949,14 @@ const server = http.createServer(async (req, res) => {
email: L.level === 1 ? m.email : null, // directs only
joined: m.created,
earnedWei: (m.memberId && earnedBy[m.memberId]) || '0' })) }));
// the member's own linked positions sit on level 1 too, labelled as theirs
const own = (await accounts.positions(s.email)).filter(p => p.memberId);
if (own.length) {
if (!out.find(L => L.level === 1)) out.unshift({ level: 1, members: [] });
const L1 = out.find(L => L.level === 1);
own.forEach((p, i) => L1.members.push({ memberId: p.memberId, name: 'You · position ' + (i + 2), own: true,
email: null, joined: p.created, earnedWei: earnedBy[p.memberId] || '0' }));
}
return json(res, 200, { levels: out, counts: out.map(L => L.members.length) });
}
// -- broadcast a message to your downline (1/day), on-site inbox + email
@@ -1386,7 +1445,10 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0;
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
out.purchasedCredits = pool.total;
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
out.positionCount = pool.per.length;
out.earnedCredits = await ads.earnedBalance(s.email);
out.availableCredits = out.purchasedCredits + out.earnedCredits;
return json(res, 200, out);
@@ -1400,7 +1462,15 @@ const server = http.createServer(async (req, res) => {
const fc = await frameCheck(b.targetUrl);
if (!fc.ok) return json(res, 400, { error: fc.reason });
}
const r = await ads.createCampaign(s.email, memberId, b);
// charge the best-funded of the member's positions (main + Qualified Start
// wallets). A campaign burns from one member id, so the budget must fit inside it.
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
const fundId = pool.best.memberId || memberId;
const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0;
const budget = Math.floor(Number(b.budget) || 0);
if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow)
return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' });
const r = await ads.createCampaign(s.email, fundId, b);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);