Campaign refill notices: tell an advertiser when an ad is nearly out and when it has stopped, one email per owner with what it delivered, their balance and the bonus ladder; every send recorded and watched for the relaunch, top-up or purchase that follows

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-22 05:21:57 -05:00
parent 9df669fa7c
commit 635844a12b
2 changed files with 244 additions and 1 deletions
+225
View File
@@ -0,0 +1,225 @@
// Campaign refill notices (Marty, 2026-09-22). Two moments in an advertiser's life were silent:
// the ad running low, and the ad stopping dead. 70 member campaigns had already delivered their whole
// budget and stopped with nobody told, and half of those owners were still holding credits they could
// have spent the same minute. This watches for both moments, writes one email and one on-site notice
// per owner (never one per campaign), leads with what the ad actually delivered, and puts the bonus
// ladder in front of them at the only moment it is persuasive.
//
// Everything sent is recorded, and every send is then watched for what the owner did next: relaunched
// a campaign, topped one up, or bought credits. That is the conversion history, in data/refill.json.
//
// Settings (siteConfig): refillNotices '1'|'0' (default on), refillLowPct (80) the share of budget
// delivered that counts as "running low".
'use strict';
const fs = require('fs');
const path = require('path');
let X = {}, FILE = null, S = null;
const DAY = 86400000;
const LOOKBACK = 45 * DAY; // a send older than this stops being watched for a conversion
const MAX_PER_TICK = 25; // owners emailed in one pass, so a backfill goes out in batches
function init(opts) { X = opts; FILE = path.join(opts.dataDir, 'refill.json'); S = load(); }
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { sent: {}, seq: 0 }; } }
function save() { try { fs.writeFileSync(FILE, JSON.stringify(S)); } catch (e) {} }
function cfg() {
const c = (X.siteConfig && X.siteConfig()) || {};
return {
on: String(c.refillNotices == null ? '1' : c.refillNotices) === '1',
lowPct: Math.min(99, Math.max(50, Number(c.refillLowPct) || 80)),
fridayOn: String(c.fridayPromo == null ? '1' : c.fridayPromo) === '1',
fridayPct: Number(c.fridayBonusPct) || 20,
};
}
const SITE = 'https://instantadpay.com';
const n0 = v => (typeof v === 'number' && isFinite(v) ? v : 0);
const money = n => Number(n || 0).toLocaleString('en-US');
const delivered = c => n0(c.spent) + n0(c.accrued);
const key = (id, state) => id + ':' + state;
// the five packages and what each dollar buys, so the ladder is never hand-typed
const PACKS = [
{ price: 5, credits: 500, bonus: 0 },
{ price: 20, credits: 2000, bonus: 0 },
{ price: 50, credits: 5500, bonus: 10 },
{ price: 100, credits: 12000, bonus: 20 },
{ price: 250, credits: 32500, bonus: 30 },
];
function ladderText() {
const c = cfg();
const rows = PACKS.map(p => ' $' + p.price + ' -> ' + money(p.credits) + ' credits' + (p.bonus ? ' (' + p.bonus + '% more per dollar)' : ''));
let s = 'Ad packages, and what each one adds to your balance:\n\n' + rows.join('\n')
+ '\n\nA credit is one cent of delivery, so the bigger packages are simply more delivery for the same dollar.';
if (c.fridayOn) s += '\n\nFive Dollar Friday: any package from $5 up, bought on a Friday, earns an extra ' + c.fridayPct + '% in credits on top of the above.';
return s;
}
function ladderHtml() {
const c = cfg();
const rows = PACKS.map(p => '<tr><td style="padding:6px 14px 6px 0"><b>$' + p.price + '</b></td><td style="padding:6px 14px 6px 0">' + money(p.credits)
+ ' credits</td><td style="padding:6px 0;color:#0E8F72">' + (p.bonus ? p.bonus + '% more per dollar' : '') + '</td></tr>').join('');
return '<p><b>Ad packages, and what each one adds to your balance:</b></p><table style="border-collapse:collapse">' + rows + '</table>'
+ '<p style="color:#556">A credit is one cent of delivery, so the bigger packages are simply more delivery for the same dollar.</p>'
+ (c.fridayOn ? '<p><b>Five Dollar Friday:</b> any package from $5 up, bought on a Friday, earns an extra ' + c.fridayPct + '% in credits on top of the above.</p>' : '');
}
// what one owner's batch of campaigns says in the body
function describe(list) {
return list.map(c => {
const bits = [money(Math.round(delivered(c))) + ' of ' + money(Math.round(n0(c.budget))) + ' credits delivered'];
if (n0(c.imps)) bits.push(money(n0(c.imps)) + ' impressions');
if (n0(c.clicks)) bits.push(money(n0(c.clicks)) + ' clicks');
return ' "' + (c.name || c.title || ('campaign #' + c.id)) + '" (' + (c.type || 'ad') + '): ' + bits.join(', ');
}).join('\n');
}
async function compose(owner, list, state, credits) {
const many = list.length > 1;
const totalImps = list.reduce((n, c) => n + n0(c.imps), 0);
const totalClicks = list.reduce((n, c) => n + n0(c.clicks), 0);
const canRelaunch = credits >= 100;
const subject = state === 'out'
? (many ? 'Your InstantAdPay ads have finished delivering' : 'Your ad "' + (list[0].name || 'campaign') + '" has finished delivering')
: (many ? 'Your InstantAdPay ads are nearly out of credits' : 'Your ad "' + (list[0].name || 'campaign') + '" is nearly out of credits');
const opener = state === 'out'
? (many ? 'Your ' + list.length + ' campaigns have spent their full budget and stopped. They delivered everything you paid for.'
: 'That campaign spent its full budget and stopped. It delivered everything you paid for.')
: (many ? 'Your ' + list.length + ' campaigns are close to spending their full budget. When they do, they stop.'
: 'That campaign is close to spending its full budget. When it does, it stops.');
const totals = (totalImps || totalClicks)
? 'Between them so far: ' + money(totalImps) + ' impressions and ' + money(totalClicks) + ' clicks.'
: '';
const next = canRelaunch
? 'You still have ' + money(Math.round(credits)) + ' credits in your balance, which is enough to put an ad back up right now without buying anything. Open Campaigns and start one: ' + SITE + '/my#campaigns'
: 'Your balance is down to ' + money(Math.round(credits)) + ' credits, so the next campaign needs a top-up first: ' + SITE + '/my#buy';
const lines = [opener, totals, next, ladderText(),
'Every package pays your sponsor line in the same on-chain transaction, and every payout is on the public ledger at ' + SITE + '/ledger.'];
const text = lines.filter(Boolean).join('\n\n') + '\n\nWhat each one did:\n' + describe(list) + '\n\nInstantAdPay';
const html = '<p>' + opener + '</p>' + (totals ? '<p>' + totals + '</p>' : '')
+ '<p>' + next.replace(/(https:\/\/[^\s]+)/g, '<a href="$1">$1</a>') + '</p>'
+ '<p><b>What each one did:</b></p><ul>' + list.map(c => '<li>' + esc(c.name || ('campaign #' + c.id)) + ' (' + esc(c.type || 'ad') + '): '
+ money(Math.round(delivered(c))) + ' of ' + money(Math.round(n0(c.budget))) + ' credits delivered'
+ (n0(c.imps) ? ', ' + money(n0(c.imps)) + ' impressions' : '') + (n0(c.clicks) ? ', ' + money(n0(c.clicks)) + ' clicks' : '') + '</li>').join('') + '</ul>'
+ ladderHtml()
+ '<p style="color:#556">Every package pays your sponsor line in the same on-chain transaction, and every payout is on the public ledger.</p>';
return { subject, text, html };
}
const esc = s => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// ---- the pass -------------------------------------------------------------------------------
let running = false;
async function tick() {
if (running || !cfg().on) return { sent: 0 };
running = true;
try {
const c = cfg();
const all = await X.ads.adminList(1000);
const mine = all.filter(x => !x.house && x.owner);
// group the campaigns that need a notice by owner, so one person gets one email
const due = new Map(); // owner -> { state -> [campaigns] }
for (const cm of mine) {
const b = n0(cm.budget); if (b <= 0) continue;
let state = null;
if (cm.status === 'out') state = 'out';
else if (cm.status === 'active' && delivered(cm) >= b * (c.lowPct / 100)) state = 'low';
if (!state) continue;
if (S.sent[key(cm.id, state)]) continue;
if (state === 'low' && S.sent[key(cm.id, 'out')]) continue; // already past it
const g = due.get(cm.owner) || {}; (g[state] = g[state] || []).push(cm); due.set(cm.owner, g);
}
let sent = 0;
for (const [owner, byState] of due) {
if (sent >= MAX_PER_TICK) break;
for (const state of ['out', 'low']) {
const list = byState[state]; if (!list || !list.length) continue;
const ok = await notify(owner, list, state).catch(e => { console.error('refill notify', owner, e.message); return false; });
if (ok) sent++;
break; // one state per owner per pass: "out" is the louder message
}
}
await checkConversions();
if (sent) { save(); console.log('refill notices sent:', sent); }
return { sent };
} finally { running = false; }
}
async function notify(owner, list, state) {
const acct = await X.accounts.byEmail(owner); if (!acct) return false;
const stopped = X.drip && X.drip.isUnsubscribed ? await X.drip.isUnsubscribed(owner).catch(() => false) : false;
let credits = 0; try { credits = await X.creditsFor(owner); } catch (e) {}
const m = await compose(owner, list, state, credits);
// the on-site notice always goes; the email is skipped for anyone who opted out
try { await X.message(owner, m.subject, m.html); } catch (e) { console.error('refill inbox', e.message); }
if (!stopped && X.mailer.hasKey()) { try { await X.mailer.send(owner, m.subject, m.text); } catch (e) { console.error('refill mail', e.message); } }
const rec = {
id: ++S.seq, owner, state, ts: Date.now(), emailed: !stopped && X.mailer.hasKey(),
campaigns: list.map(c => ({ id: c.id, name: c.name || null, type: c.type || null, budget: Math.round(n0(c.budget)), delivered: Math.round(delivered(c)), imps: n0(c.imps), clicks: n0(c.clicks) })),
creditsAtSend: Math.round(credits), memberId: acct.memberId || 0, converted: null,
};
for (const cm of list) S.sent[key(cm.id, state)] = rec.id;
S.records = S.records || {}; S.records[rec.id] = rec;
save();
return true;
}
// ---- did anything come of it? ---------------------------------------------------------------
// Polled rather than hooked: a relaunch, a top-up and a purchase all show up in state, and polling
// cannot miss one because a call site was forgotten.
async function checkConversions() {
S.records = S.records || {};
const open = Object.values(S.records).filter(r => !r.converted && Date.now() - r.ts < LOOKBACK);
if (!open.length) return;
const all = await X.ads.adminList(1000);
const evs = (X.chain.recentEvents(4000) || []).filter(e => e.type === 'Purchase');
for (const r of open) {
const since = r.ts;
// 1. bought credits (any position on the account)
let ids = [r.memberId].filter(Boolean);
try { const ps = await X.accounts.positions(r.owner); ids = [...new Set([...ids, ...ps.map(p => p.memberId).filter(Boolean)])]; } catch (e) {}
const buy = evs.find(e => ids.includes(Number(e.id || e.buyerId)) && norm(e) > since);
if (buy) { mark(r, 'purchase', { cents: n0(buy.priceCents), tx: buy.tx || null }); continue; }
// 2. put an ad back up, or topped one up
const fresh = all.find(c => c.owner === r.owner && !c.house && (n0(c.created) > since || (c.status === 'active' && !r.campaigns.some(x => x.id === c.id))));
if (fresh) { mark(r, 'relaunch', { campaignId: fresh.id, name: fresh.name || null }); continue; }
const topped = all.find(c => { const was = r.campaigns.find(x => x.id === c.id); return was && n0(c.budget) > was.budget; });
if (topped) { mark(r, 'topup', { campaignId: topped.id, added: Math.round(n0(topped.budget) - r.campaigns.find(x => x.id === topped.id).budget) }); }
}
save();
}
const norm = e => { let t = n0(e.ts); if (t < 1e12) t *= 1000; return t; };
function mark(r, kind, detail) {
r.converted = { kind, at: Date.now(), hours: Math.round((Date.now() - r.ts) / 3600000), detail: detail || null };
console.log('refill converted:', r.owner, kind, 'after', r.converted.hours + 'h');
}
// ---- what the admin sees --------------------------------------------------------------------
function stats() {
S.records = S.records || {};
const recs = Object.values(S.records).sort((a, b) => b.ts - a.ts);
const by = { out: { sent: 0, converted: 0 }, low: { sent: 0, converted: 0 } };
const kinds = {};
let revenueCents = 0, hours = [];
for (const r of recs) {
const b = by[r.state] || (by[r.state] = { sent: 0, converted: 0 });
b.sent++;
if (r.converted) {
b.converted++; kinds[r.converted.kind] = (kinds[r.converted.kind] || 0) + 1;
hours.push(r.converted.hours);
if (r.converted.kind === 'purchase') revenueCents += n0(r.converted.detail && r.converted.detail.cents);
}
}
const conv = recs.length ? Math.round(recs.filter(r => r.converted).length / recs.length * 100) : 0;
return {
enabled: cfg().on, lowPct: cfg().lowPct,
totals: { sent: recs.length, converted: recs.filter(r => r.converted).length, ratePct: conv,
purchasesUsd: Math.round(revenueCents / 100), medianHours: hours.length ? hours.sort((a, b) => a - b)[Math.floor(hours.length / 2)] : null },
byState: by, byKind: kinds,
recent: recs.slice(0, 60).map(r => ({ id: r.id, owner: r.owner, state: r.state, at: r.ts, emailed: r.emailed,
credits: r.creditsAtSend, campaigns: r.campaigns.length, imps: r.campaigns.reduce((n, c) => n + c.imps, 0),
converted: r.converted })),
};
}
module.exports = { init, tick, stats, PACKS };