Balances: one reservation rule (earned first) + purchased-grade credited pool

- balances(): live campaign budgets are set aside up front, earned pool first then
  purchased, so dashboard numbers never move per impression; new 'in campaigns' line
- earned pool gains a purchase_grade portion for refunds/comps: shows under Purchased
  as 'credited to you' and funds login ads (daily login charge draws on it first)
- viewing credits always spend before credited purchased money
- dashboard/donut/spend banner/earn pane/positions show purchased, credited, earned, in campaigns
- chatbot canned answer + AI prompt: the balance rule

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-10 11:16:40 -05:00
parent af6d2db711
commit 4487a90d16
6 changed files with 45 additions and 25 deletions
+20 -12
View File
@@ -782,7 +782,8 @@ const server = http.createServer(async (req, res) => {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.onchainSponsorId = mm.sponsorId;
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
const bal = await ads.balances((await myMemberIds(s)).ids, s.email);
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
} catch (e) { out.chainReadError = true; }
}
return json(res, 200, out);
@@ -831,7 +832,8 @@ const server = http.createServer(async (req, res) => {
try {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.credits = (await ads.pooledCredits((await myMemberIds(s)).ids)).total;
const bal = await ads.balances((await myMemberIds(s)).ids, s.email);
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
} catch (e) { out.chainReadError = true; }
let earned = 0n, n = 0;
for (const ev of chain.recentEvents(600)) {
@@ -878,19 +880,21 @@ const server = http.createServer(async (req, res) => {
const mainId = await auth.refreshMemberId(s);
const list = await accounts.positions(s.email);
const out = [];
const posIds = [mainId, ...list.map(p => p.memberId)].filter(Boolean);
let balPer = {}, credited = 0; try { const bb = await ads.balances(posIds, s.email); credited = bb.credited; for (const p of bb.per) balPer[p.memberId] = p.avail; } catch (e) {}
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) {}
row.credits = balPer[id] != null ? balPer[id] : 0;
}
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) {}
main.credits = balPer[mainId] != null ? balPer[mainId] : 0;
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
}
// live POL balances (the link signature proved the wallet is theirs) + a POL/USD rate from the catalog
@@ -898,7 +902,7 @@ const server = http.createServer(async (req, res) => {
if (main.address) main.balanceWei = await bal(main.address);
for (const row of out) row.balanceWei = await bal(row.address);
let polUsd = 0; try { const cat = await chain.catalog(); const pk = cat.find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0) });
return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0), credited });
}
if (p === '/api/my/positions/remove' && req.method === 'POST') {
const s = await auth.fromRequest(req);
@@ -1554,12 +1558,15 @@ const server = http.createServer(async (req, res) => {
const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
const pool = await ads.pooledCredits((await myMemberIds(s)).ids);
const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
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;
out.creditedCredits = pool.credited; // refunded/credited purchased money, free (counts inside purchasedCredits)
out.earnedCredits = pool.earned; // earned pool minus what live campaigns already hold
out.earnedReserved = pool.earnedReserved;
out.inCampaigns = pool.inCampaigns; // budget still to deliver across live campaigns
out.availableCredits = pool.available;
return json(res, 200, out);
}
if (p === '/api/my/campaigns' && req.method === 'POST') {
@@ -1573,13 +1580,14 @@ const server = http.createServer(async (req, res) => {
}
// 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 ids = (await myMemberIds(s)).ids;
const pool = await ads.balances(ids, s.email);
const fundId = pool.best.memberId || memberId;
const earnedNow = String(b.type) !== 'login' ? await ads.earnedBalance(s.email) : 0;
const earnedNow = String(b.type) !== 'login' ? pool.earned : 0;
const budget = Math.floor(Number(b.budget) || 0);
if (pool.per.length > 1 && budget > pool.best.avail + earnedNow && budget <= pool.total + earnedNow)
if (pool.per.length > 1 && budget > pool.best.avail + pool.credited + 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);
const r = await ads.createCampaign(s.email, fundId, b, ids);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);