cb6da01e1c
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>