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 -13
View File
@@ -192,16 +192,19 @@
try {
const st = await (await fetch('/api/my/earn')).json();
if (!st.error) {
const purchased = d.credits || 0, earned = st.earned || 0, wc = Math.min(d.welcomeCredits || 0, earned);
// one rule: a balance is what is NOT committed to a live campaign. Budgets are
// set aside when a campaign starts and spend down inside Campaigns, so these
// numbers only move when a campaign is created, topped up or paused
const purchased = d.credits || 0, earned = d.earnedCredits != null ? d.earnedCredits : (st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0)), inC = d.inCampaigns || 0;
$('dbCredits').textContent = (purchased + earned).toLocaleString();
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased · ' + earned.toLocaleString() + ' earned';
$('dbCreditsSub').textContent = purchased.toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earned.toLocaleString() + ' earned' + (inC ? ' · ' + inC.toLocaleString() + ' in campaigns' : '');
donut($('chDonut'),
[{ v: purchased, color: CH.mint }, { v: wc, color: CH.amber }, { v: earned - wc, color: CH.cyan }],
{ big: (purchased + earned).toLocaleString(), small: 'spendable' });
[{ v: purchased, color: CH.mint }, { v: earned, color: CH.cyan }, { v: inC, color: CH.amber }],
{ big: (purchased + earned).toLocaleString(), small: 'available' });
legend($('chDonutLegend'), [
{ color: CH.mint, label: 'Purchased', v: purchased.toLocaleString() },
{ color: CH.amber, label: 'Welcome', v: wc.toLocaleString() },
{ color: CH.cyan, label: 'Earned and credited', v: Math.max(0, earned - wc).toLocaleString() }]);
{ color: CH.mint, label: 'Purchased, available', v: purchased.toLocaleString() },
{ color: CH.cyan, label: 'Earned, available', v: earned.toLocaleString() },
{ color: CH.amber, label: 'In live campaigns', v: inC.toLocaleString() }]);
const done = Math.min(st.views || 0, st.target || 5), left = Math.max(0, (st.target || 5) - done);
donut($('chRing'), [{ v: done, color: st.claimed ? CH.mint : CH.cyan }, { v: left, color: 'none' }],
{ big: done + '/' + (st.target || 5), small: st.claimed ? 'claimed' : 'ads viewed' });
@@ -357,9 +360,9 @@
}
}).catch(() => {});
}
const wc = d.welcomeCredits || 0;
$('dbCredits').textContent = ((d.credits || 0) + wc).toLocaleString();
$('dbCreditsSub').textContent = wc ? (d.credits || 0).toLocaleString() + ' purchased · ' + wc + ' welcome' : '';
const earnedAv = d.earnedCredits || 0, inCamp = d.inCampaigns || 0;
$('dbCredits').textContent = ((d.credits || 0) + earnedAv).toLocaleString();
$('dbCreditsSub').textContent = (d.credits || 0).toLocaleString() + ' purchased' + (d.creditedCredits ? ' (' + d.creditedCredits.toLocaleString() + ' credited to you)' : '') + ' · ' + earnedAv.toLocaleString() + ' earned' + (inCamp ? ' · ' + inCamp.toLocaleString() + ' in campaigns' : '');
$('dbEarned').textContent = IAP.fmtPol(d.earnedWei || '0');
$('dbBuyers').textContent = d.buyerCount || 0;
$('dbTeam').textContent = (d.referrals || []).length;
@@ -591,11 +594,15 @@
if ($('spendBanner')) {
$('spendBanner').hidden = false;
$('spendBig').textContent = r.availableCredits.toLocaleString();
$('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery' + (r.earnedCredits ? ' · ' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits.toLocaleString() + ' earned by viewing' : '') + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : '');
$('spendSub').textContent = '= $' + (r.availableCredits / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' of ad delivery · ' + r.purchasedCredits.toLocaleString() + ' purchased' + (r.creditedCredits ? ' (' + r.creditedCredits.toLocaleString() + ' of it credited to you, spends on anything)' : '') + ' + ' + (r.earnedCredits || 0).toLocaleString() + ' earned' + (r.positionCount > 1 ? ' · pooled across ' + r.positionCount + ' positions (largest single position ' + (r.largestPosition || 0).toLocaleString() + ')' : '');
if ($('spendNote')) $('spendNote').textContent = r.inCampaigns
? 'Not counting ' + r.inCampaigns.toLocaleString() + ' credits already set aside for your live campaigns. That budget spends down inside each campaign below. This number only moves when you start, top up or pause a campaign.'
: 'This is what is not committed to a campaign. When you start one, its budget moves out of here and spends down inside the campaign.';
$('spendRates').innerHTML = [['Banner', r.rates.bannerCreditsPerBatch + ' cr / ' + r.rates.bannerBatch + ' views'], ['Text', r.rates.textCreditsPerBatch + ' cr / ' + r.rates.textBatch + ' views'], ['Login', r.rates.loginCreditsPerDay + ' cr / day'], ['Solo', r.rates.soloCostPerRecipient + ' cr / delivery'], ['Featured', (r.rates.featuredPerDay || 40) + ' cr / day'], ['Visit', (r.rates.visitCostPerVisit || 3) + ' cr / visit']].map(x => '<span><b>' + x[0] + '</b> ' + x[1] + '</span>').join('');
}
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
+ (r.inCampaigns ? ' · ' + r.inCampaigns.toLocaleString() + ' set aside in live campaigns' : '')
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
@@ -1361,7 +1368,7 @@
+ '<span class="id">' + (r.main.memberId ? '#' + r.main.memberId : 'payouts not on yet') + '</span>'
+ '<span class="earn on">' + (r.main.buyerCount || 0) + ' qualifying buyer(s)</span>'
+ '<span class="dt">' + (r.main.credits || 0).toLocaleString() + ' credits' + (r.main.balanceWei != null ? ' · ' + (Number(BigInt(r.main.balanceWei) / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }) + ' POL' : '') + '</span></div>' : '';
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>. A campaign budget spends from one position at a time.</p>' : '');
const html = mainRow + rows + (list.length ? '<p class="muted small" style="margin:8px 0 0">Pooled credits: <b>' + (r.totalCredits || 0).toLocaleString() + '</b>' + (r.credited ? ' plus <b>' + r.credited.toLocaleString() + '</b> credited to your account (spends from any position)' : '') + '. A campaign budget spends from one position at a time.</p>' : '');
if ($('qsList')) $('qsList').innerHTML = html;
// Wallet tab: live POL balance of the linked wallet, and which package it covers
const wb = $('walletBal');
@@ -1500,7 +1507,7 @@
const st = await (await fetch('/api/my/earn')).json();
if (st.error) return null;
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
$('earnBalance').textContent = st.earned + ' earned credits';
$('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : '');
const done = st.views >= st.target;
$('earnClaimBtn').hidden = !(done && !st.claimed);
if (st.claimed) {