diff --git a/public/suite-traffic.js b/public/suite-traffic.js index d1fa0f0..9071647 100644 --- a/public/suite-traffic.js +++ b/public/suite-traffic.js @@ -181,10 +181,15 @@ // can stop it rendering as nonsense. Clamp to what the member actually // bought so the row always reconciles: served + left = bought. Any genuine // over-delivery is a bonus to them and needs no explanation on this table. + // The network counts `remaining` DOWN for text ads and UP for banners — + // measured, not documented. Reading both the same way showed every banner + // as 0 served while it was quietly delivering thousands. var bought = Number(c.bought != null ? c.bought : c.impressions) || 0; - var rawLeft = l.remaining != null ? Number(l.remaining) : null; - var left = rawLeft == null ? null : Math.max(0, Math.min(rawLeft, bought)); - var servedShown = left == null ? Number(served) : Math.max(0, bought - left); + var rawRem = l.remaining != null ? Number(l.remaining) : null; + var servedShown, left; + if (rawRem == null) { servedShown = Number(served) || 0; left = null; } + else if (c.kind === 'text') { left = Math.max(0, Math.min(rawRem, bought)); servedShown = bought - left; } + else { servedShown = Math.max(0, Math.min(rawRem, bought)); left = bought - servedShown; } var statusCell = c.stopped ? 'stopped (' + Number(c.refunded || 0).toLocaleString() + ' returned)' : (l.live ? 'running' : 'finished'); diff --git a/suite-traffic.js b/suite-traffic.js index 18ee804..7a6c4e7 100644 --- a/suite-traffic.js +++ b/suite-traffic.js @@ -79,6 +79,37 @@ function record(memberId, entry) { writeLedger(trimmed); } +// The ad network counts the SAME `remaining` column in opposite directions for +// the two placement types, which is not documented anywhere and had to be +// measured: +// +// TEXT ads — `remaining` counts DOWN from the purchase to zero as it serves. +// (ad 2689: 1,111 left mid-flight, 0 once complete.) +// BANNER ads — `remaining` counts UP as impressions are delivered, straight +// past the amount purchased. (ad 2707: 3,521 one day, 6,129 the +// next, on a 2,500 buy.) +// +// Reading both the same way made every banner report "0 served" while quietly +// delivering thousands — and made stop() treat delivered impressions as +// unserved and refund them. +// +// Returns what the member should see: served + left always reconciles to what +// they bought, and neither can exceed it. +function interpret(kind, bought, stat) { + const b = Math.max(0, Number(bought) || 0); + const rem = Math.max(0, Number(stat && stat.remaining) || 0); + let served; + if (kind === 'text') { + // count-down: what is gone is what was bought minus what is left + served = b - Math.min(rem, b); + } else { + // count-up: `remaining` IS the delivered count + served = Math.min(rem, b); + } + served = Math.max(0, Math.min(served, b)); + return { served: served, left: Math.max(0, b - served) }; +} + // ── signed call to the NAS bridge ─────────────────────────────────────────── function callNas(payload) { return new Promise(function (resolve, reject) { @@ -194,12 +225,15 @@ async function stop(memberId, adId) { if (pre.stopped) throw new Error('That banner is already stopped.'); let served = 0, unserved = 0; + const boughtGuess = Number(pre.bought != null ? pre.bought : pre.impressions) || 0; try { const s = await callNas({ action: 'stats', ad_ids: [Number(adId)] }); const st = (s.stats || [])[0]; if (st) { - served = Math.max(0, Number(st.served) || 0); - unserved = Math.max(0, Number(st.remaining) || 0); + // Interpret by placement type — banners count up, text counts down. + const r = interpret(pre.kind === 'text' ? 'text' : 'banner', boughtGuess, st); + served = r.served; + unserved = r.left; } } catch (e) { /* if stats are unavailable, refund nothing rather than guess */ } @@ -225,9 +259,7 @@ async function stop(memberId, adId) { // shows what they launched, not just what it ended up costing them. if (row.bought == null) row.bought = row.impressions; - // A handful of rows on the network carry a `remaining` larger than their - // `assigned` (a pre-existing counter quirk). Clamp, or a member sees - // "1,002 returned" on an ad they bought 1,000 impressions for. + // interpret() already reconciled these against what was bought. unserved = Math.min(unserved, row.bought); served = row.bought - unserved; row.stopped = true; @@ -245,4 +277,4 @@ async function stats(adIds) { return r.stats || []; } -module.exports = { init, configured, status, launch, stop, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE }; +module.exports = { init, configured, status, launch, stop, stats, interpret, allowanceFor, sizes, CREATIVES, ALLOWANCE };