Traffic Desk: stop-and-refund, plus never let a banner dead-end

Three fixes from live testing of the Traffic Desk:

- /p/<id> with no page built now 302s to /join/<id> instead of returning
  raw JSON 404. Any /p/ link already on a banner, flyer, or in a DM must
  always land somewhere useful.
- The personal-page ad destination is only offered (client) and only
  accepted (server) once a page actually exists; otherwise the member is
  pointed at the Page Builder.
- Members can stop a running banner and get the unserved impressions back
  in their monthly balance. Counters are read before deactivation, since
  deactivating zeroes `remaining` and would look fully served.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 10:03:38 -05:00
parent f7276589e9
commit 931eb4336b
4 changed files with 130 additions and 15 deletions
+45 -4
View File
@@ -122,9 +122,14 @@ async function launch(opts) {
throw new Error('That is more than your remaining ' + st.remaining.toLocaleString() + ' impressions this month.');
}
const target = opts.target === 'page'
? 'https://rmcircle.team/p/' + opts.id
: 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : '');
// Server-side guard: only allow the personal page as a destination when one
// actually exists. /p/<id> also redirects to /join/<id> when empty, so a live
// banner can never dead-end — but we shouldn't create that situation at all.
let target = 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : '');
if (opts.target === 'page') {
if (!opts.hasPage) throw new Error('Build your personal page first, then you can point ads at it.');
target = 'https://rmcircle.team/p/' + opts.id;
}
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
const res = await callNas({
@@ -144,10 +149,46 @@ async function launch(opts) {
return entry;
}
// Stop a running banner and return the UNSERVED impressions to the member's
// monthly balance. Order matters: read the counters BEFORE deactivating,
// because deactivation zeroes `remaining` and would make it look fully served.
async function stop(memberId, adId) {
const all = readLedger();
const mk = monthKey();
const rows = ((all[mk] || {})[String(memberId)]) || [];
const row = rows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!row) throw new Error('That banner is not one of yours from this month.');
if (row.stopped) throw new Error('That banner is already stopped.');
let served = 0, unserved = 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);
}
} catch (e) { /* if stats are unavailable, refund nothing rather than guess */ }
await callNas({ action: 'deactivate', ad_id: Number(adId) });
// Charge only what actually served; the rest returns to the allowance.
// `bought` preserves the original order size so the member's history still
// shows what they launched, not just what it ended up costing them.
if (row.bought == null) row.bought = row.impressions;
row.stopped = true;
row.stoppedAt = new Date().toISOString();
row.served = served;
row.refunded = unserved;
row.impressions = served; // what this campaign counts against the month
writeLedger(all);
return { adId: Number(adId), served: served, refunded: unserved };
}
async function stats(adIds) {
if (!adIds || !adIds.length) return [];
const r = await callNas({ action: 'stats', ad_ids: adIds.slice(0, 200) });
return r.stats || [];
}
module.exports = { init, configured, status, launch, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE };
module.exports = { init, configured, status, launch, stop, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE };