Files
martbost cb6da01e1c 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>
2026-09-17 19:52:12 -05:00

137 lines
6.9 KiB
JavaScript

// NAS syndication — pushes IAP campaigns out to Network Ad Space (Marty's own
// EvolutionScript platform; no source, so its MySQL DB IS the API). Mirrors the
// CTB Rewards direct-write pattern: INSERT payments then sponsorads with
// approved:1 (bypasses NAS moderation), and reads `remaining` back to reconcile
// spend into IAP's unified credit pool.
//
// KNOWN NAS TRUTHS (measured across RM Circle + CTB, 1600+ ads):
// - sponsorads.remaining counts DOWN: served = assigned - remaining.
// - pid 1 = text, pid 2 = banner ONLY; banner size lives in width/height.
// - hits = clicks (not impressions). catid 5 = Cryptocurrencies.
// - NAS drifts its own counters upward post-insert → clamp served to assigned.
//
// FEATURE-FLAGGED: inert unless NAS_DB_HOST/USER/PASSWORD/NAME are all set.
// Nothing here runs (no connection, no writes) when the flag is off.
const crypto = require('crypto');
let pool = null;
function enabled() {
return !!(process.env.NAS_DB_HOST && process.env.NAS_DB_USER
&& process.env.NAS_DB_PASSWORD && process.env.NAS_DB_NAME);
}
function nasPool() {
if (pool) return pool;
const mysql = require('mysql2/promise');
pool = mysql.createPool({
host: process.env.NAS_DB_HOST,
port: Number(process.env.NAS_DB_PORT || 3306),
user: process.env.NAS_DB_USER,
password: process.env.NAS_DB_PASSWORD,
database: process.env.NAS_DB_NAME,
charset: 'latin1', // EvolutionScript is latin1
connectionLimit: 3,
connectTimeout: 10000
});
return pool;
}
async function q(sql, args) { const [r] = await nasPool().query(sql, args); return r; }
const CATID_CRYPTO = Number(process.env.NAS_CATID || 5);
// how many NAS impressions one IAP credit buys, per format. IAP charges credits
// on its own surfaces at these same rates, so NAS delivery draws the same pool.
function impressionsPerCredit(type) {
return type === 'banner' ? 5 : type === 'text' ? 10 : type === 'video' ? 0 : 0;
}
function nasKind(type) {
// pid encodes text(1)/banner(2); adtype is a separate small int — 1 is the
// value the overwhelming majority of live NAS rows use for both formats.
const adtype = Number(process.env.NAS_ADTYPE || 1);
if (type === 'banner') return { pid: 2, adtype };
if (type === 'text') return { pid: 1, adtype };
return null; // only banner/text syndicate to NAS in v1 (login/solo/video are IAP-native)
}
// push one IAP campaign into NAS. `c` is a pubC-shaped campaign. Returns
// { nasAdId, assigned } or { skipped } / throws on a real DB error.
async function pushCampaign(c, opts = {}) {
if (!enabled()) return { skipped: 'flag-off' };
const kind = nasKind(c.type);
if (!kind) return { skipped: 'type' };
const budgetLeft = c.budget - (c.spent || 0);
const window = c.dailyCap ? Math.min(c.dailyCap, budgetLeft) : budgetLeft; // capped campaigns start with one day's allowance
const assigned = Math.max(1, Math.floor(window * impressionsPerCredit(c.type)));
const token = 'iap_' + crypto.randomBytes(8).toString('hex'); // manage token = Username
const now = new Date();
const days = Number(opts.days || 30);
const edate = new Date(now.getTime() + days * 86400000);
const payref = 'iap_campaign:' + c.id;
// payments first (MyISAM, no txn) — hand-rollback the row if sponsorads fails
const pay = await q(
'INSERT INTO payments (Username, Amount, Currency_code, status, Date, pay_address) VALUES (?,?,?,1,?,?)',
[token, 0, 'IAP_CREDIT', now, payref]);
try {
const ad = await q(
`INSERT INTO sponsorads
(Username, Subject, Body, WebsiteURL, assigned, remaining, hits, approved, Date, adtype,
Name, Email, PaymentDetails, EDate, sp, width, height, BannerURL, pid, ref_by, catid)
VALUES (?,?,?,?,?,?,0,1,?,?,?,?,?,?,'',?,?,?,?,0,?)`,
// clicks route through our redirect so they count and carry the network site as the referrer
[token, c.title || null, c.type === 'text' ? (c.body || null) : null, 'https://instantadpay.com/api/ads/click/' + c.id,
assigned, assigned, now, kind.adtype,
opts.name || 'InstantAdPay member', opts.email || 'ads@instantadpay.com',
'InstantAdPay campaign #' + c.id, edate,
c.width || '', c.height || '', c.type === 'banner' ? c.imageUrl : null, kind.pid, CATID_CRYPTO]);
return { nasAdId: ad.insertId, manageToken: token, assigned };
} catch (e) {
try { await q('DELETE FROM payments WHERE ID=?', [pay.insertId]); } catch (e2) {}
throw e;
}
}
// read served count for a syndicated ad (assigned - remaining, clamped ≥0 and
// ≤ assigned because NAS drifts counters upward post-insert).
async function readServed(nasAdId) {
if (!enabled()) return null;
const rows = await q('SELECT assigned, remaining, hits FROM sponsorads WHERE ID=?', [Number(nasAdId)]);
if (!rows.length) return null;
const assigned = Number(rows[0].assigned) || 0;
const remaining = Number(rows[0].remaining) || 0;
const served = Math.max(0, Math.min(assigned, assigned - remaining));
return { assigned, remaining, served, clicks: Number(rows[0].hits) || 0 };
}
// 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) {
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, served }; // callers MUST persist this as the final served count
}
// top up a syndicated ad with more impressions (buy-more-views / reactivate).
// point an existing NAS ad at our click redirect (migration for rows pushed before 2026-09-10)
async function setClickUrl(nasAdId, campaignId) {
if (!enabled()) return { skipped: 'flag-off' };
await q('UPDATE sponsorads SET WebsiteURL=? WHERE ID=?', ['https://instantadpay.com/api/ads/click/' + Number(campaignId), Number(nasAdId)]);
return { ok: true };
}
async function topUp(nasAdId, addImpressions, days) {
if (!enabled()) return { skipped: 'flag-off' };
await q(`UPDATE sponsorads SET assigned=assigned+?, remaining=remaining+?, approved=1,
EDate=DATE_ADD(NOW(), INTERVAL ? DAY) WHERE ID=?`,
[Number(addImpressions), Number(addImpressions), Number(days || 30), Number(nasAdId)]);
return { ok: true };
}
module.exports = { setClickUrl, enabled, pushCampaign, readServed, deactivate, topUp, impressionsPerCredit, nasKind };