From cb6da01e1c1f6da7834ad09b31a7d68553df030d Mon Sep 17 00:00:00 2001 From: martbost Date: Thu, 17 Sep 2026 19:52:12 -0500 Subject: [PATCH] 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) --- ads.js | Bin 100055 -> 102208 bytes nas.js | 15 ++++++- qa/nas-served.mjs | 102 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 qa/nas-served.mjs diff --git a/ads.js b/ads.js index 8d67468f765bd00d94cc726a76aa6f3a37d723d1..19632a492895f6902b56c51a5a8f82945143269f 100644 GIT binary patch delta 14974 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 fR(6($ZezRoW8296zuQJxv12>&{c$^3GV}O0, 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) { 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)]); - 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). diff --git a/qa/nas-served.mjs b/qa/nas-served.mjs new file mode 100644 index 0000000..861b000 --- /dev/null +++ b/qa/nas-served.mjs @@ -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);