// 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 => '
Ad packages, and what each one adds to your balance:
A credit is one cent of delivery, so the bigger packages are simply more delivery for the same dollar.
' + (c.fridayOn ? 'Five Dollar Friday: any package from $5 up, bought on a Friday, earns an extra ' + c.fridayPct + '% in credits on top of the above.
' : ''); } // 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 = '' + opener + '
' + (totals ? '' + totals + '
' : '') + '' + next.replace(/(https:\/\/[^\s]+)/g, '$1') + '
' + 'What each one did:
Every package pays your sponsor line in the same on-chain transaction, and every payout is on the public ledger.
'; return { subject, text, html }; } const esc = s => String(s == null ? '' : s).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 };