Files

73 lines
6.4 KiB
JavaScript

// 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');
const fs = require('fs'); const path = require('path');
let R = null; // { notify(text), dataDir }
function init(refs) { R = refs; }
// credits already returned for past counting errors: subtract them so history that was made right does not keep flagging
function refunded() { const out = {}; try { for (const f of fs.readdirSync(R.dataDir)) if (/^refunds-.*\.json$/.test(f)) for (const i of (JSON.parse(fs.readFileSync(path.join(R.dataDir, f), 'utf8')).items || [])) out[i.id] = (out[i.id] || 0) + Number(i.credits || 0); } catch (e) {} return out; }
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 back = refunded(); for (const c of camps) if (back[c.id]) c.spent = Math.max(0, Number(c.spent) - back[c.id]); // already made right
const logStart = (await db.q('SELECT MIN(day) d FROM camp_hours'))[0].d; const logStartMs = logStart ? Date.parse(logStart + 'T00:00:00Z') : 0;
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 = [];
// only campaigns that started after the hourly log did
for (const c of camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs)) { const l = h[c.id] || 0; if (Math.abs(Number(c.imps) - l) > Math.max(5, Math.round(0.02 * Math.max(Number(c.imps), l)))) 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, camps.filter(c => ['banner', 'text'].includes(c.type) && Number(c.created) >= logStartMs).length + ' campaigns compared (started since the hourly log began ' + (logStart || '') + ')');
}
// 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;
}
// the "once a day" memory lives on the volume: a redeploy restarts the process, and Marty got an alert after every deploy (2026-09-15)
const STATE = () => path.join(R.dataDir, 'audit-state.json');
function lastAlertDay() { try { return JSON.parse(fs.readFileSync(STATE(), 'utf8')).lastAlertDay || ''; } catch (e) { return ''; } }
function setAlertDay(d) { try { fs.writeFileSync(STATE(), JSON.stringify({ lastAlertDay: d })); } catch (e) {} }
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) { setAlertDay(day); R.notify('⚠️ LinkSpin 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 };