diff --git a/refill.js b/refill.js new file mode 100644 index 0000000..8ecfa36 --- /dev/null +++ b/refill.js @@ -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 => '
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 }; diff --git a/server.js b/server.js index 2508453..0dff860 100644 --- a/server.js +++ b/server.js @@ -39,7 +39,8 @@ const audit = require('./audit'); // counter audit: views vs delivery logs, const leaderboard = require('./leaderboard'); const toolkit = require('./toolkit'); const badge = require('./badge'); // achievement badge image drawn on the server with ffmpeg (2026-09-16) -const friday = require('./friday'); // Five Dollar Friday: +20% credits on Friday packs, the wave, the badge (Marty, 2026-09-20) +const friday = require('./friday'); +const refill = require('./refill'); // campaign running-low / ran-out notices, and what came of them // Five Dollar Friday: +20% credits on Friday packs, the wave, the badge (Marty, 2026-09-20) const snapshot = require('./snapshot'); // daily growth snapshot -> Telegram payments feed (Marty, 2026-09-15) const pipeline = require('./pipeline'); // sponsor follow-up board (coming soon until site setting pipelineMode = on) (Marty, 2026-09-15) const videomaker = require('./videomaker'); // Circuit tool: promo videos with the member's own end card (ffmpeg in the image) // badge-gated promo toolkit + AI Copy Engine (Surge and up) (Marty, 2026-09-14) // referral contest: /leaderboard, Overview card, weekly + monthly winners (Marty, 2026-09-14) // release notes + roadmap: /whats-new, Overview card, Admin > Releases (Marty, 2026-09-14) // blog -> Blotato -> X + Instagram on publish (Marty, 2026-09-13) // admin member card: search, drilldown, edits (Marty, 2026-09-13) // admin-written coaching articles, server-rendered public /blog with SEO metadata (Marty, 2026-09-12) @@ -414,6 +415,15 @@ async function boot() { friday.init({ dataDir: DATA_DIR, siteConfig, chain, ads, accounts, mailer, drip, telegram: async text => { const sc = siteConfig(); if (!sc.telegramBotToken || !sc.telegramEchoChatId) return false; return telegramSend(sc.telegramEchoChatId, '\u{1F7E0} InstantAdPay \u00b7 ' + text, sc.telegramEchoTopicId); }, notify: async (memberId, subject, text, kind) => { const a = await accounts.byMemberId(memberId); if (!a || !a.email) return; const html = '' + String(text).replace(/&/g, '&').replace(//g, '>').replace(/(https:\/\/[^\s]+)/g, '$1').split('\n\n').join('
').replace(/\n/g, '
') + '