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 };