84a9063bbe
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
64 lines
5.0 KiB
JavaScript
64 lines
5.0 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');
|
|
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 };
|