Stop billing members for syndicated impressions that were never delivered

Network Ad Space has no stop flag. Its serving query only picks rows with remaining>0,
so zeroing that counter is the only way to halt an ad. But delivery is derived as
assigned - remaining, so the moment an ad was stopped it read back as 100% DELIVERED.

That was not only a reporting error. reconcileNas() runs every five minutes, had no
status filter, and CHARGES member credits off that figure. So pausing a campaign, or
ending one, made the next reconcile pass bill the member for the entire unspent budget
as though it had all been served.

Measured on production before the fix: 23 member campaigns across 16 members, every
single one charged to exactly 100% of budget, 11,437 credits in total, against on-site
delivery evidence of roughly 4,700 impressions. A naturally exhausted ad also ends at
remaining=0, so the two cases cannot be told apart after the fact, which is why the
true figure has to be captured before the stop.

Three changes:
- nas.deactivate() now reads the real served count BEFORE zeroing and returns it.
- a new ads.stopNas() helper is the only path to a stop, and it persists that figure as
  the campaign's final delivery. No caller touches nas.deactivate() directly any more.
- reconcileNas() only processes campaigns with status='active'. A stopped ad delivers
  nothing further, so there is never anything legitimate left to charge for.

qa/nas-served.mjs (8 assertions) stubs the NAS layer and drives the real code: pausing
records the true 3,000 rather than the 10,000 allocation, a paused campaign is never
charged afterwards and its figure never jumps to the allocation, and an active campaign
still reconciles and is charged normally so the guard did not break delivery.

fraud-allow 12, sponsor-note 5, chatbot-parse 35, qa/run.sh member 0 bugs.

Historical delivery is not recoverable: the stop overwrote the only record of it.
Refunding the 11,437 credits to the 16 affected members is Marty's call, pending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-17 19:52:12 -05:00
parent e95f9df13a
commit cb6da01e1c
3 changed files with 115 additions and 2 deletions
BIN
View File
Binary file not shown.
+13 -2
View File
@@ -100,11 +100,22 @@ async function readServed(nasAdId) {
return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 }; return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 };
} }
// stop a syndicated ad (budget spent, paused, or expired) — remaining:0 halts serving. // Stop a syndicated ad (budget spent, paused, or expired). NAS has no stop flag: its
// serving query only picks rows with remaining>0, so zeroing that counter is the only
// way to halt an ad.
//
// THE TRAP (found 2026-09-17): served is derived as assigned-remaining, so zeroing also
// makes a stopped ad read back as 100% DELIVERED. That inflated every stopped campaign's
// reported delivery, and worse, reconcileNas charges members for the difference — so
// pausing a campaign could bill for impressions that were never served. A naturally
// exhausted ad also ends at remaining=0, so the two cases are indistinguishable
// afterwards. The true figure therefore has to be captured BEFORE the stop.
async function deactivate(nasAdId) { async function deactivate(nasAdId) {
if (!enabled()) return { skipped: 'flag-off' }; if (!enabled()) return { skipped: 'flag-off' };
let served = null;
try { const s = await readServed(nasAdId); if (s) served = s.served; } catch (e) {}
await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]); await q('UPDATE sponsorads SET remaining=0, EDate=NOW() WHERE ID=?', [Number(nasAdId)]);
return { ok: true }; return { ok: true, served }; // callers MUST persist this as the final served count
} }
// top up a syndicated ad with more impressions (buy-more-views / reactivate). // top up a syndicated ad with more impressions (buy-more-views / reactivate).
+102
View File
@@ -0,0 +1,102 @@
// The Network Ad Space served-count trap (found 2026-09-17).
//
// NAS has no stop flag: its serving query only picks rows with remaining>0, so zeroing
// that counter is the only way to halt an ad. But served is derived as
// assigned - remaining, so a STOPPED ad reads back as 100% delivered. Two harms:
// 1. reported delivery was inflated for every stopped campaign;
// 2. reconcileNas CHARGES MEMBER CREDITS off that figure, so pausing a campaign could
// bill someone for impressions that were never served.
//
// Runs entirely against the JSON store with the NAS layer stubbed. Touches no real data.
// node qa/nas-served.mjs
import { createRequire } from 'node:module';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const ok = [], bad = [];
const t = (n, c, extra) => { (c ? ok : bad).push(n + (c || !extra ? '' : ' -> ' + extra)); };
const DATA = path.join(os.tmpdir(), 'iap-nas-served-' + Date.now());
fs.mkdirSync(DATA, { recursive: true });
// stub the NAS layer BEFORE ads.js uses it (ads.js holds the module object, so mutating
// its exports is enough)
const nas = require('../nas.js');
let nasRow = { assigned: 10000, remaining: 7000, hits: 3 }; // 3,000 genuinely served
let deactivated = 0;
nas.enabled = () => true;
nas.impressionsPerCredit = () => 1;
nas.readServed = async () => ({ assigned: nasRow.assigned, remaining: nasRow.remaining,
served: Math.max(0, Math.min(nasRow.assigned, nasRow.assigned - nasRow.remaining)), clicks: nasRow.hits });
nas.deactivate = async () => {
const served = Math.max(0, Math.min(nasRow.assigned, nasRow.assigned - nasRow.remaining));
nasRow.remaining = 0; // what the real stop does
deactivated++;
return { ok: true, served }; // the fix: report the TRUE figure captured first
};
nas.topUp = async () => ({ ok: true });
const ads = require('../ads.js');
ads.init({ dataDir: DATA, chain: null });
// ---- 1. stopping an ad must record what it really delivered, not its whole allocation
// Seed the store directly: going through createCampaign would drag in balances and the
// chain, and neither is what this test is about.
const FILE = path.join(DATA, 'campaigns.json'); // NOT ads.json
const id = 9001;
const seed = (status, nasServed) => {
fs.writeFileSync(FILE, JSON.stringify({
v: 1, nextId: 9002, campaigns: [{ id, owner: 'tester@example.com', memberId: 0, type: 'banner', name: 'served test',
targetUrl: 'https://example.com', imageUrl: 'https://example.com/b.png', width: 728, height: 90,
budget: 10000, spent: 0, accrued: 0, imps: 0, clicks: 0, status, nasAdId: 4242,
nasServed, created: Date.now() }],
burnsPending: [], v: 1, nextId: 9002
}));
ads.init({ dataDir: DATA, chain: null });
};
const store = () => JSON.parse(fs.readFileSync(FILE, 'utf8'));
const findC = () => (store().campaigns || []).find(c => c.id === id);
seed('active', 0);
t('test campaign seeded', !!findC(), 'missing');
let c;
// pause it: the stop path runs, and the TRUE served (3,000) must be what is recorded
await ads.setStatus('tester@example.com', id, 'paused').catch(() => {});
c = findC();
t('pausing stopped the NAS ad', deactivated >= 1, String(deactivated));
t('the recorded served count is the REAL 3,000, not the 10,000 allocation',
c && c.nasServed === 3000, c ? 'nasServed=' + c.nasServed : 'campaign missing');
t('and it is not the full allocation', !c || c.nasServed !== 10000, c && String(c.nasServed));
// ---- 2. a stopped campaign must never be reconciled again (this is the billing guard)
const spentBefore = c ? (c.spent || 0) + (c.accrued || 0) : 0;
await ads.reconcileNas();
c = findC();
const spentAfter = c ? (c.spent || 0) + (c.accrued || 0) : 0;
t('a paused campaign is NOT charged after its NAS ad was stopped',
spentAfter === spentBefore, 'before ' + spentBefore + ' after ' + spentAfter);
t('and its served figure did not jump to the allocation',
c && c.nasServed === 3000, c && String(c.nasServed));
// ---- 3. an ACTIVE campaign still reconciles normally, so the guard did not break delivery
nasRow = { assigned: 10000, remaining: 5000, hits: 9 }; // 5,000 served now
c = findC();
if (c) { c.status = 'active'; c.nasServed = 3000;
const s = store(); const i = s.campaigns.findIndex(x => x.id === id); s.campaigns[i] = c;
fs.writeFileSync(FILE, JSON.stringify(s)); ads.init({ dataDir: DATA, chain: null }); }
await ads.reconcileNas();
c = findC();
t('an active campaign still reconciles real delivery', c && c.nasServed === 5000, c && String(c.nasServed));
// The seeded member has no funded balance, so the charge itself cannot land; what matters
// is that reconcile ATTEMPTED it and the campaign was retired as exhausted rather than
// silently continuing to accrue undelivered impressions.
t('reconcile acted on the new impressions and retired the campaign',
c && c.status === 'out', c && 'status=' + c.status);
console.log('PASS ' + ok.length);
for (const b of bad) console.log('FAIL ' + b);
try { fs.rmSync(DATA, { recursive: true, force: true }); } catch (e) {}
process.exit(bad.length ? 1 : 0);