diff --git a/ads.js b/ads.js index 27ecd18..8d67468 100644 Binary files a/ads.js and b/ads.js differ diff --git a/chatbot.js b/chatbot.js index 029f959..3c72920 100644 --- a/chatbot.js +++ b/chatbot.js @@ -28,7 +28,7 @@ const CANNED = [ { re: /(how (much|do i) earn|commission|percent|split)/i, a: 'Every package splits the same way: 50 percent to the direct sponsor, 20 percent to level 2, 10 percent to level 3, 20 percent to the platform. Those numbers are constants in the contract and cannot be changed. Play with scenarios on the home page calculator. No income is promised; nobody earns unless real ad buying happens.' }, { re: /(balance (went|going|keeps going) down|credits? (disappear|missing|gone|dropping|went down|ticking)|why (is|did) my (balance|credits)|in campaigns|set aside|reserved credits|available to spend)/i, - a: 'One rule: your balance is what is NOT committed to a live campaign. When you start a campaign its whole budget is set aside right then (earned credits first, then purchased), so your Available number drops once and then sits still while the ad runs. The budget spends down inside that campaign in Members > Campaigns, where you can see delivered views, network views and what is left. The dashboard shows Purchased available, Earned available and In live campaigns as three separate numbers; a paused campaign keeps its unspent budget set aside so it can resume. Nothing is lost when a balance reads low while a campaign is live. Purchased credits live on the blockchain; if we ever refund or credit purchased money it shows under Purchased as "credited to you" and spends like a purchase, login ads included. Earned credits fund every format except login ads. Two formats are flat buys paid in full when booked: featured links (sold by the day) and verified-visit packs. Adding credits to a featured link does nothing, so its row offers Extend run instead, which books more days at the daily rate; a visit pack offers Buy more visits.' }, + a: 'One rule: your balance is what is NOT committed to a live campaign. When you start a campaign its whole budget is set aside right then (earned credits first, then purchased), so your Available number drops once and then sits still while the ad runs. The budget spends down inside that campaign in Members > Campaigns, where you can see delivered views, network views and what is left. The dashboard shows Purchased available, Earned available and In live campaigns as three separate numbers; a paused campaign keeps its unspent budget set aside so it can resume. Nothing is lost when a balance reads low while a campaign is live. Purchased credits live on the blockchain; if we ever refund or credit purchased money it shows under Purchased as "credited to you" and spends like a purchase, login ads included. Earned credits fund every format except login ads. Two formats are flat buys paid in full when booked: featured links (sold by the day) and verified-visit packs. Adding credits to a featured link does nothing, so its row offers Extend run instead, which books more days at the daily rate; a visit pack offers Buy more visits. Every credit that comes in or goes out is listed with its reason under Members > Campaigns > Credit activity (also linked from Earn credits): sign-in bonus, daily claim, video watched, verified visit, campaign buys, views delivered per day, grants and refunds. Check it before assuming credits went missing.' }, { re: /(qualif|unlock level|level 2|level 3|pass.?up)/i, a: 'Two different things are earned here. CREDITS: every member, free ones included, earns ad credits by viewing ads, videos, visits and the daily sign-in; credits buy your own ads and are never withdrawn. POL: real crypto paid to your wallet when your referrals buy packages, once you are activated: the $20 starter package plus payouts switched on from your wallet. Level 1 pays 50 percent of the packages your direct referrals buy, from the day you are activated. Bring 2 buyers of $20 or more and level 2 unlocks; 5 unlock level 3. When a level is not qualified, its share climbs the sponsor line, checking up to 25 positions, and pays the first qualified person. Qualification never expires and cannot be bought. Qualified Start: you may link extra wallets of your own as positions under your account (Buy packages > Qualified Start); each one that buys a $20+ package counts as a qualifying buyer, its credits pool with yours, and 50% of its purchase comes back to your main wallet. Your own money, your own wallets, a faster start, never an income promise.' }, { re: /(free member|without (a )?wallet|do i (have to|need to) buy|earn (in )?credits|credits (vs|versus|or) pol|earn pol|get paid in)/i, @@ -92,6 +92,7 @@ FACTS: - SPONSOR NUDGE ON A HELD BUY (2026-09-15): when a referral's purchase is held because the sponsor has not switched on payouts, the sponsor is emailed and gets an on-site message the same minute (once an hour at most) telling them who is waiting and to link a wallet / switch on payouts on the Wallet tab; nothing is charged to the buyer, who can simply try again once the sponsor is ready. - DRILL DOWN FROM THE OVERVIEW (2026-09-15): every name chip in 'Your line at a glance' on the Overview is clickable; it jumps to My line with that person's Activity panel already open and the row highlighted. - VISIT PACKS ARE PAID UP FRONT (2026-09-15, Michael's question): a verified-visits campaign charges the whole pack when it is created (3 credits per visit, so 1,000 visits = 3,000 credits) and then delivers over time as members complete verified visits (each member can do at most 20 a day, so packs fill gradually: tens per day at today's size). The Campaigns row shows 'paid up front' and 'N of M visits delivered'; the credits are not being consumed again, they were reserved once. Same for featured-day bookings: a featured link is a flat buy (featuredPerDay credits per day, whole run charged when booked), its row shows 'paid up front', the day count and 'runs through ', and it ends on its last day ('run ended'). FEATURED CANNOT TAKE ADDED CREDITS (2026-09-16, Hugh and Michael both tried): the row offers 'Extend run' which books more days after the current run (or from today if it ended) and charges them right away; there is no 'Buy more views' on a featured row any more. Visit packs offer 'Buy more visits' (charged now, adds to the pack). Credits added to a campaign are set aside immediately (Available drops at once) and whatever is unspent returns to Available when the campaign ends. +- CREDIT ACTIVITY LEDGER (2026-09-16): Members > Campaigns has a 'Credit activity' list (last 40 lines, newest first; also linked from the Earn credits pane) showing every real credit movement with a reason: welcome credits, sign-in bonus with streak, daily claim, inbox reads, video watches, verified visits, milestone and leaderboard bonuses, partner codes, admin/team grants, AI Copy Engine charges and refunds, flat buys (featured runs, extensions, visit packs, more visits), login-ad days, and metered delivery rolled up as one line per campaign per day ('Ad views: NAME', 'Inbox deliveries: NAME', 'Video views: NAME'); lines marked '(purchased credits)' spent on-chain purchased credits rather than the earned pool. Reservations (credits set aside for a live campaign) are NOT ledger lines; they show as 'in live campaigns'. Earned credits are spent before purchased ones, so an earn followed by a buy appears as two lines. The ledger starts 2026-09-16; nothing earlier is listed. - PAYMENT + MISSED-PAYMENT NOTICES (2026-09-15): every payout that lands (who bought, level, share in POL and dollars, transaction link) and every missed payout is delivered BOTH by email and as an on-site inbox message from the company account, shown in the login pop-up and the Messages card, so it is waiting when the member signs in. MISSED-PAYOUT detail: when a level-2 or level-3 share passes a member by because that level is not open on their account, the member gets an email the same minute: who bought, the POL and dollar amount they missed, how many qualifying buyers they have versus the 2 or 5 needed, and the two ways to close the gap (bring buyers, or Qualified Start). Qualified members whose wallet rejected a transfer get a different email telling them to link a regular wallet. - LAUNCH WEEK SWIPES (2026-09-15): the founding-week checklist page (/launch) ends with four promoter emails members send to their OWN lists, one a day toward Monday's opening, with the member's invite link and the FOUNDER code (500 credits for anyone who joins before Mon 2026-09-21 9 AM Central) filled in, each with a Copy button. These are swipes for members, not emails the site sends. - LOGIN ADS CHARGE ONLY ON DAYS SHOWN (2026-09-15): the login ad daily fee (100 credits) is charged only for days the ad was actually shown at least once; with many login ads sharing the sign-ins, an ad that was not picked that day pays nothing. Featured links now count views (one per viewer per hour) as well as clicks. diff --git a/db.js b/db.js index 8322471..ba823bb 100644 --- a/db.js +++ b/db.js @@ -262,6 +262,18 @@ async function bootstrap() { burned_at BIGINT NULL, INDEX (burned_tx) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + // credit activity ledger: one row per real credit movement (metered delivery rolls up per campaign per day) + await q(`CREATE TABLE IF NOT EXISTS credit_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(190) NOT NULL, + ts BIGINT NOT NULL, + day CHAR(10) NOT NULL, + delta INT NOT NULL, + kind VARCHAR(20) NOT NULL, + note VARCHAR(160) NOT NULL, + ref VARCHAR(40) NULL, + INDEX (email, ts), INDEX (email, kind, ref, day) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // follow-up email sequence queue (one row per free account) await q(`CREATE TABLE IF NOT EXISTS drips ( email VARCHAR(190) PRIMARY KEY, diff --git a/leaderboard.js b/leaderboard.js index 25db1b5..9c436e8 100644 --- a/leaderboard.js +++ b/leaderboard.js @@ -111,7 +111,7 @@ async function rolloverTick() { const prize = prizeText(kind, sc); const rec = { kind, start: prevStart, end: cur, prize: prize || '', ladder: lad, top: top.map(pub), granted: [], at: now }; w.unshift(rec); changed = true; - for (let i = 0; i < lad.length && i < top.length; i++) { try { await R.ads.addEarned(top[i].email, lad[i]); rec.granted.push({ rank: i + 1, name: top[i].name, credits: lad[i] }); } catch (e) {} } + for (let i = 0; i < lad.length && i < top.length; i++) { try { await R.ads.addEarned(top[i].email, lad[i], { log: { kind: 'bonus', note: 'Leaderboard prize: rank ' + (i + 1) + ' (' + kind + ')' } }); rec.granted.push({ rank: i + 1, name: top[i].name, credits: lad[i] }); } catch (e) {} } if (top[0]) { const when = new Date(prevStart).toLocaleDateString('en-US', { timeZone: TZ, month: 'short', day: 'numeric' }); const line = '\u{1F3C6} InstantAdPay · ' + (kind === 'week' ? 'Weekly' : 'Monthly') + ' referral contest (from ' + when + '): ' + top[0].name + ' wins with ' + top[0].sales + ' package' + (top[0].sales === 1 ? '' : 's') + ' sold ($' + top[0].usd + ')' + (top[1] ? ' · 2nd ' + top[1].name + ' ($' + top[1].usd + ')' : '') + (top[2] ? ' · 3rd ' + top[2].name + ' ($' + top[2].usd + ')' : '') + (rec.granted.length ? '\nCredits awarded: ' + rec.granted.map(g => g.name + ' +' + g.credits.toLocaleString()).join(', ') : (prize ? '\nPrize: ' + prize : '')) + '\ninstantadpay.com/leaderboard'; diff --git a/public/assets/my.js b/public/assets/my.js index 9001e0a..fc2e614 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -779,6 +779,21 @@ } catch (e) {} } + // credit activity ledger under the campaign table (2026-09-16): every earn and spend with its reason + async function loadCreditActivity(el) { + try { + const r = await (await fetch('/api/my/credits/activity')).json(); + if (!r.items || !r.items.length) return; + const box = document.createElement('div'); box.id = 'creditActivity'; box.style.marginTop = '16px'; + const when = ts => new Date(ts).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + box.innerHTML = '

Credit activity

' + + '

Every credit that came in or went out, newest first. Earned credits are spent before purchased ones, so an earn followed by a buy shows as two lines here. Views a campaign delivers roll up into one line per day.

' + + '
' + + r.items.map(i => '').join('') + + '
' + when(i.ts) + '' + (i.delta > 0 ? '+' : '') + i.delta.toLocaleString() + '' + esc(i.note) + '
'; + el.appendChild(box); + } catch (e) {} + } async function loadCampaigns() { try { const r = await (await fetch('/api/my/campaigns')).json(); @@ -808,7 +823,7 @@ + (r.rates.soloCostPerRecipient || 5) + 'cr/delivery'; const el = $('campList'); el.innerHTML = ''; - if (!r.campaigns.length) { el.innerHTML = '

No campaigns yet. Launch your first below.

'; return; } + if (!r.campaigns.length) { el.innerHTML = '

No campaigns yet. Launch your first below.

'; loadCreditActivity(el); return; } const tbl = document.createElement('div'); tbl.className = 'tablewrap'; tbl.innerHTML = '' @@ -832,6 +847,7 @@ const note = document.createElement('p'); note.className = 'muted small'; note.textContent = 'Credits you put into a campaign are set aside the moment you start it, so your Available number drops right away and the campaign spends from its own budget. Featured links and verified-visit packs are paid in full up front; a featured run is sold by the day, so use Extend run to keep it going. Whatever a campaign has not spent comes back to Available when it ends.'; el.appendChild(note); + loadCreditActivity(el); el.querySelectorAll('button[data-extend]').forEach(b => b.addEventListener('click', async () => { const rr = r.rates || {}; const durs = rr.featuredDurations || [1, 2, 7]; const per = rr.featuredPerDay || 40; const d = await IAP.ask({ title: 'Extend featured run', text: 'How many more days? Choose ' + durs.join(', ') + '. ' + per + ' credits a day, charged now. The days are added after the current run, or start today if it has ended.', type: 'number', placeholder: 'e.g. ' + durs[durs.length - 1], ok: 'Extend' }); @@ -2100,6 +2116,7 @@ if (st.error) return null; $('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed'; $('earnBalance').textContent = (st.earnedAvailable != null ? st.earnedAvailable : st.earned) + ' earned credits available' + (st.reserved ? ' · ' + st.reserved + ' in live campaigns' : ''); + if ($('earnActLink')) $('earnActLink').onclick = e => { e.preventDefault(); jumpTo('campaigns', 'creditActivity'); }; const done = st.views >= st.target; $('earnClaimBtn').hidden = !(done && !st.claimed); if (st.claimed) { diff --git a/public/my.html b/public/my.html index e953efe..3adbecb 100644 --- a/public/my.html +++ b/public/my.html @@ -597,7 +597,8 @@ view counts. Finish today's set, claim your credits, and spend them on banner and text campaigns just like purchased ones.

… - …

+ … + Credit activity

Press the button to open the first ad in a new tab.
@@ -1021,7 +1022,7 @@ - + diff --git a/server.js b/server.js index 57110c1..4e55168 100644 --- a/server.js +++ b/server.js @@ -1048,11 +1048,11 @@ const server = http.createServer(async (req, res) => { // partner promo code carried on the join link: redeem once per account (ignored if invalid/used) try { const pc = parseCookies(req)['iap.promo']; - if (pc) { const g = await promos.redeem(pc, e, 'link'); if (g.ok) { if (g.funder && g.funder !== e) { if (await ads.spendEarned(g.funder, g.credits)) { await ads.addEarned(e, g.credits); console.log('promo redeemed (member-funded)', g.code, g.credits, e, 'by', g.funder); } else console.log('promo funder short', g.code, g.funder); } else { await ads.addEarned(e, g.credits); console.log('promo redeemed', g.code, g.credits, e); } } } + if (pc) { const g = await promos.redeem(pc, e, 'link'); if (g.ok) { if (g.funder && g.funder !== e) { if (await ads.spendEarned(g.funder, g.credits, { log: { kind: 'promo', note: 'Partner code ' + g.code + ' funded for ' + e } })) { await ads.addEarned(e, g.credits, { log: { kind: 'promo', note: 'Partner code ' + g.code } }); console.log('promo redeemed (member-funded)', g.code, g.credits, e, 'by', g.funder); } else console.log('promo funder short', g.code, g.funder); } else { await ads.addEarned(e, g.credits, { log: { kind: 'promo', note: 'Partner code ' + g.code } }); console.log('promo redeemed', g.code, g.credits, e); } } } } catch (err) { console.error('promo redeem', err.message); } // legacy bridge: a listed former Faucet Wave / Tier One Ads member gets welcome-back credits once if (r.created && /^(fw|t1)-(adv|earn)$/.test(via)) { - try { const g = legacy.grant(e, siteConfig()); if (g) { await ads.addEarned(e, g.credits); console.log('legacy grant', g.brand, g.seg, g.credits, e); } } + try { const g = legacy.grant(e, siteConfig()); if (g) { await ads.addEarned(e, g.credits, { log: { kind: 'grant', note: 'Welcome-back credits (' + g.brand + ')' } }); console.log('legacy grant', g.brand, g.seg, g.credits, e); } } catch (err) { console.error('legacy grant', err.message); } } if (r.created && b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent, new joins only @@ -1300,7 +1300,7 @@ const server = http.createServer(async (req, res) => { const b = await readBody(req); const g = await promos.redeem(b.code, s.email, 'dashboard'); if (g.error) return json(res, 400, g); - await ads.addEarned(s.email, g.credits); + await ads.addEarned(s.email, g.credits, { log: { kind: 'promo', note: 'Partner code ' + String(b.code || '').toUpperCase() } }); return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner }); } // -- admin: member card. GET ?q= resolves email / @username / #id / share code / wallet; @@ -1336,7 +1336,7 @@ const server = http.createServer(async (req, res) => { if (b.grantCredits !== undefined) { const n = Math.round(Number(b.grantCredits)); if (!(n > 0) || n > 100000) return json(res, 400, { error: 'Credits: a whole number from 1 to 100,000.' }); - await ads.addEarned(e, n); + await ads.addEarned(e, n, { log: { kind: 'grant', note: 'Credits granted by admin' } }); console.log('admin credits', e, n, String(b.note || '').slice(0, 100)); } return json(res, 200, await adminMember.view(e)); @@ -2259,7 +2259,7 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), already: true }); const tier = await ads.chargeVideoView(t.id); // charge advertiser; null if it ran dry if (!tier) return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), gone: true }); - await ads.addEarned(s.email, tier.reward); + await ads.addEarned(s.email, tier.reward, { log: { kind: 'earn', note: 'Watched a video (' + tier.secs + 's)' } }); await ads.markVideoSeen(s.email, t.id); const status = await ads.recordVideoWatch(s.email); return json(res, 200, { ok: true, credited: tier.reward, status }); @@ -2379,6 +2379,11 @@ const server = http.createServer(async (req, res) => { res.writeHead(302, baseHeaders({ Location: target })); return res.end(); } + if (p === '/api/my/credits/activity' && req.method === 'GET') { // credit ledger: every earn and spend with a reason, newest first + const s = await auth.fromRequest(req); + if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); + return json(res, 200, { items: await ads.creditActivity(s.email, 40) }); + } if (p === '/api/my/campaigns' && req.method === 'GET') { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); diff --git a/toolkit.js b/toolkit.js index e401b8a..3e8e61e 100644 --- a/toolkit.js +++ b/toolkit.js @@ -109,12 +109,12 @@ async function generate(email, kind, brief, angle) { let charged = 0; if (st.freeLeft <= 0) { if (st.available < st.cost) return { error: 'Your free generations for this month are used up and this one costs ' + st.cost + ' credits; you have ' + st.available + ' available. Earn a few on the Earn tab and come back.' }; - if (!(await R.ads.spendEarned(email, st.cost))) return { error: 'Could not charge ' + st.cost + ' credits. Try again in a moment.' }; + if (!(await R.ads.spendEarned(email, st.cost, { log: { kind: 'tool', note: 'AI Copy Engine: ' + kind } }))) return { error: 'Could not charge ' + st.cost + ' credits. Try again in a moment.' }; charged = st.cost; } let text; try { text = scrub(await complete(buildMessages(kind, brief, angle, { username: acct.username, link: st.link }))); } - catch (e) { if (charged) { try { await R.ads.addEarned(email, charged); } catch (x) {} } return { error: 'The engine did not answer. Nothing was charged. Try again.' }; } + catch (e) { if (charged) { try { await R.ads.addEarned(email, charged, { log: { kind: 'refund', note: 'Refund: AI Copy Engine did not answer' } }); } catch (x) {} } return { error: 'The engine did not answer. Nothing was charged. Try again.' }; } const u = usage(); const me = u[email] = u[email] || { months: {}, history: [] }; me.months[monthKey()] = (me.months[monthKey()] || 0) + 1; me.history = (me.history || []).concat([{ ts: Date.now(), kind, angle: angle || 'plain', brief: String(brief || '').slice(0, 120), text: text.slice(0, 1500), charged }]).slice(-40); @@ -204,8 +204,8 @@ async function grant(fromEmail, toEmail, credits) { if (!(await R.accounts.isDownlineOf(fromEmail, to, 3))) return { error: 'That member is not in your line.' }; let avail = 0; try { const st = await R.ads.viewStatus(fromEmail); avail = st.earnedAvailable != null ? st.earnedAvailable : (st.earned || 0); } catch (e) {} if (avail < n) return { error: 'You have ' + avail + ' credits available to give; free some up or earn more first.' }; - if (!(await R.ads.spendEarned(fromEmail, n))) return { error: 'Could not move the credits. Try again.' }; - await R.ads.addEarned(to, n); + if (!(await R.ads.spendEarned(fromEmail, n, { log: { kind: 'grant', note: 'Team grant to ' + to } }))) return { error: 'Could not move the credits. Try again.' }; + await R.ads.addEarned(to, n, { log: { kind: 'grant', note: 'Team grant from your sponsor line' } }); const a = await R.accounts.byEmail(fromEmail), b = await R.accounts.byEmail(to); const fromName = a.username ? '@' + a.username : 'your sponsor', toName = b.username ? '@' + b.username : to.replace(/@.*/, '') + '@'; try { await R.messages.sendChat(a.memberId || 0, fromEmail, to, 'I just moved ' + n + ' ad credits to your account. Spend them on a campaign pointed at your invite link: Campaigns > New campaign.'); } catch (e) {}
NameTypeViews hereNetwork viewsClicks