635844a12b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
13 KiB
JavaScript
226 lines
13 KiB
JavaScript
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
// ---- 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 };
|