diff --git a/audit.js b/audit.js new file mode 100644 index 0000000..3e42fff --- /dev/null +++ b/audit.js @@ -0,0 +1,63 @@ +// Counter audit (Marty, 2026-09-15: "buyers will lose confidence if they feel cheated"). Every ad format's +// recorded impressions are reconciled against the log that proves delivery, and login/featured charging is +// checked against actual shows. Runs on demand from Admin > Reports and once a day; any issue alerts the admin. +// DB mode only (production); JSON mode reports "not checked". +const db = require('./db'); +let R = null; // { notify(text) } +function init(refs) { R = refs; } + +async function run() { + const out = { checkedAt: Date.now(), checks: [] }; + if (!db.enabled()) { out.checks.push({ name: 'Counters', ok: true, detail: 'JSON mode: not checked', issues: [] }); return out; } + const camps = await db.q("SELECT id, name, type, status, imps, clicks, spent, accrued, budget, created, house FROM campaigns WHERE house=0 OR house IS NULL"); + const byType = t => camps.filter(c => c.type === t); + const add = (name, issues, detail) => out.checks.push({ name, ok: !issues.length, detail, issues }); + // 1. per-view formats: campaign.imps must equal the rows in the log that paid for them + for (const [type, table, label] of [['video', 'video_seen', 'completed watches'], ['visits', 'visit_seen', 'verified visits'], ['solo', 'solo_inbox', 'inbox deliveries']]) { + const rows = await db.q('SELECT campaign_id, COUNT(*) n FROM ' + table + ' GROUP BY campaign_id'); const logN = {}; for (const r of rows) logN[r.campaign_id] = Number(r.n); + const issues = []; let tot = 0, logTot = 0; + for (const c of byType(type)) { const l = logN[c.id] || 0; tot += Number(c.imps); logTot += l; if (Number(c.imps) !== l) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' ' + label); } + add(type + ' views vs ' + label, issues, tot + ' views recorded, ' + logTot + ' ' + label); + } + // 2. banner/text: imps must equal the hourly log (both bumped on serve) + { + const rows = await db.q('SELECT campaign_id, SUM(n) n FROM camp_hours GROUP BY campaign_id'); const h = {}; for (const r of rows) h[r.campaign_id] = Number(r.n); + const issues = []; + for (const c of camps.filter(c => ['banner', 'text'].includes(c.type))) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > 2) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': ' + c.imps + ' views vs ' + l + ' in the hourly log'); } + add('banner/text views vs hourly log', issues, byType('banner').length + byType('text').length + ' campaigns compared'); + } + // 3. login: days charged vs days shown (a day may only be charged after the ad was shown) + { + const rows = await db.q('SELECT campaign_id, COUNT(DISTINCT day) d FROM camp_hours WHERE n>0 GROUP BY campaign_id'); const shown = {}; for (const r of rows) shown[r.campaign_id] = Number(r.d); + const issues = []; + for (const c of byType('login')) { const charged = Math.round((Number(c.spent) + Number(c.accrued || 0)) / 100), s = shown[c.id] || 0; if (charged > s) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': charged ' + charged + ' day(s), shown on ' + s); } + add('login ads: days charged vs days shown', issues, byType('login').length + ' campaigns compared'); + } + // 4. featured: a live booking older than two hours must have views + { + const issues = []; const now = Date.now(); + for (const c of byType('featured').filter(c => c.status === 'active' && now - Number(c.created) > 2 * 3600e3)) if (!Number(c.imps)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ': live with 0 views'); + add('featured: live bookings have views', issues, byType('featured').filter(c => c.status === 'active').length + ' live bookings'); + } + // 5. clicks can only exceed views where a click precedes the counted view (visits, login) + { + const issues = []; + for (const c of camps.filter(c => !['visits', 'login'].includes(c.type) && Number(c.clicks) > Number(c.imps) + 5)) issues.push('#' + c.id + ' ' + c.name.slice(0, 30) + ' (' + c.type + '): ' + c.clicks + ' clicks vs ' + c.imps + ' views'); + add('clicks vs views', issues, 'campaigns with more clicks than views, where that cannot happen'); + } + // 6. budgets: spent can never pass budget + { + const issues = camps.filter(c => Number(c.spent) + Number(c.accrued || 0) > Number(c.budget) + 1).map(c => '#' + c.id + ' ' + c.name.slice(0, 30) + ': spent ' + (Number(c.spent) + Number(c.accrued || 0)) + ' of ' + c.budget); + add('spend never exceeds budget', issues, camps.length + ' campaigns'); + } + return out; +} +let lastAlertDay = ''; +async function dailyTick() { + try { + const r = await run(); const bad = r.checks.filter(c => !c.ok); const day = new Date().toISOString().slice(0, 10); + if (bad.length && lastAlertDay !== day && R && R.notify) { lastAlertDay = day; R.notify('⚠️ InstantAdPay counter audit found ' + bad.length + ' issue' + (bad.length === 1 ? '' : 's') + ': ' + bad.map(c => c.name + ' (' + c.issues.length + ')').join('; ') + '. Admin > Reports > Counters audit.'); } + return r; + } catch (e) { console.error('audit', e.message); return null; } +} +module.exports = { init, run, dailyTick }; diff --git a/public/admin.html b/public/admin.html index 27aad39..bed9c54 100644 --- a/public/admin.html +++ b/public/admin.html @@ -270,6 +270,12 @@
Every format's recorded views are reconciled against the log that proves delivery, login days charged against days shown, and featured bookings checked for views. Runs daily and alerts you on Telegram; run it any time here.
+ +