Files
instantadpay/ads.js
T
martbost f0a6a93f8b Campaigns: archive a finished one, get the credits back, keep the numbers
A member asked for a delete button. Deleting would orphan the credit ledger,
the hourly view rows and the P&L, and throw away the figures they actually
want, so a campaign is archived instead: out of the working table, listed in
full underneath with its views, clicks and spend, and restorable.

The credits are the real point. Reserved budget is computed from campaigns
that are active or paused, so a paused campaign quietly holds credits the
member cannot spend anywhere else. Archiving releases them the moment the
status changes, with nothing to refund and nothing that can double-refund.

Only a paused or finished campaign can be archived, so pause stays the
deliberate first step, and restoring brings it back paused rather than live.
The syndication rails are stopped on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 08:40:17 -05:00

1839 lines
111 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
// JSON volume file fallback for local dev.
// Spend accrues per campaign; burns queue for the engine signer to consume()
// on-chain (/api/admin/burns). All exported functions are async.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const db = require('./db');
const nas = require('./nas'); // NAS syndication (inert unless NAS_DB_* env is set)
const adrevlnks = require('./adrevlnks'); // AdRevLinks popup syndication (inert unless ADREVLNKS_BRIDGE_* is set)
const dripoffers = require('./dripoffers'); // DripOffers paid-per-click syndication (inert unless DRIPOFFERS_BRIDGE_* is set)
let DATA_DIR = null;
let chain = null;
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json');
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
// Admin house ads: owned by this pseudo-account, funded by nothing. They serve
// through every normal path but the spend is never charged to anyone; the
// budget only acts as a delivery cap (status flips to 'out' when it's reached).
const HOUSE_OWNER = 'house@instantadpay.com';
function rates() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
return Object.assign({
bannerBatch: 10, bannerCreditsPerBatch: 2,
textBatch: 10, textCreditsPerBatch: 1,
// Login ads are a flat slot buy: one price, 30 days, unlimited impressions, no
// delivery guarantee. Same total as the old 100/day x 30, but with a known end and
// no daily drain against inventory that may not exist that day.
loginSlotCredits: 1000,
loginSlotDays: 30,
loginSlotsMax: 3, // member slots live at once; the sign-ins are shared, more slots = fewer views each
loginCreditsPerDay: 100, // legacy: only used to close out pre-slot campaigns
loginDwellSeconds: 10, // full-screen interstitial after sign-in
burnBatchMin: 50,
welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5,
viewDwellSeconds: 10, // Marty 2026-09-09: at least 10s so people actually look
// onsite solo ads: full-message inbox delivery, charged per guaranteed recipient
soloCostPerRecipient: 5,
soloMinRecipients: 10,
soloReadCredits: 2, // earned by the reader per rewarded read
soloReadCapPerDay: 5,
soloReadDwellSeconds: 10,
// watch-to-earn video ads: advertiser picks a required watch length, which
// sets the per-view price; the viewer earns per completed watch
videoTiers: [
{ secs: 10, cost: 3, reward: 1 },
{ secs: 30, cost: 7, reward: 2 },
{ secs: 60, cost: 12, reward: 4 }
],
videoWatchCapPerDay: 8,
// featured rotation: your link runs in the featured strip for N days; the
// dilution (how many links share the rotation) is disclosed before you buy
featuredPerDay: 40, // credits per day
featuredDurations: [1, 2, 7],
featuredSlotsPerDay: 10, // cap on links sharing the rotation on any one day
featuredWindowDays: 7, // how far ahead a day can be booked
// verified visits: buy a pack of guaranteed unique human visits; each is a
// dwelled + captcha-verified visit by a distinct member (never a repeat)
visitCostPerVisit: 3, // credits the advertiser pays per delivered visit
visitMinPack: 20, // smallest pack
visitReward: 1, // credits the viewer earns per verified visit
visitDwellSeconds: 8,
visitCapPerDay: 20, // per-viewer daily cap on rewarded visits
// credit bonuses paid once when a member reaches each milestone (the same
// ladder as the Overview stepper). Keys: payouts / firstBuyer / level2 / level3
milestoneBonus: { payouts: 10, firstBuyer: 25, level2: 50, level3: 100 }
}, saved);
}
function setRates(patch) {
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
return rates();
}
const TYPES = ['banner', 'text', 'login', 'solo', 'video', 'featured', 'visits'];
// standard IAB sizes — ids map straight to NAS sponsorads width/height (pid 2)
const BANNER_SIZES = [
{ id: '728x90', w: 728, h: 90, label: 'Leaderboard 728×90' },
{ id: '300x250', w: 300, h: 250, label: 'Medium rectangle 300×250' },
{ id: '468x60', w: 468, h: 60, label: 'Banner 468×60' },
{ id: '160x600', w: 160, h: 600, label: 'Wide skyscraper 160×600' },
{ id: '120x600', w: 120, h: 600, label: 'Skyscraper 120×600' },
{ id: '320x50', w: 320, h: 50, label: 'Mobile leaderboard 320×50' },
{ id: '125x125', w: 125, h: 125, label: 'Square button 125×125' }
];
// solo bodies are member-authored rich text rendered in OTHER members'
// browsers: whitelist-sanitize server-side — known tags only, no attributes
// (except http(s) hrefs, rebuilt clean), every stray angle bracket escaped
const SOLO_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'p', 'br',
'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'div', 'span', 'figure']);
function sanitizeSolo(html) {
const src = String(html || '').replace(//g, '')
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') // drop script/style content whole
.replace(/<(script|style)\b[^>]*>/gi, '') // and any unclosed opener
.slice(0, 12000).replace(/<!--[\s\S]*?-->/g, '');
const keep = [];
const tokenized = src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => {
tag = tag.toLowerCase();
let out = '';
const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)|https:\/\/[^\s"'<>]+)$/i.test(s);
const srcOf = a => { const m2 = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(a || ''); return (m2 && (m2[1] || m2[2])) || ''; };
if (tag === 'a') {
if (close) out = '</a>';
else {
const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || '');
const href = (hm && (hm[1] || hm[2])) || '';
out = URL_RE.test(href) ? '<a href="' + href.replace(/"/g, '%22') + '" target="_blank" rel="noopener nofollow">' : '';
}
} else if (tag === 'img') { // inline images: only our uploads or https, no other attrs
const s = srcOf(attrs);
out = (!close && safeSrc(s)) ? '<img src="' + s.replace(/"/g, '%22') + '" alt="" loading="lazy">' : '';
} else if (tag === 'video') {
out = close ? '</video>' : (safeSrc(srcOf(attrs)) ? '<video src="' + srcOf(attrs).replace(/"/g, '%22') + '" controls playsinline>' : '<video controls playsinline>');
} else if (tag === 'source') {
const s = srcOf(attrs);
out = (!close && safeSrc(s)) ? '<source src="' + s.replace(/"/g, '%22') + '">' : '';
} else if (SOLO_TAGS.has(tag)) out = '<' + (close ? '/' : '') + tag + '>';
keep.push(out);
return '' + (keep.length - 1) + '';
});
return tokenized.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/(\d+)/g, (m, i) => keep[Number(i)]).trim();
}
const URL_RE = /^https?:\/\/[^\s]+$/i;
const bid = () => crypto.randomBytes(8).toString('hex');
function batchFor(type, r) {
return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch }
: type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null;
}
function validate(input) {
const type = String(input.type || '');
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
const name = String(input.name || '').trim().slice(0, 60);
if (!name) return { error: 'Give the campaign a name.' };
const targetUrl = String(input.targetUrl || '').trim();
if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' };
// visits are a flat pack: the budget is the pack price, derived from the count (the form has no budget box)
let budget = Math.floor(Number(input.budget) || 0);
if (type === 'visits' && !budget) budget = Math.floor(Number(input.count) || 0) * (rates().visitCostPerVisit || 3);
if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null };
if (type !== 'featured' && (input.startsAt || input.endsAt)) { // optional schedule (featured books whole days instead)
const now = Date.now();
const s = input.startsAt ? Number(input.startsAt) : 0, e = input.endsAt ? Number(input.endsAt) : 0;
if (input.startsAt && !(s > 0)) return { error: 'The start time does not look right.' };
if (input.endsAt && !(e > 0)) return { error: 'The end time does not look right.' };
if (s > now + 366 * 86400000) return { error: 'Pick a start within the next year.' };
if (e && e <= Math.max(now, s) + 30 * 60000) return { error: 'The end needs to be at least 30 minutes after the start.' };
if (s > now) out.starts = s;
if (e) out.expires = e;
}
if (input.geo !== undefined && input.geo !== null && String(input.geo).trim() !== '') { // country tiers: '1', '1,2', '2,3' ...; all three = everyone
const tiers = [...new Set(String(input.geo).split(/[\s,]+/).filter(t => ['1', '2', '3'].includes(t)))].sort();
if (!tiers.length) return { error: 'Pick at least one country tier, or leave it on everyone.' };
if (tiers.length < 3) out.geo = tiers.join(',');
}
if ((type === 'banner' || type === 'text') && input.dailyCap) { // optional pacing: credits per day
const cap = Math.floor(Number(input.dailyCap) || 0);
if (cap < 10) return { error: 'A daily cap needs to be at least 10 credits.' };
if (cap > budget) return { error: 'The daily cap cannot exceed the budget.' };
out.dailyCap = cap;
}
if (type === 'banner') {
out.imageUrl = String(input.imageUrl || '').trim();
if (!URL_RE.test(out.imageUrl)) return { error: 'Banner ads need an image URL starting with http(s)://' };
const size = BANNER_SIZES.find(s => s.id === String(input.size || ''));
if (!size) return { error: 'Pick a banner size.' };
out.width = size.w; out.height = size.h;
}
// login ads are full-screen interstitials of the target URL itself: no creative needed
if (type === 'text') {
out.title = String(input.title || '').trim().slice(0, 60);
out.body = String(input.body || '').trim().slice(0, 140);
if (!out.title) return { error: 'Text ads need a headline.' };
}
if (type === 'solo') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 80);
out.body = sanitizeSolo(input.body);
const plain = out.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!out.title) return { error: 'Solo ads need a subject line.' };
if (plain.length < 40) return { error: 'Write the message — at least 40 characters.' };
if (plain.length > 2000) return { error: 'Keep the message under 2000 characters of text.' };
const mu = String(input.mediaUrl || '').trim();
if (mu) {
if (!/^\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)$/.test(mu))
return { error: 'Attach the image or video through the uploader.' };
out.imageUrl = mu; // solo media rides the image_url column
}
out.ctaLabel = String(input.ctaLabel || '').trim().slice(0, 30) || null;
const min = (r.soloCostPerRecipient || 5) * (r.soloMinRecipients || 10);
if (budget < min) return { error: 'Solo ads start at ' + min + ' credits (' + (r.soloMinRecipients || 10) + ' guaranteed deliveries).' };
}
if (type === 'featured') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 70);
if (!out.title) return { error: 'Featured links need a headline.' };
const days = Number(input.days);
if (!(r.featuredDurations || []).includes(days)) return { error: 'Pick a duration (' + (r.featuredDurations || []).join(', ') + ' days).' };
const start = dayStart(input.startDay); // start-of-day UTC of the chosen booking day
if (start === null) return { error: 'Pick a valid start day.' };
const todayStart = dayStart(0);
if (start < todayStart) return { error: 'That day has already started — pick today or later.' };
if (start > todayStart + (r.featuredWindowDays || 7) * 86400000) return { error: 'You can book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' };
out.body = String(days);
out.starts = start;
out.expires = start + days * 86400000;
out.featuredRun = { start, days }; // checked for slot availability in createCampaign
const cost = days * (r.featuredPerDay || 40);
if (budget < cost) return { error: 'A ' + days + '-day featured run is ' + cost + ' credits.' };
out.budget = cost; // flat buy
}
if (type === 'login') {
const r = rates();
const cost = r.loginSlotCredits || 3000, days = r.loginSlotDays || 30;
if (budget < cost) return { error: 'A login-ad slot is ' + cost + ' credits for ' + days + ' days of unlimited impressions.' };
out.budget = cost; // flat buy, never metered
out.body = String(days);
out.expires = Date.now() + days * 86400000; // the sweep closes it and returns anything unspent
}
if (type === 'visits') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 80) || null;
const count = Math.floor(Number(input.count) || 0);
if (count < (r.visitMinPack || 20)) return { error: 'Verified-visit packs start at ' + (r.visitMinPack || 20) + ' visits.' };
out.body = String(count); // target visit count rides the body column
const cost = count * (r.visitCostPerVisit || 3);
if (budget < cost) return { error: count + ' verified visits is ' + cost + ' credits.' };
out.budget = cost; // flat up-front buy
}
if (type === 'video') {
const r = rates();
const src = String(input.videoUrl || '').trim();
if (!/^(\/uploads\/[a-z0-9]{24}\.(mp4|webm)|https:\/\/[^\s]+\.(mp4|webm)(\?[^\s]*)?)$/i.test(src))
return { error: 'Upload an MP4/WebM, or paste a direct https link ending in .mp4 or .webm.' };
out.imageUrl = src; // video source rides the image_url column
// client-detected pixel dimensions decide the surface: portrait -> Shorts reel, landscape -> Watch videos
out.width = Number(input.videoW) > 0 ? Math.round(Number(input.videoW)) : null;
out.height = Number(input.videoH) > 0 ? Math.round(Number(input.videoH)) : null;
out.title = String(input.title || '').trim().slice(0, 80) || null;
out.ctaLabel = String(input.ctaLabel || '').trim().slice(0, 30) || null;
const tier = (r.videoTiers || []).find(t => t.secs === Number(input.watchSecs));
if (!tier) return { error: 'Pick a watch length (' + (r.videoTiers || []).map(t => t.secs + 's').join(', ') + ').' };
out.body = String(tier.secs); // required watch seconds ride the body column
const min = tier.cost * 10; // at least 10 views' worth
if (budget < min) return { error: 'A ' + tier.secs + 's video ad starts at ' + min + ' credits (' + tier.cost + ' per view, 10 views).' };
}
return { ok: true, c: out };
}
const pubC = c => ({ id: c.id, type: c.type, name: c.name, owner: c.owner || null, house: !!c.house, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null,
title: c.title || null, body: c.body || null, budget: c.budget, spent: (c.spent || 0) + (c.accrued || 0),
width: c.width || null, height: c.height || null, ctaLabel: c.ctaLabel || null,
expires: c.expires || null, starts: c.starts || null,
imps: c.imps || 0, impsNas: c.nasServed || 0, clicks: c.clicks || 0, status: c.status, created: c.created, scheduled: !!(c.starts && c.starts > Date.now() && c.status === 'active'),
dailyCap: c.dailyCap || null, daySpent: (c.dayKey === dayKeyNow()) ? (c.daySpent || 0) : 0, geo: c.geo || null });
// Marty's day, not UTC. The UTC boundary rolled over at 7 PM Central, so an evening
// sign-in and the next morning's counted as two days and the once-per-day bonus paid
// twice: 83 of the first 364 were less than 24h apart, one pair 24 minutes apart.
// America/Chicago tracks CST/CDT on its own, so this stays right through November.
const ctDay = t => new Date(t === undefined ? Date.now() : t)
.toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
const dayKeyNow = () => ctDay();
// scheduled window: a campaign serves only between its start (if set) and its end (if set)
const inWindow = (c, now) => (!c.starts || c.starts <= (now || Date.now())) && (!c.expires || c.expires > (now || Date.now()));
const SCHED_SQL = ' AND (starts IS NULL OR starts<=?) AND (expires IS NULL OR expires>?)';
// country tiers: a restricted campaign shows only to viewers whose tier is listed; an unknown
// country (no tier) never matches a restricted campaign. FIND_IN_SET('', geo) is 0, so the SQL agrees.
const geoOk = (c, tier) => !c.geo || (!!tier && String(c.geo).split(',').includes(String(tier)));
const GEO_SQL = " AND (geo IS NULL OR geo='' OR FIND_IN_SET(?, geo))";
async function bumpGeo(id, cc) {
if (!cc) return;
if (db.enabled()) { try { await db.q('INSERT INTO camp_geo (campaign_id,cc,n) VALUES (?,?,1) ON DUPLICATE KEY UPDATE n=n+1', [Number(id), cc]); } catch (e) {} return; }
J.db.geo = J.db.geo || {}; const k = id + ':' + cc; J.db.geo[k] = (J.db.geo[k] || 0) + 1;
}
async function geoFor(ids) {
const out = {}; const list = (ids || []).map(Number).filter(Boolean); if (!list.length) return out;
if (db.enabled()) {
const rows = await db.q('SELECT campaign_id, cc, n FROM camp_geo WHERE campaign_id IN (' + list.map(() => '?').join(',') + ')', list);
for (const r of rows) (out[r.campaign_id] = out[r.campaign_id] || []).push({ cc: r.cc, n: Number(r.n) });
} else {
for (const [k, n] of Object.entries(J.db.geo || {})) { const [id, cc] = k.split(':'); if (list.includes(Number(id))) (out[id] = out[id] || []).push({ cc, n }); }
}
for (const id of Object.keys(out)) out[id] = out[id].sort((a, b) => b.n - a.n).slice(0, 5);
return out;
}
// on-site views per UTC hour (last 7 days feed the by-hour chart)
const hourKey = () => { const d = new Date(); return { day: d.toISOString().slice(0, 10), hour: d.getUTCHours() }; };
async function bumpHour(id) {
const { day, hour } = hourKey();
if (db.enabled()) { try { await db.q('INSERT INTO camp_hours (campaign_id,day,hour,n) VALUES (?,?,?,1) ON DUPLICATE KEY UPDATE n=n+1', [Number(id), day, hour]); } catch (e) {} return; }
J.db.hours = J.db.hours || {};
const k = id + ':' + day + ':' + hour; J.db.hours[k] = (J.db.hours[k] || 0) + 1;
}
async function hoursFor(ids) {
const out = {}; const list = (ids || []).map(Number).filter(Boolean); if (!list.length) return out;
const since = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
for (const id of list) out[id] = new Array(24).fill(0);
if (db.enabled()) {
const rows = await db.q('SELECT campaign_id, hour, SUM(n) n FROM camp_hours WHERE campaign_id IN (' + list.map(() => '?').join(',') + ') AND day>=? GROUP BY campaign_id, hour', [...list, since]);
for (const r of rows) if (out[r.campaign_id]) out[r.campaign_id][r.hour] = Number(r.n);
return out;
}
for (const [k, n] of Object.entries(J.db.hours || {})) { const [id, day, hour] = k.split(':'); if (out[id] && day >= since) out[id][Number(hour)] += n; }
return out;
}
const capped = c => !!(c.dailyCap && c.dayKey === dayKeyNow() && (c.daySpent || 0) >= c.dailyCap);
const served = c => ({ id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id,
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null,
width: c.width || null, height: c.height || null });
// ---- JSON fallback ----
const J = {
db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] },
FILE: () => path.join(DATA_DIR, 'campaigns.json'),
load() {
try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {}
if (!this.db || this.db.v !== 1) this.db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
},
save() {
try {
const tmp = this.FILE() + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(this.db));
fs.renameSync(tmp, this.FILE());
} catch (e) { console.error('ads save failed', e.message); }
},
async unburned(memberId) {
let s = 0;
for (const b of this.db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount;
for (const c of this.db.campaigns) if (c.memberId === memberId) s += c.accrued || 0;
return s;
},
async committed(memberId) { // unspent budget of live campaigns: reserved, not spendable twice
let s = 0;
for (const c of this.db.campaigns) if (c.memberId === memberId && ['active', 'paused'].includes(c.status)) s += Math.max(0, (c.budget || 0) - (c.spent || 0) - (c.accrued || 0));
return s;
},
async create(owner, memberId, c) {
const row = Object.assign({ id: this.db.nextId++, owner, memberId, spent: 0, accrued: 0,
imps: 0, clicks: 0, batchImps: 0, status: 'active', created: Date.now() }, c);
this.db.campaigns.push(row);
this.save();
return pubC(row);
},
async list(owner) { return this.db.campaigns.filter(c => c.owner === owner).map(pubC); },
async setStatus(owner, id, status) {
const c = this.db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
if (!c) return { error: 'No such campaign.' };
c.status = status;
this.save();
return { ok: true, campaign: pubC(c) };
},
async countLoginSlots() { return this.db.campaigns.filter(c => c.type === 'login' && c.status === 'active' && !c.house && inWindow(c)).length; },
async serve(type, opts) {
const r = rates();
const ex = opts && opts.excludeEmail;
const w = opts && Number(opts.width), h = opts && Number(opts.height);
const tier = opts && opts.tier;
let pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active' && (!ex || c.owner !== ex) && !capped(c) && inWindow(c) && geoOk(c, tier)
&& (!w || (Number(c.width) === w && Number(c.height) === h)));
if (type === 'login' && pool.some(c => !c.house)) pool = pool.filter(c => !c.house); // members before the house on the sign-in screen
if (!pool.length) return null;
const c = pool[Math.floor(Math.random() * pool.length)];
c.imps += 1; bumpHour(c.id); bumpGeo(c.id, opts && opts.cc);
if (type === 'login') { c.lastShownDay = ctDay(); this.save(); } // a login day is only charged once it was shown
const b = batchFor(type, r);
if (b) {
c.batchImps += 1;
if (c.batchImps >= b.n) {
c.batchImps = 0;
// never charge past the budget: on-site batches and network reconciles can land in
// the same window, so the last batch is capped at what is left (any extra views are free)
const cr = Math.min(b.cr, Math.max(0, c.budget - c.spent - (c.accrued || 0)));
if (cr) {
bumpDayJ(c, cr);
// earned pool pays first (spec §8b); only the remainder burns on-chain
if (await spendEarned(c.owner, cr, { log: deliveryLog(c) })) {
c.spent += cr;
} else if (!c.memberId) {
c.status = 'out'; // earned-only advertiser ran dry: no chain pool to fall to
} else {
c.accrued = (c.accrued || 0) + cr; await creditLog(c.owner, -cr, deliveryLog(c, true));
if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
}
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
}
}
this.save();
return served(c);
},
async click(id) {
const c = this.db.campaigns.find(x => x.id === Number(id));
if (!c) return null;
c.clicks += 1;
this.save();
return c.targetUrl;
},
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
const c = this.db.campaigns.find(x => x.id === Number(id));
return c ? c.targetUrl : null;
},
async dailySweep() {
const r = rates();
const today = ctDay();
let n = 0;
for (const c of this.db.campaigns) {
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today || !inWindow(c)) continue;
if (c.expires) continue; // slot buy: paid in full up front, never metered daily
if (c.lastShownDay !== today) continue; // 2026-09-15: eleven login ads were sharing a handful of sign-ins and some paid for days with zero shows
c.lastDayCharged = today;
const fee = Math.min(r.loginCreditsPerDay, Math.max(0, c.budget - c.spent - (c.accrued || 0))); // never charge past the budget (Shift AI was charged 200 of 125)
if (!fee) { c.status = 'out'; continue; }
if (c.owner === HOUSE_OWNER) c.spent += fee; // house: cap only, no burn
else if (await spendEarned(c.owner, fee, { purchasedOnly: true, log: loginLog(c) })) c.spent += fee; // credited purchased money first
else { c.accrued = (c.accrued || 0) + fee; await creditLog(c.owner, -fee, loginLog(c, true)); }
if (c.accrued >= r.burnBatchMin) {
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
if (c.spent + c.accrued >= c.budget) c.status = 'out';
n += 1;
}
if (n) this.save();
return n;
},
async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); },
async markBurned(id, tx) {
const b = this.db.burnsPending.find(x => x.id === id);
if (!b) return { error: 'No such burn.' };
b.burnedTx = tx; b.burnedAt = Date.now();
this.save();
return { ok: true };
}
};
// ---- MySQL mode: guarded UPDATEs keep the serving path concurrency-safe ----
const rowC = r => ({ id: r.id, owner: r.owner_email, memberId: r.member_id, house: !!r.house, type: r.type, name: r.name,
targetUrl: r.target_url, imageUrl: r.image_url, title: r.title, body: r.body, budget: r.budget,
spent: r.spent, accrued: r.accrued, imps: r.imps, clicks: r.clicks, batchImps: r.batch_imps,
width: r.width || null, height: r.height || null, ctaLabel: r.cta_label || null,
nasAdId: r.nas_ad_id || null, nasServed: r.nas_served || 0, dripId: r.drip_id || null, dailyCap: r.daily_cap || null, daySpent: r.day_spent || 0, dayKey: r.day_key || null, geo: r.geo || null,
expires: r.expires ? Number(r.expires) : null, starts: r.starts ? Number(r.starts) : null,
status: r.status, created: Number(r.created) });
const D = {
async unburned(memberId) {
const a = await db.q('SELECT COALESCE(SUM(amount),0) s FROM burns WHERE member_id=? AND burned_tx IS NULL', [memberId]);
const b = await db.q('SELECT COALESCE(SUM(accrued),0) s FROM campaigns WHERE member_id=?', [memberId]);
return Number(a[0].s) + Number(b[0].s);
},
async committed(memberId) { // unspent budget of live campaigns: reserved, not spendable twice
const r = await db.q("SELECT COALESCE(SUM(GREATEST(budget-spent-accrued,0)),0) s FROM campaigns WHERE member_id=? AND status IN ('active','paused')", [memberId]);
return Number(r[0].s);
},
async create(owner, memberId, c) {
const r = await db.q(`INSERT INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,budget,created,cta_label,width,height,expires,starts,house,daily_cap,geo)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now(), c.ctaLabel || null, c.width || null, c.height || null, c.expires || null, c.starts || null, c.house ? 1 : 0, c.dailyCap || null, c.geo || null]);
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [r.insertId]);
return pubC(rowC(rows[0]));
},
async list(owner) {
const rows = await db.q('SELECT * FROM campaigns WHERE owner_email=? ORDER BY id DESC', [owner]);
return rows.map(r => pubC(rowC(r)));
},
async setStatus(owner, id, status) {
const r = await db.q('UPDATE campaigns SET status=? WHERE id=? AND owner_email=?', [status, Number(id), owner]);
if (!r.affectedRows) return { error: 'No such campaign.' };
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
return { ok: true, campaign: pubC(rowC(rows[0])) };
},
async countLoginSlots() { const r = await db.q("SELECT COUNT(*) n FROM campaigns WHERE type='login' AND status='active' AND house=0 AND (expires IS NULL OR expires>?)", [Date.now()]); return Number(r[0].n) || 0; },
async serve(type, opts) {
const r = rates();
const ex = (opts && opts.excludeEmail) || '';
const w = opts && Number(opts.width), h = opts && Number(opts.height);
const sz = w ? ' AND width=? AND height=?' : '';
const params = w ? [type, ex, w, h] : [type, ex];
const nowMs = Date.now(); params.push(nowMs, nowMs, String((opts && opts.tier) || ''));
const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' AND owner_email<>?' + sz + " AND (daily_cap IS NULL OR day_key IS NULL OR day_key<>'" + dayKeyNow() + "' OR day_spent<daily_cap)" + SCHED_SQL + GEO_SQL + (type === 'login' ? ' ORDER BY house ASC, RAND() LIMIT 1' : ' ORDER BY RAND() LIMIT 1'), params); // login: members before the house
if (!rows.length) return null;
const c = rowC(rows[0]);
await db.q('UPDATE campaigns SET imps=imps+1, batch_imps=batch_imps+1 WHERE id=?', [c.id]); bumpHour(c.id); bumpGeo(c.id, opts && opts.cc);
if (type === 'login') await db.q('UPDATE campaigns SET last_shown_day=? WHERE id=?', [ctDay(), c.id]); // a login day is only charged once it was shown
const b = batchFor(type, r);
if (b) {
// atomic batch rollover: only one worker wins the WHERE guard
const won = await db.q('UPDATE campaigns SET batch_imps=batch_imps-? WHERE id=? AND batch_imps>=?',
[b.n, c.id, b.n]);
if (won.affectedRows) {
// never charge past the budget (on-site batches and network reconciles can overlap)
const cr = Math.min(b.cr, Math.max(0, c.budget - (c.spent || 0) - (c.accrued || 0)));
if (cr) {
await bumpDayD(c.id, cr);
// earned pool pays first (spec §8b); only the remainder burns on-chain
if (await spendEarned(c.owner, cr, { log: deliveryLog(c) })) {
await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [cr, c.id]);
} else if (!c.memberId) {
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [c.id]); // earned-only ran dry
} else {
await creditLog(c.owner, -cr, deliveryLog(c, true));
await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [cr, c.id]);
await this.rollBurn(c.id, r);
}
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [c.id]);
}
}
return served(c);
},
async rollBurn(id, r) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [id]);
if (!rows.length) return;
const c = rowC(rows[0]);
if (c.owner === HOUSE_OWNER) { // house ads never burn: fold accrual into spent, no burn row
if (c.accrued > 0) await db.q('UPDATE campaigns SET spent=spent+accrued, accrued=0 WHERE id=?', [id]);
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active' AND spent+accrued>=budget", [id]);
return;
}
if (c.accrued >= r.burnBatchMin) {
const upd = await db.q('UPDATE campaigns SET spent=spent+?, accrued=accrued-? WHERE id=? AND accrued>=?',
[c.accrued, c.accrued, id, c.accrued]);
if (upd.affectedRows) {
await db.q('INSERT INTO burns (id,member_id,amount,ref,ts) VALUES (?,?,?,?,?)',
[bid(), c.memberId, c.accrued, 'campaign-' + id, Date.now()]);
}
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [id]);
},
async click(id) {
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return null;
await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]);
return rows[0].target_url;
},
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
return rows.length ? rows[0].target_url : null;
},
async dailySweep() {
const r = rates();
const today = ctDay();
const due = await db.q(`SELECT id, owner_email, budget, spent, accrued FROM campaigns WHERE type='login' AND status='active' AND expires IS NULL AND (last_day_charged IS NULL OR last_day_charged<>?) AND last_shown_day=?` + SCHED_SQL, [today, today, Date.now(), Date.now()]); // only days the ad was actually shown (2026-09-15)
let n = 0;
for (const row of due) {
const fee = Math.min(r.loginCreditsPerDay, Math.max(0, Number(row.budget) - Number(row.spent || 0) - Number(row.accrued || 0))); // never charge past the budget
if (!fee) { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [row.id]); continue; }
// credited purchased money (refunds, comps) pays first; otherwise accrue toward an on-chain burn
const fromPool = row.owner_email !== HOUSE_OWNER && await spendEarned(row.owner_email, fee, { purchasedOnly: true, log: loginLog(row) });
const upd = fromPool
? await db.q('UPDATE campaigns SET last_day_charged=?, spent=spent+? WHERE id=? AND (last_day_charged IS NULL OR last_day_charged<>?)', [today, fee, row.id, today])
: await db.q('UPDATE campaigns SET last_day_charged=?, accrued=accrued+? WHERE id=? AND (last_day_charged IS NULL OR last_day_charged<>?)', [today, fee, row.id, today]);
if (!upd.affectedRows) continue;
if (!fromPool && row.owner_email !== HOUSE_OWNER) await creditLog(row.owner_email, -fee, loginLog(row, true));
n += 1;
if (fromPool) await db.q("UPDATE campaigns SET status='out' WHERE id=? AND spent+accrued>=budget", [row.id]);
else await this.rollBurn(row.id, r);
}
return n;
},
async pendingBurns() {
const rows = await db.q('SELECT * FROM burns WHERE burned_tx IS NULL ORDER BY ts');
return rows.map(b => ({ id: b.id, memberId: b.member_id, amount: b.amount, ref: b.ref, ts: Number(b.ts) }));
},
async markBurned(id, tx) {
const r = await db.q('UPDATE burns SET burned_tx=?, burned_at=? WHERE id=? AND burned_tx IS NULL', [String(tx), Date.now(), String(id)]);
return r.affectedRows ? { ok: true } : { error: 'No such burn.' };
}
};
// ---- earned/welcome credits (spec §8b second pool: engine-side, never
// on-chain; welcome grant is idempotent and lazy so existing accounts get
// theirs on next dashboard load) ----
const EJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'earned.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
// ---- credit activity ledger (2026-09-16, Hugh: "I earned 10 but it shows 0"): every real
// movement of a member's credits, with a reason. Metered delivery rolls up per campaign per day.
const CLJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'credit-log.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { items: [] }; } if (!this.db.items) this.db.items = []; },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
async function creditLog(email, delta, log) {
const e = String(email || '').toLowerCase(); const d = Math.round(Number(delta) || 0);
if (!e || !d || e === HOUSE_OWNER) return;
const L = log || {};
const kind = String(L.kind || (d > 0 ? 'earn' : 'spend')).slice(0, 20);
const note = (String(L.note || (d > 0 ? 'Credits added' : 'Campaign spend')) + (L.chain ? ' (purchased credits)' : '')).slice(0, 160);
const ref = L.ref != null ? String(L.ref).slice(0, 40) : null; const day = today(); const ts = Date.now();
try {
if (db.enabled()) {
if (L.rollup && ref) { const u = await db.q('UPDATE credit_log SET delta=delta+?, ts=? WHERE email=? AND kind=? AND ref=? AND day=? AND note=?', [d, ts, e, kind, ref, day, note]); if (u.affectedRows) return; }
await db.q('INSERT INTO credit_log (email,ts,day,delta,kind,note,ref) VALUES (?,?,?,?,?,?,?)', [e, ts, day, d, kind, note, ref]);
return;
}
if (!CLJ.db) CLJ.load();
if (L.rollup && ref) { const x = CLJ.db.items.find(i => i.email === e && i.kind === kind && i.ref === ref && i.day === day && i.note === note); if (x) { x.delta += d; x.ts = ts; CLJ.save(); return; } }
CLJ.db.items.push({ email: e, ts, day, delta: d, kind, note, ref });
if (CLJ.db.items.length > 20000) CLJ.db.items.splice(0, CLJ.db.items.length - 20000);
CLJ.save();
} catch (er) { console.error('credit log', er.message); }
}
async function creditActivity(email, limit) {
const e = String(email || '').toLowerCase(); const n = Math.min(200, Math.max(1, Number(limit) || 40));
if (db.enabled()) return (await db.q('SELECT ts, delta, kind, note, ref FROM credit_log WHERE email=? ORDER BY ts DESC, id DESC LIMIT ?', [e, n])).map(r => ({ ts: Number(r.ts), delta: Number(r.delta), kind: r.kind, note: r.note, ref: r.ref }));
if (!CLJ.db) CLJ.load();
return CLJ.db.items.filter(i => i.email === e).sort((a, b) => b.ts - a.ts).slice(0, n).map(i => ({ ts: i.ts, delta: i.delta, kind: i.kind, note: i.note, ref: i.ref }));
}
const campName = c => (c && (c.name || ('#' + c.id))) || '';
function deliveryLog(c, chain) { return { kind: 'delivery', rollup: true, ref: c.id, chain: !!chain, note: ({ banner: 'Ad views', text: 'Ad views', solo: 'Inbox deliveries', video: 'Video views' }[c.type] || 'Delivery') + ': ' + campName(c) }; }
function loginLog(c, chain) { return { kind: 'login', ref: c.id, chain: !!chain, note: 'Login ad day: ' + campName(c) }; }
function chargeLog(c, why, chain) { return why ? { kind: 'buy', ref: c.id, chain: !!chain, note: why } : deliveryLog(c, chain); }
async function earnedBalance(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
if (db.enabled()) {
const r = await db.q('SELECT balance FROM earned_credits WHERE email=?', [e]);
return r.length ? r[0].balance : 0;
}
if (!EJ.db) EJ.load();
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
async function welcomeGranted(email) { // read-only check, never grants
const e = String(email || '').toLowerCase();
if (!e) return true;
if (db.enabled()) {
const r = await db.q('SELECT granted_welcome FROM earned_credits WHERE email=?', [e]);
return r.length ? !!r[0].granted_welcome : false;
}
if (!EJ.db) EJ.load();
return !!(EJ.db[e] && EJ.db[e].welcomed);
}
// idempotent milestone bonuses: grant the credit bonus for each newly-reached
// milestone once. `reached` is the list of currently-true milestone keys.
// Returns [{ key, credited }] for any that were newly granted this call.
// which milestone badges this member has been granted (payouts, firstBuyer, level2, level3)
async function milestonesOf(email) {
const e = String(email || '').toLowerCase(); if (!e) return [];
if (db.enabled()) { const r = await db.q('SELECT milestones FROM earned_credits WHERE email=?', [e]); return (r.length && r[0].milestones ? r[0].milestones : '').split(',').filter(Boolean); }
if (!EJ.db) EJ.load(); return (EJ.db[e] && EJ.db[e].milestones) || [];
}
async function grantMilestones(email, reached) {
const e = String(email || '').toLowerCase();
if (!e || !reached || !reached.length) return [];
const bonus = rates().milestoneBonus || {};
let have = [];
if (db.enabled()) {
const r = await db.q('SELECT milestones FROM earned_credits WHERE email=?', [e]);
have = (r.length && r[0].milestones ? r[0].milestones : '').split(',').filter(Boolean);
} else {
if (!EJ.db) EJ.load();
have = (EJ.db[e] && EJ.db[e].milestones) || [];
}
const granted = [];
for (const key of reached) {
if (have.includes(key) || !(bonus[key] > 0)) continue;
await addEarned(e, bonus[key], { log: { kind: 'bonus', note: 'Milestone bonus: ' + ({ payouts: 'payouts switched on', firstBuyer: 'first qualifying buyer', level2: 'level 2 unlocked', level3: 'level 3 unlocked' }[key] || key) } });
have.push(key);
granted.push({ key, credited: bonus[key] });
}
if (granted.length) {
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated,milestones) VALUES (?,0,0,?,?)
ON DUPLICATE KEY UPDATE milestones=VALUES(milestones), updated=VALUES(updated)`, [e, Date.now(), have.join(',')]);
} else {
if (!EJ.db) EJ.load();
EJ.db[e] = EJ.db[e] || { balance: 0 };
EJ.db[e].milestones = have;
EJ.save();
}
}
return granted;
}
async function grantWelcome(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
const amount = rates().welcomeCredits || 0;
if (db.enabled()) {
const was = await db.q('SELECT granted_welcome g FROM earned_credits WHERE email=?', [e]);
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,1,?)
ON DUPLICATE KEY UPDATE balance = balance + IF(granted_welcome=0, ?, 0),
granted_welcome = 1, updated = VALUES(updated)`, [e, amount, Date.now(), amount]);
if (amount && (!was.length || !was[0].g)) await creditLog(e, amount, { kind: 'bonus', note: 'Welcome credits' });
return earnedBalance(e);
}
if (!EJ.db) EJ.load();
if (!EJ.db[e] || !EJ.db[e].welcomed) {
EJ.db[e] = { balance: ((EJ.db[e] && EJ.db[e].balance) || 0) + amount, welcomed: true };
EJ.save();
if (amount) await creditLog(e, amount, { kind: 'bonus', note: 'Welcome credits' });
}
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
// spend from the earned pool first (banner/text only); true = fully covered
// The engine-side pool has two grades. `viewing` credits were earned by attention
// (welcome, daily sets, milestones) and fund the baseline formats. `purchase_grade`
// is refunded or credited PURCHASED money (a burner mistake, a comp): it shows as
// purchased on the dashboard and can fund anything, login ads included. Viewing
// credits always spend first so the purchased-grade part is the last to go.
async function earnedSplit(email) {
const e = String(email || '').toLowerCase();
if (!e) return { total: 0, grade: 0, viewing: 0 };
let total = 0, grade = 0;
if (db.enabled()) {
const r = await db.q('SELECT balance, purchase_grade FROM earned_credits WHERE email=?', [e]);
if (r.length) { total = r[0].balance; grade = r[0].purchase_grade || 0; }
} else {
if (!EJ.db) EJ.load();
const rec = EJ.db[e]; if (rec) { total = rec.balance || 0; grade = rec.grade || 0; }
}
grade = Math.max(0, Math.min(grade, total));
return { total, grade, viewing: total - grade };
}
// opts.purchasedOnly: take the amount from the purchased-grade part only (login ads)
async function spendEarned(email, amount, opts) {
const e = String(email || '').toLowerCase();
if (!e || !amount) return false;
if (e === HOUSE_OWNER) return true; // house ads: bottomless pool, nobody is charged
const po = !!(opts && opts.purchasedOnly);
if (db.enabled()) {
const r = po
? await db.q('UPDATE earned_credits SET balance=balance-?, purchase_grade=purchase_grade-? WHERE email=? AND purchase_grade>=? AND balance>=?', [amount, amount, e, amount, amount])
: await db.q('UPDATE earned_credits SET purchase_grade=LEAST(purchase_grade, balance-?), balance=balance-? WHERE email=? AND balance>=?', [amount, amount, e, amount]);
if (r.affectedRows) await creditLog(e, -amount, opts && opts.log);
return !!r.affectedRows;
}
if (!EJ.db) EJ.load();
const rec = EJ.db[e];
if (!rec || rec.balance < amount) return false;
if (po && (rec.grade || 0) < amount) return false;
rec.balance -= amount;
rec.grade = po ? (rec.grade || 0) - amount : Math.min(rec.grade || 0, rec.balance);
EJ.save();
await creditLog(e, -amount, opts && opts.log);
return true;
}
// opts.purchased: credit as purchased-grade (refunds, comps) instead of viewing credits
async function addEarned(email, amount, opts) {
const e = String(email || '').toLowerCase();
const g = (opts && opts.purchased) ? amount : 0;
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,purchase_grade,granted_welcome,updated) VALUES (?,?,?,0,?)
ON DUPLICATE KEY UPDATE balance=balance+VALUES(balance), purchase_grade=purchase_grade+VALUES(purchase_grade), updated=VALUES(updated)`, [e, amount, g, Date.now()]);
} else {
if (!EJ.db) EJ.load();
EJ.db[e] = EJ.db[e] || { balance: 0 };
EJ.db[e].balance += amount;
if (g) EJ.db[e].grade = (EJ.db[e].grade || 0) + g;
EJ.save();
}
await creditLog(e, amount, (opts && opts.log) || { kind: g ? 'credit' : 'earn', note: g ? 'Credited to you' : 'Credits earned' });
}
// ---- onsite solo ads: full-message ads delivered into member inboxes,
// charged per guaranteed delivery; readers earn credits for dwelled reads ----
const SJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'inbox.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
// lazy guaranteed delivery: whenever a member touches their inbox (or the
// dashboard asks for their unread count), pending solos fill toward their
// recipient guarantee — never the sender's own, never twice to one member,
// and the advertiser is charged per delivery through the same earned-first
// then burn-accrual path every other format uses
async function deliverSolos(email, max = 3, viewer) {
const tier = viewer && viewer.tier, cc = viewer && viewer.cc;
const e = String(email || '').toLowerCase();
if (!e) return 0;
const r = rates();
const cost = r.soloCostPerRecipient || 5;
let n = 0;
if (db.enabled()) {
const rows = await db.q(`SELECT c.* FROM campaigns c
WHERE c.type='solo' AND c.status='active' AND c.owner_email<>?
AND c.budget - c.spent - c.accrued >= ?
AND NOT EXISTS (SELECT 1 FROM solo_inbox s WHERE s.campaign_id=c.id AND s.email=?)
AND (c.starts IS NULL OR c.starts<=?) AND (c.expires IS NULL OR c.expires>?)
AND (c.geo IS NULL OR c.geo='' OR FIND_IN_SET(?, c.geo))
ORDER BY c.created LIMIT ?`, [e, cost, e, Date.now(), Date.now(), String(tier || ''), max]);
for (const row of rows) {
try { await db.q('INSERT INTO solo_inbox (campaign_id,email,delivered) VALUES (?,?,?)', [row.id, e, Date.now()]); }
catch (er) { continue; } // unique key lost a race: already delivered
if (await spendEarned(row.owner_email, cost, { log: deliveryLog(row) })) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [cost, row.id]);
} else if (row.member_id) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [cost, row.id]); await creditLog(row.owner_email, -cost, deliveryLog(row, true));
await D.rollBurn(row.id, r);
} else { // earned-only advertiser ran dry: undo the delivery, close the campaign
await db.q('DELETE FROM solo_inbox WHERE campaign_id=? AND email=?', [row.id, e]);
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [row.id]);
continue;
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [row.id]);
bumpHour(row.id); bumpGeo(row.id, cc);
n++;
}
} else {
if (!SJ.db) SJ.load();
const have = new Set(SJ.db.items.filter(i => i.email === e).map(i => i.cid));
for (const c of J.db.campaigns) {
if (n >= max) break;
if (c.type !== 'solo' || c.status !== 'active' || c.owner === e || have.has(c.id) || !inWindow(c) || !geoOk(c, tier)) continue;
if (c.budget - c.spent - (c.accrued || 0) < cost) continue;
if (await spendEarned(c.owner, cost, { log: deliveryLog(c) })) c.spent += cost;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + cost; await creditLog(c.owner, -cost, deliveryLog(c, true));
if (c.accrued >= r.burnBatchMin) {
J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
} else { c.status = 'out'; continue; } // earned-only ran dry
c.imps += 1; bumpHour(c.id); bumpGeo(c.id, cc);
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
SJ.db.items.push({ id: SJ.db.nextId++, cid: c.id, email: e, delivered: Date.now(), readTs: 0, rewarded: 0, rewardedDay: null });
n++;
}
if (n) { J.save(); SJ.save(); }
}
return n;
}
async function inboxList(email, viewer) {
const e = String(email || '').toLowerCase();
await deliverSolos(e, 3, viewer);
const r = rates();
let items = [];
if (db.enabled()) {
const rows = await db.q(`SELECT s.id, s.campaign_id cid, s.delivered, s.read_ts, s.rewarded,
c.title, c.member_id mid FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id
WHERE s.email=? ORDER BY s.delivered DESC LIMIT 100`, [e]);
items = rows.map(x => ({ id: x.id, cid: x.cid, subject: x.title, fromMemberId: x.mid,
delivered: Number(x.delivered), read: !!x.read_ts, rewarded: !!x.rewarded }));
} else {
if (!SJ.db) SJ.load();
items = SJ.db.items.filter(i => i.email === e).sort((a, b) => b.delivered - a.delivered).slice(0, 100)
.map(i => {
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, fromMemberId: c.memberId || 0,
delivered: i.delivered, read: !!i.readTs, rewarded: !!i.rewarded };
});
}
return { items, unread: items.filter(i => !i.read).length,
readCredits: r.soloReadCredits || 2, readDwell: r.soloReadDwellSeconds || 10, readCap: r.soloReadCapPerDay || 5 };
}
async function inboxOpen(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const rows = await db.q(`SELECT s.*, c.title, c.body, c.member_id mid, c.image_url media, c.cta_label cta
FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id WHERE s.id=? AND s.email=?`, [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (!x.read_ts) await db.q('UPDATE solo_inbox SET read_ts=? WHERE id=? AND read_ts IS NULL', [Date.now(), x.id]);
return { id: x.id, cid: x.campaign_id, subject: x.title, body: x.body || '', fromMemberId: x.mid,
mediaUrl: x.media || null, mediaType: x.media ? (/\.(mp4|webm)$/.test(x.media) ? 'video' : 'image') : null,
ctaLabel: x.cta || null, visited: !!x.visited_ts,
url: '/api/ads/click/' + x.campaign_id, delivered: Number(x.delivered),
rewarded: !!x.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.readTs) { i.readTs = Date.now(); SJ.save(); }
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, body: c.body || '', fromMemberId: c.memberId || 0,
mediaUrl: c.imageUrl || null, mediaType: c.imageUrl ? (/\.(mp4|webm)$/.test(c.imageUrl) ? 'video' : 'image') : null,
ctaLabel: c.ctaLabel || null, visited: !!i.visitedTs,
url: '/api/ads/click/' + i.cid, delivered: i.delivered,
rewarded: !!i.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
// the reader clicked the CTA and the click relay opened the advertiser: record
// it so the read reward requires an actual visit, not just sitting on the page
async function markSoloVisit(email, id) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
const r = await db.q('UPDATE solo_inbox SET visited_ts=? WHERE id=? AND email=? AND visited_ts IS NULL', [Date.now(), Number(id), e]);
return { ok: true, changed: !!r.affectedRows };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.visitedTs) { i.visitedTs = Date.now(); SJ.save(); }
return { ok: true };
}
async function claimSoloRead(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
const dwellMs = (r.soloReadDwellSeconds || 10) * 1000;
const cap = r.soloReadCapPerDay || 5;
const reward = r.soloReadCredits || 2;
const day = today();
if (db.enabled()) {
const rows = await db.q('SELECT * FROM solo_inbox WHERE id=? AND email=?', [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (x.rewarded) return { error: 'Already claimed for this one.' };
if (!x.read_ts || Date.now() - Number(x.read_ts) < dwellMs - 400) return { error: 'Give it a real read first.' };
if (!x.visited_ts) return { error: 'Click through to the advertiser first — that visit is what earns the credits.' };
const cnt = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND rewarded=1 AND rewarded_day=?', [e, day]);
if (cnt[0].n >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
const upd = await db.q('UPDATE solo_inbox SET rewarded=1, rewarded_day=? WHERE id=? AND rewarded=0', [day, x.id]);
if (!upd.affectedRows) return { error: 'Already claimed for this one.' };
await addEarned(e, reward, { log: { kind: 'earn', note: 'Read an inbox message' } });
return { ok: true, credited: reward };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (i.rewarded) return { error: 'Already claimed for this one.' };
if (!i.readTs || Date.now() - i.readTs < dwellMs - 400) return { error: 'Give it a real read first.' };
if (!i.visitedTs) return { error: 'Click through to the advertiser first — that visit is what earns the credits.' };
const nToday = SJ.db.items.filter(x => x.email === e && x.rewarded && x.rewardedDay === day).length;
if (nToday >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
i.rewarded = 1;
i.rewardedDay = day;
SJ.save();
await addEarned(e, reward, { log: { kind: 'earn', note: 'Read an inbox message' } });
return { ok: true, credited: reward };
}
async function unreadCount(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
await deliverSolos(e);
if (db.enabled()) {
const r = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND read_ts IS NULL', [e]);
return r[0].n;
}
if (!SJ.db) SJ.load();
return SJ.db.items.filter(i => i.email === e && !i.readTs).length;
}
// ---- attention-gated daily claim (view N real ads -> claim earned credits) ----
const VJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'dailyviews.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
const today = () => ctDay();
// Claim streak (Marty, 2026-09-12): consecutive days the daily set was claimed. Day 1 pays the
// base claim, day 2 pays 7, day 3+ pays 10, and every 7th day pays 25. Rewards coming back.
function claimBonus(day) {
const base = rates().dailyClaimCredits || 5;
if (day >= 7 && day % 7 === 0) return 25;
if (day >= 3) return 10;
if (day === 2) return Math.max(base, 7);
return base;
}
// consecutive claimed days ending YESTERDAY (today is handled by the caller)
async function claimStreakBefore(email) {
const e = String(email || '').toLowerCase();
if (!db.enabled()) return 0;
const since = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10);
const rows = await db.q('SELECT day FROM daily_views WHERE email=? AND claimed=1 AND day>=? AND day<? ORDER BY day DESC', [e, since, today()]);
let n = 0; let expect = new Date(Date.now() - 86400000);
for (const r of rows) {
if (String(r.day) !== ctDay(expect)) break;
n++; expect = new Date(expect.getTime() - 86400000);
}
return n;
}
async function viewStatus(email) {
const e = String(email || '').toLowerCase();
const r = rates();
let views = 0, claimed = false;
if (db.enabled()) {
const rows = await db.q('SELECT views, claimed FROM daily_views WHERE email=? AND day=?', [e, today()]);
if (rows.length) { views = rows[0].views; claimed = !!rows[0].claimed; }
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (rec && rec.day === today()) { views = rec.views; claimed = !!rec.claimed; }
}
const sp = await earnedSplit(e);
const earned = sp.viewing;
const reserved = Math.min(earned, (await liveBudgets(e)).filter(c => c.type !== 'login').reduce((n, c) => n + c.left, 0));
const before = await claimStreakBefore(e);
const streakDay = claimed ? before + 1 : before + 1; // the day number today's claim is / would be
let visitsLeft = 0; try { const vs = await visitStatus(e); visitsLeft = Math.max(0, vs.cap - vs.count); } catch (err) {}
return { views, target: r.dailyViewTarget, claimCredits: claimBonus(streakDay), streakDay, nextClaim: claimBonus(streakDay + 1), visitsLeft,
dwell: r.viewDwellSeconds, claimed, earned, earnedAvailable: earned - reserved, reserved, credited: sp.grade };
}
async function recordView(email) {
const e = String(email || '').toLowerCase();
const r = rates();
const minGap = Math.max(2, r.viewDwellSeconds - 1) * 1000;
const now = Date.now();
if (db.enabled()) {
await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts) VALUES (?,?,0,0,0)
ON DUPLICATE KEY UPDATE email=email`, [e, today()]);
const upd = await db.q(`UPDATE daily_views SET views=views+1, last_ts=? WHERE email=? AND day=? AND views<? AND last_ts<=?`,
[now, e, today(), r.dailyViewTarget, now - minGap]);
if (!upd.affectedRows) return Object.assign(await viewStatus(e), { tooFast: true });
} else {
if (!VJ.db) VJ.load();
let rec = VJ.db[e];
if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0 };
if (rec.views >= r.dailyViewTarget || now - rec.last < minGap) return Object.assign(await viewStatus(e), { tooFast: true });
rec.views += 1; rec.last = now;
VJ.save();
}
return viewStatus(e);
}
// ---- watch-to-earn video ads: serve a video, enforce watch on server clock,
// charge the advertiser the tier cost per completed watch, credit the viewer ----
function videoTierFor(secs) { return (rates().videoTiers || []).find(t => t.secs === Number(secs)); }
function servedVideo(c) {
const t = videoTierFor(c.body) || { secs: 10, reward: 1 };
return { id: c.id, videoUrl: c.imageUrl, title: c.title || null, ctaUrl: '/api/ads/click/' + c.id,
ctaLabel: c.ctaLabel || null, watchSecs: t.secs, reward: t.reward,
width: c.width || null, height: c.height || null };
}
// orientation: 'portrait' (Shorts reel) = height>width; 'landscape' (Watch videos) = anything else,
// INCLUDING unknown dimensions so legacy/undetected videos still play in the standard tab.
async function serveVideo(opts) {
const ex = (opts && opts.excludeEmail) || '';
const ori = (opts && opts.orientation) || '';
if (db.enabled()) {
let oc = '';
if (ori === 'portrait') oc = ' AND width IS NOT NULL AND height IS NOT NULL AND height>width';
else if (ori === 'landscape') oc = ' AND (width IS NULL OR height IS NULL OR width>=height)';
const params = [ex];
let seen = '';
if (ex && !(opts && opts.ignoreSeen)) { seen = ' AND id NOT IN (SELECT campaign_id FROM video_seen WHERE email=? AND day=?)'; params.push(ex, today()); }
params.push(Date.now(), Date.now(), String((opts && opts.tier) || ''));
const rows = await db.q(`SELECT * FROM campaigns WHERE type='video' AND status='active'
AND owner_email<>? AND spent+accrued<budget` + oc + seen + SCHED_SQL + GEO_SQL + ` ORDER BY RAND() LIMIT 1`, params);
if (rows.length) bumpGeo(rows[0].id, opts && opts.cc);
return rows.length ? servedVideo(rowC(rows[0])) : null;
}
const portrait = c => c.width && c.height && c.height > c.width;
const oriOk = c => ori === 'portrait' ? portrait(c) : ori === 'landscape' ? !portrait(c) : true;
if (!VJ.db) VJ.load();
const seenIds = (ex && !(opts && opts.ignoreSeen) && VJ.db[ex] && VJ.db[ex].day === today()) ? (VJ.db[ex].videosSeen || []) : [];
const pool = J.db.campaigns.filter(c => c.type === 'video' && c.status === 'active' && inWindow(c) && geoOk(c, opts && opts.tier)
&& (!ex || c.owner !== ex) && (c.spent + (c.accrued || 0) < c.budget) && oriOk(c) && !seenIds.includes(c.id));
const pick = pool.length ? pool[Math.floor(Math.random() * pool.length)] : null;
if (pick) bumpGeo(pick.id, opts && opts.cc);
return pick ? servedVideo(pick) : null;
}
async function chargeVideoView(id) {
const r = rates();
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE id=? AND type='video' AND status='active'", [Number(id)]);
if (!rows.length) return null;
const c = rowC(rows[0]); const t = videoTierFor(c.body); if (!t) return null;
if (await spendEarned(c.owner, t.cost, { log: deliveryLog(c) })) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [t.cost, c.id]);
} else if (c.memberId) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [t.cost, c.id]); await creditLog(c.owner, -t.cost, deliveryLog(c, true));
await D.rollBurn(c.id, r);
} else { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [c.id]); return null; }
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active' AND spent+accrued>=budget", [c.id]);
bumpHour(c.id);
return t;
}
const c = J.db.campaigns.find(x => x.id === Number(id) && x.type === 'video');
if (!c || c.status !== 'active') return null;
const t = videoTierFor(c.body); if (!t) return null;
if (await spendEarned(c.owner, t.cost, { log: deliveryLog(c) })) { c.spent += t.cost; }
else if (c.memberId) {
c.accrued = (c.accrued || 0) + t.cost; await creditLog(c.owner, -t.cost, deliveryLog(c, true));
if (c.accrued >= r.burnBatchMin) {
J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
} else { c.status = 'out'; J.save(); return null; }
c.imps += 1; bumpHour(c.id);
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
J.save();
return t;
}
async function videoStatus(email) {
const e = String(email || '').toLowerCase(); const r = rates(); const cap = r.videoWatchCapPerDay || 8;
let count = 0;
if (db.enabled()) {
const rows = await db.q('SELECT video_count FROM daily_views WHERE email=? AND day=?', [e, today()]);
if (rows.length) count = rows[0].video_count || 0;
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (rec && rec.day === today()) count = rec.videoCount || 0;
}
return { count, cap, left: Math.max(0, cap - count) };
}
async function recordVideoWatch(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) {
await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts,video_count) VALUES (?,?,0,0,0,1)
ON DUPLICATE KEY UPDATE video_count=video_count+1`, [e, today()]);
} else {
if (!VJ.db) VJ.load();
let rec = VJ.db[e];
if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0, videoCount: 0 };
rec.videoCount = (rec.videoCount || 0) + 1;
VJ.save();
}
return videoStatus(e);
}
// once-per-day-per-video dedup so a member can't re-earn from the same video today
async function markVideoSeen(email, campaignId) {
const e = String(email || '').toLowerCase(), id = Number(campaignId) || 0;
if (db.enabled()) { try { await db.q('INSERT IGNORE INTO video_seen (email,campaign_id,day,ts) VALUES (?,?,?,?)', [e, id, today(), Date.now()]); } catch (x) {} return; }
if (!VJ.db) VJ.load();
let rec = VJ.db[e]; if (!rec || rec.day !== today()) rec = VJ.db[e] = { day: today(), views: 0, claimed: false, last: 0, videoCount: 0 };
rec.videosSeen = rec.videosSeen || []; if (!rec.videosSeen.includes(id)) rec.videosSeen.push(id); VJ.save();
}
async function hasWatchedVideoToday(email, campaignId) {
const e = String(email || '').toLowerCase(), id = Number(campaignId) || 0;
if (db.enabled()) { const r = await db.q('SELECT 1 FROM video_seen WHERE email=? AND campaign_id=? AND day=? LIMIT 1', [e, id, today()]); return r.length > 0; }
if (!VJ.db) VJ.load();
const rec = VJ.db[e]; return !!(rec && rec.day === today() && (rec.videosSeen || []).includes(id));
}
async function claimDaily(email) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const upd = await db.q('UPDATE daily_views SET claimed=1 WHERE email=? AND day=? AND claimed=0 AND views>=?',
[e, today(), r.dailyViewTarget]);
if (!upd.affectedRows) return { error: 'View todays ads first, then claim.' };
} else {
if (!VJ.db) VJ.load();
const rec = VJ.db[e];
if (!rec || rec.day !== today() || rec.claimed || rec.views < r.dailyViewTarget)
return { error: 'View todays ads first, then claim.' };
rec.claimed = true;
VJ.save();
}
const day = (await claimStreakBefore(e)) + 1;
const amount = claimBonus(day);
await addEarned(e, amount, { log: { kind: 'earn', note: 'Daily claim (streak day ' + day + ')' } });
return { ok: true, credited: amount, streakDay: day, nextClaim: claimBonus(day + 1), status: await viewStatus(e) };
}
const impl = () => db.enabled() ? D : J;
// Country-tier lists, injected rather than imported: the lists are admin-editable site config,
// which lives in server.js. Only the DripOffers rail needs them (it is the one syndication
// target that can carry a geo-restricted campaign faithfully).
let tiersOf = null;
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; tiersOf = opts.tiers || null; J.load(); }
const geoTiers = () => { try { return tiersOf ? tiersOf() : null; } catch (e) { return null; } };
async function availableCredits(memberId) {
const onchain = await chain.creditBalance(memberId, 0);
// on-chain balance, minus spend not yet burned, minus budget already promised to live campaigns
return Math.max(0, onchain - await impl().unburned(memberId) - await impl().committed(memberId));
}
// budget still to deliver on an owner's live campaigns. This is what the dashboard
// shows as "in campaigns" and holds back from the balances up front, so a balance
// only moves when a campaign is created, topped up or paused, never per impression
async function liveBudgets(owner) {
const o = String(owner || '').toLowerCase();
if (!o) return [];
const rows = db.enabled()
? (await db.q("SELECT id, member_id, type, GREATEST(budget-spent-accrued,0) lft FROM campaigns WHERE owner_email=? AND status IN ('active','paused')", [o]))
.map(r => ({ id: r.id, memberId: r.member_id || 0, type: r.type, left: Number(r.lft) }))
: J.db.campaigns.filter(c => c.owner === o && ['active', 'paused'].includes(c.status))
.map(c => ({ id: c.id, memberId: c.memberId || 0, type: c.type, left: Math.max(0, (c.budget || 0) - (c.spent || 0) - (c.accrued || 0)) }));
return rows.filter(r => r.left > 0);
}
// the balances a member sees. One rule, stated on the dashboard: a balance is
// what is NOT committed to a live campaign. Live budgets are reserved from the
// earned pool first, then from purchased credits (the same order delivery spends
// in, so the two numbers never move against each other while an ad serves).
// `per` = purchased credits free on each on-chain member the account controls
// (main + linked positions); `best` = the one a new campaign is charged to
// (each campaign burns from exactly one member id, so a budget has to fit inside one)
async function balances(ids, owner) {
const list = [...new Set((ids || []).filter(Boolean))];
const live = await liveBudgets(owner);
const sp = owner ? await earnedSplit(owner) : { total: 0, grade: 0, viewing: 0 };
let cover = sp.viewing, grade = sp.grade;
const coverBy = {}; // how much of each member's committed budget the engine-side pool already covers
// login budgets draw on credited purchased money first
for (const c of live.filter(c => c.type === 'login')) { const y = Math.min(grade, c.left); grade -= y; coverBy[c.memberId] = (coverBy[c.memberId] || 0) + y; }
// everything else: viewing credits first, then credited purchased money; campaigns with no position first (they have no other source)
for (const c of live.filter(c => c.type !== 'login').sort((a, b) => (a.memberId ? 1 : 0) - (b.memberId ? 1 : 0))) {
const x = Math.min(cover, c.left); cover -= x;
const y = Math.min(grade, c.left - x); grade -= y;
coverBy[c.memberId] = (coverBy[c.memberId] || 0) + x + y;
}
const per = [];
for (const id of list) {
let avail = 0;
try {
const onchain = await chain.creditBalance(id, 0);
const comm = owner ? live.filter(c => c.memberId === id).reduce((n, c) => n + c.left, 0) : await impl().committed(id);
avail = Math.max(0, onchain - await impl().unburned(id) - Math.max(0, comm - (coverBy[id] || 0)));
} catch (e) {}
per.push({ memberId: id, avail });
}
const onchainTotal = per.reduce((n, p) => n + p.avail, 0);
const best = per.slice().sort((a, b) => b.avail - a.avail)[0] || { memberId: 0, avail: 0 };
const inCampaigns = live.reduce((n, c) => n + c.left, 0);
// `credited` = purchased-grade pool still free: counts as purchased, spends from any position
return { total: onchainTotal + grade, onchain: onchainTotal, credited: grade, best, per, earned: cover, earnedRaw: sp.total,
earnedReserved: sp.total - cover - grade, inCampaigns, available: onchainTotal + grade + cover, live };
}
const pooledCredits = balances;
async function createCampaign(owner, memberId, input, ids) {
const v = validate(input);
if (v.error) return v;
const bal = await balances(ids && ids.length ? ids : [memberId], owner);
const purchased = (bal.per.find(p => p.memberId === memberId) || { avail: 0 }).avail + bal.credited;
// Login slots are capped (Marty, 2026-09-19): every live slot shares the same sign-ins, so a
// fourth slot only thins the other three. Member slots only; house ads step aside anyway.
if (v.c.type === 'login') {
const max = rates().loginSlotsMax || 3;
const live = await impl().countLoginSlots();
if (live >= max) return { error: 'All ' + max + ' login-ad slots are taken right now. A slot frees up when a running one ends its 30 days; check back in a few days.' };
}
const earned = v.c.type !== 'login' ? bal.earned : 0; // viewing credits: baseline formats only, and only the part not already in a campaign
const avail = purchased + earned;
if (v.c.type === 'login' && v.c.budget > avail) { if (bal.earned > 0) return { error: 'Login ads spend purchased credits only, and you have ' + purchased + ' purchased not already in a campaign. Your ' + bal.earned + ' earned credits can fund banner, text, solo, video, featured and verified-visit campaigns.' }; }
if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail
+ (earned ? ', including ' + earned + ' earned' : '') + ').' };
// featured runs book specific day-slots — verify every covered day has room
if (v.c.type === 'featured' && v.c.featuredRun) {
const av = await featuredAvailable(v.c.featuredRun.start, v.c.featuredRun.days);
if (!av.ok) return { error: 'That run is full on ' + av.fullDay + '. Pick a different start day or duration.' };
}
delete v.c.featuredRun; // transient, not a stored field
const campaign = await impl().create(owner, memberId, v.c);
// verified visits: flat up-front buy; keep active until the pack is delivered
if (campaign.type === 'visits') {
await chargeCredits(campaign.id, v.c.budget, 'Verified-visit pack (' + Number(v.c.body || 0) + ' visits): ' + campName(campaign));
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
// login ads are a flat 30-day slot, same shape as featured: charge the whole price at
// purchase, then let it run to `expires`. Nothing is metered daily, so a slot never drains
// against days when the gate produced little or nothing.
if (campaign.type === 'login') {
await chargeCredits(campaign.id, v.c.budget, 'Login-ad slot (' + Number(v.c.body || 30) + ' days): ' + campName(campaign));
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
// featured is a flat up-front buy (not metered) — charge the full price now,
// then keep it active (it runs until `expires`, not until budget is "spent")
if (campaign.type === 'featured') {
await chargeCredits(campaign.id, v.c.budget, 'Featured run (' + Number(v.c.body || 0) + ' days): ' + campName(campaign));
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
// syndicate banner/text out to NAS (inert unless NAS_DB_* is configured);
// never let a NAS hiccup block the IAP campaign from going live
if (nas.enabled() && nas.nasKind(campaign.type) && !campaign.geo && !(campaign.starts && campaign.starts > Date.now())) { // scheduled ones go out at start time (scheduleSweep); geo-restricted ones never syndicate (the network is worldwide)
try {
const r = await nas.pushCampaign(campaign, { email: owner, name: input.advName || 'InstantAdPay member' });
if (r && r.nasAdId) await setNasLink(campaign.id, r.nasAdId);
} catch (e) { console.error('nas push', campaign.id, e.message); }
}
// AdRevLinks popup syndication. Separate surface, separate audience: that server's popup
// inventory is Tier 1 only, the opposite of the NAS rail's Tier 3 skew. Inert unless
// configured, capped so one campaign cannot swallow its ~5,500 daily impressions, and
// never allowed to block the campaign going live.
if (adrevlnks.enabled() && !campaign.geo && !(campaign.starts && campaign.starts > Date.now())) {
try {
const r = await adrevlnks.push(campaign, {});
if (r && r.campaign_id) console.log('adrevlnks push', campaign.id, '->', r.campaign_id, r.views_booked + ' views');
} catch (e) { console.error('adrevlnks push', campaign.id, e.message); }
}
// DripOffers paid-per-click syndication. Clicks, not impressions: a real person picks the
// offer off an offerwall and has to stay on the target for the dwell time. Unlike the other
// two rails this one CAN carry a geo-restricted campaign, because it filters on an explicit
// country list — so geo campaigns, which reach no external rail today, are deliberately
// included here. Inert unless configured, and never allowed to block the campaign going live.
if (dripoffers.enabled() && !(campaign.starts && campaign.starts > Date.now())) {
try {
const r = await dripoffers.push(campaign, { tiers: geoTiers() });
if (r && r.campaign_id) { await setDripLink(campaign.id, r.campaign_id); console.log('dripoffers push', campaign.id, '->', r.campaign_id, r.clicks_booked + ' clicks'); }
} catch (e) { console.error('dripoffers push', campaign.id, e.message); }
}
return { ok: true, campaign };
}
// every few minutes: close campaigns past their end (unspent budget returns to
// Available), pull ended banners/text off the network, and push scheduled
// banners/text to the network once their start time arrives
async function scheduleSweep() {
const now = Date.now();
let ended = [], starting = [], startingDrip = [];
const dripKinds = "('" + dripoffers.KINDS.join("','") + "')";
if (db.enabled()) {
ended = (await db.q("SELECT * FROM campaigns WHERE status IN ('active','paused') AND expires IS NOT NULL AND expires<=?", [now])).map(rowC);
for (const c of ended) await db.q("UPDATE campaigns SET status='done' WHERE id=?", [c.id]);
starting = (await db.q("SELECT * FROM campaigns WHERE status='active' AND nas_ad_id IS NULL AND type IN ('banner','text') AND (geo IS NULL OR geo='') AND starts IS NOT NULL AND starts<=? AND (expires IS NULL OR expires>?)", [now, now])).map(rowC);
// DripOffers takes a wider set than NAS (visits too, and geo-restricted campaigns), so it
// gets its own list rather than borrowing the NAS one.
startingDrip = (await db.q("SELECT * FROM campaigns WHERE status='active' AND drip_id IS NULL AND type IN " + dripKinds + " AND starts IS NOT NULL AND starts<=? AND (expires IS NULL OR expires>?)", [now, now])).map(rowC);
} else {
if (!J.db) J.load();
ended = J.db.campaigns.filter(c => ['active', 'paused'].includes(c.status) && c.expires && c.expires <= now);
for (const c of ended) c.status = 'done';
starting = J.db.campaigns.filter(c => c.status === 'active' && !c.nasAdId && !c.geo && ['banner', 'text'].includes(c.type) && c.starts && c.starts <= now && (!c.expires || c.expires > now));
startingDrip = J.db.campaigns.filter(c => c.status === 'active' && !c.dripId && dripoffers.kindOk(c.type) && c.starts && c.starts <= now && (!c.expires || c.expires > now));
if (ended.length) J.save();
}
// Each rail is pulled independently. These used to be nested inside the NAS check, which
// meant an ended campaign kept running on the other rails whenever NAS was switched off.
if (adrevlnks.enabled()) for (const c of ended) { try { await adrevlnks.pause(c.id); } catch (e) {} }
if (dripoffers.enabled()) {
for (const c of ended) { try { await dripoffers.pause(c.id); } catch (e) {} }
for (const c of startingDrip) {
try { const r = await dripoffers.push(pubC(c), { tiers: geoTiers() }); if (r && r.campaign_id) await setDripLink(c.id, r.campaign_id); }
catch (e) { console.error('dripoffers push (scheduled)', c.id, e.message); }
}
}
if (nas.enabled()) {
for (const c of ended) if (c.nasAdId) await stopNas(c.id, c.nasAdId);
for (const c of starting) {
try { const r = await nas.pushCampaign(pubC(c), { email: c.owner, name: 'InstantAdPay member' }); if (r && r.nasAdId) await setNasLink(c.id, r.nasAdId); }
catch (e) { console.error('nas push (scheduled)', c.id, e.message); }
}
}
return { ended: ended.length, started: starting.length };
}
// charge an arbitrary credit amount against a campaign's budget, earned-pool
// first then on-chain accrual — the shared spend path for NAS reconciliation
// per-day spend counters behind the optional daily cap
function bumpDayJ(c, credits) { const k = dayKeyNow(); if (c.dayKey !== k) { c.dayKey = k; c.daySpent = 0; } c.daySpent = (c.daySpent || 0) + credits; }
async function bumpDayD(id, credits) {
const k = dayKeyNow();
await db.q('UPDATE campaigns SET day_spent=IF(day_key=?, day_spent+?, ?), day_key=? WHERE id=?', [k, credits, credits, k, Number(id)]);
}
async function chargeCredits(id, credits, why) { // `why` labels the credit-activity line for flat buys
const r = rates();
if (!credits || credits <= 0) return { charged: 0 };
if (db.enabled()) {
const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]);
if (!rows.length) return { charged: 0 };
const c = rowC(rows[0]);
credits = Math.min(credits, Math.max(0, c.budget - (c.spent || 0) - (c.accrued || 0))); // never past the budget
if (!credits) { await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active'", [id]); return { charged: 0, exhausted: true }; }
await bumpDayD(c.id, credits);
if (await spendEarned(c.owner, credits, { log: chargeLog(c, why) })) await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [credits, id]);
else if (c.memberId) { await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [credits, id]); await creditLog(c.owner, -credits, chargeLog(c, why, true)); await D.rollBurn(id, r); }
else { await db.q("UPDATE campaigns SET status='out' WHERE id=?", [id]); return { charged: 0, exhausted: true }; }
const after = rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [id]))[0]);
const exhausted = (after.spent + after.accrued) >= after.budget;
if (exhausted) await db.q("UPDATE campaigns SET status='out' WHERE id=? AND status='active'", [id]);
return { charged: credits, exhausted };
}
const c = J.db.campaigns.find(x => x.id === Number(id));
if (!c) return { charged: 0 };
credits = Math.min(credits, Math.max(0, c.budget - c.spent - (c.accrued || 0))); // never past the budget
if (!credits) { c.status = 'out'; J.save(); return { charged: 0, exhausted: true }; }
bumpDayJ(c, credits);
if (await spendEarned(c.owner, credits, { log: chargeLog(c, why) })) c.spent += credits;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + credits; await creditLog(c.owner, -credits, chargeLog(c, why, true));
if (c.accrued >= r.burnBatchMin) { J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); c.spent += c.accrued; c.accrued = 0; }
} else { c.status = 'out'; J.save(); return { charged: 0, exhausted: true }; }
const exhausted = (c.spent + (c.accrued || 0)) >= c.budget;
if (exhausted) c.status = 'out';
J.save();
return { charged: credits, exhausted };
}
async function setNasLink(id, nasAdId) {
if (db.enabled()) await db.q('UPDATE campaigns SET nas_ad_id=? WHERE id=?', [nasAdId, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.nasAdId = nasAdId; J.save(); } }
}
// Remembering the DripOffers campaign id is what lets a SCHEDULED campaign be picked up when
// its start time arrives: the sweep looks for eligible campaigns that have no drip id yet.
// Without it there is no way to tell "never syndicated" from "already syndicated".
async function setDripLink(id, dripId) {
if (db.enabled()) await db.q('UPDATE campaigns SET drip_id=? WHERE id=?', [dripId, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.dripId = dripId; J.save(); } }
}
// Stop a campaign's NAS ad and record what it had ACTUALLY delivered before the stop.
// Never call nas.deactivate() directly: zeroing the counter without snapshotting first
// makes the ad read back as fully delivered (see nas.js deactivate).
async function stopNas(campaignId, nasAdId) {
if (!nasAdId) return;
try {
const r = await nas.deactivate(nasAdId);
if (r && typeof r.served === 'number') await setNasServed(campaignId, r.served);
} catch (e) {}
}
async function setNasServed(id, served) {
if (db.enabled()) await db.q('UPDATE campaigns SET nas_served=? WHERE id=?', [served, Number(id)]);
else { const c = J.db.campaigns.find(x => x.id === Number(id)); if (c) { c.nasServed = served; J.save(); } }
}
// reconcile NAS delivery into the unified credit pool: read each syndicated
// ad's served count (assigned - remaining, counts DOWN), charge the new
// impressions to the campaign's budget, and stop the NAS ad when budget is dry
async function reconcileNas() {
if (!nas.enabled()) return { done: 0 };
let camps;
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE nas_ad_id IS NOT NULL AND status='active' AND type IN ('banner','text')");
camps = rows.map(rowC);
} else {
if (!J.db) J.load();
camps = J.db.campaigns.filter(c => c.nasAdId && c.status === 'active' && ['banner', 'text'].includes(c.type));
}
let n = 0;
for (const c of camps) {
let st;
try { st = await nas.readServed(c.nasAdId); } catch (e) { continue; }
if (!st) continue;
const ipc = nas.impressionsPerCredit(c.type) || 1;
// a capped campaign gets one day's allowance of network impressions at a time: on a new day,
// hand NAS the next allowance (bounded by the budget left) instead of the whole budget
if (c.dailyCap && c.status === 'active' && c.dayKey !== dayKeyNow()) {
const left = c.budget - (c.spent || 0) - (c.accrued || 0);
const allow = Math.max(0, Math.min(c.dailyCap, left)) * ipc;
if (allow > 0 && st.remaining < allow) { try { await nas.topUp(c.nasAdId, allow - st.remaining, 30); } catch (e) {} }
if (db.enabled()) await db.q('UPDATE campaigns SET day_key=?, day_spent=0 WHERE id=?', [dayKeyNow(), c.id]);
else { c.dayKey = dayKeyNow(); c.daySpent = 0; J.save(); }
}
const prev = c.nasServed || 0;
if (st.served > prev) {
const creditsDue = Math.floor(st.served / ipc) - Math.floor(prev / ipc);
if (creditsDue > 0) {
const res = await chargeCredits(c.id, creditsDue);
if (res.exhausted) await stopNas(c.id, c.nasAdId);
}
await setNasServed(c.id, st.served);
n++;
}
}
return { done: n };
}
// buy more views: raise a campaign's budget cap by `addCredits` (bounded by the
// member's available credits, same rule as creation), reactivate it if it had
// run out or was paused, and extend the syndicated NAS ad if there is one.
async function topUpCampaign(owner, memberId, id, addCredits) {
const add = Math.floor(Number(addCredits) || 0);
if (add < 10) return { error: 'Add at least 10 credits.' };
const list = await impl().list(owner);
const c = list.find(x => x.id === Number(id));
if (!c) return { error: 'No such campaign.' };
if (c.type === 'featured') return { error: 'Featured links are booked by the day, not by credits. Use Extend run to add days.' };
const fundId = c.memberId || memberId; // a campaign burns from the member it was created under
const bal = await balances(fundId ? [fundId] : [], owner);
const purchased = (fundId ? bal.best.avail : 0) + bal.credited;
const earned = c.type !== 'login' ? bal.earned : 0;
if (add > purchased + earned) return { error: 'That exceeds your available credits (' + (purchased + earned) + ' not already committed to a campaign).' };
const raw = db.enabled()
? rowC((await db.q('SELECT * FROM campaigns WHERE id=? AND owner_email=?', [Number(id), owner]))[0])
: J.db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
if (!raw) return { error: 'No such campaign.' };
const reactivate = raw.status !== 'active';
// verified-visit packs are flat buys: a top-up buys MORE VISITS at the pack rate and is
// charged right now, exactly like the original pack (never parked as unspent budget)
if (raw.type === 'visits') {
const r = rates(); const per = r.visitCostPerVisit || 3;
const more = Math.floor(add / per);
if (more < 1) return { error: 'A verified visit is ' + per + ' credits.' };
// budget = what has actually been charged + this buy: any credits parked in the row by the old
// "add credits" path are released, never re-reserved when the pack reactivates
const cost = more * per, total = Number(raw.body || 0) + more, newBudget = (raw.spent || 0) + (raw.accrued || 0) + cost;
if (db.enabled()) await db.q("UPDATE campaigns SET budget=?, body=?, status='active' WHERE id=?", [newBudget, String(total), Number(id)]);
else { raw.budget = newBudget; raw.body = String(total); raw.status = 'active'; J.save(); }
await chargeCredits(raw.id, cost, 'More verified visits (+' + more + '): ' + campName(raw)); // flips to 'out' once fully charged, so restore 'active' after (same as pack creation)
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [raw.id]); else { raw.status = 'active'; J.save(); }
return { ok: true, added: cost, visits: more, total, budget: newBudget, reactivated: reactivate };
}
const newBudget = raw.budget + add;
if (db.enabled()) {
await db.q("UPDATE campaigns SET budget=?, status=IF(status='active','active','active') WHERE id=?", [newBudget, Number(id)]);
} else { raw.budget = newBudget; raw.status = 'active'; J.save(); }
if (raw.nasAdId && nas.enabled()) {
try { await nas.topUp(raw.nasAdId, add * (nas.impressionsPerCredit(raw.type) || 0), 30); } catch (e) {}
}
return { ok: true, added: add, budget: newBudget, reactivated: reactivate };
}
// featured runs are sold by the day: extending books more day-slots right after the
// current run (or from today if it already ended) at the flat daily price, charged now.
// This replaces "add credits" for featured links, which used to park the credits
// unspent until the run ended (Hugh + Michael, 2026-09-15/16).
async function extendFeatured(owner, memberId, id, daysIn) {
const r = rates();
const durations = r.featuredDurations || [1, 2, 7];
const days = Number(daysIn);
if (!durations.includes(days)) return { error: 'Pick a duration (' + durations.join(', ') + ' days).' };
let raw = null;
if (db.enabled()) { const rows = await db.q("SELECT * FROM campaigns WHERE id=? AND owner_email=? AND type='featured'", [Number(id), owner]); raw = rows.length ? rowC(rows[0]) : null; }
else { if (!J.db) J.load(); raw = J.db.campaigns.find(x => x.id === Number(id) && x.owner === owner && x.type === 'featured') || null; }
if (!raw) return { error: 'No such featured link.' };
const cost = days * (r.featuredPerDay || 40);
const fundId = raw.memberId || memberId;
const bal = await balances(fundId ? [fundId] : [], owner);
const avail = (fundId ? bal.best.avail : 0) + bal.credited + bal.earned;
if (cost > avail) return { error: 'A ' + days + '-day extension is ' + cost + ' credits; you have ' + avail + ' not already committed to a campaign.' };
const prevEnd = Number(raw.expires) || 0;
const start = Math.max(prevEnd, dayStart(0));
const av = await featuredAvailable(start, days);
if (!av.ok) return { error: 'The featured strip is full on ' + av.fullDay + '. Try a shorter extension.' };
const expires = start + days * DAY_MS;
const totalDays = Number(raw.body || 0) + days;
// budget = charged so far + this extension. A featured row that was topped up under the old
// "add credits" button carries parked, unspent budget; reactivating it with that budget intact
// would reserve those credits again (Hugh, 2026-09-16: balance 1,133 -> 0 after a 280 extension)
const newBudget = (raw.spent || 0) + (raw.accrued || 0) + cost;
if (db.enabled()) await db.q("UPDATE campaigns SET budget=?, expires=?, body=?, status='active' WHERE id=?", [newBudget, expires, String(totalDays), raw.id]);
else { raw.budget = newBudget; raw.expires = expires; raw.body = String(totalDays); raw.status = 'active'; J.save(); }
await chargeCredits(raw.id, cost, 'Featured run extended ' + days + ' day' + (days === 1 ? '' : 's') + ': ' + campName(raw)); // charging the whole budget marks it 'out'; a featured run is live until `expires`
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [raw.id]); else { raw.status = 'active'; J.save(); }
return { ok: true, days, cost, expires, resumed: prevEnd <= Date.now() };
}
const DAY_MS = 86400000;
// start-of-day (UTC) for a booking input: a YYYY-MM-DD string, or an integer
// offset in days from today. Returns a ms timestamp, or null if unparseable.
function dayStart(input) {
if (input === undefined || input === null || input === '') input = 0;
if (typeof input === 'number' || /^\d+$/.test(String(input))) {
const base = Math.floor(Date.now() / DAY_MS) * DAY_MS;
return base + Number(input) * DAY_MS;
}
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(input));
if (!m) return null;
const t = Date.UTC(+m[1], +m[2] - 1, +m[3]);
return Number.isFinite(t) ? t : null;
}
// all featured campaigns still relevant (not yet expired), for occupancy math
async function featuredAll() {
const now = Date.now();
if (db.enabled()) {
return (await db.q("SELECT * FROM campaigns WHERE type='featured' AND status IN ('active') AND expires>?", [now])).map(rowC);
}
if (!J.db) J.load();
return J.db.campaigns.filter(c => c.type === 'featured' && c.status === 'active' && (c.expires || 0) > now);
}
// how many featured runs cover a given day [dayStart, dayStart+DAY)
function occupancyOn(list, ds) {
return list.filter(c => (c.starts || c.created) < ds + DAY_MS && (c.expires || 0) > ds).length;
}
// occupancy for the booking window: [{ day:'YYYY-MM-DD', offset, count, cap, open }]
async function featuredOccupancy() {
const r = rates();
const cap = r.featuredSlotsPerDay || 10;
const list = await featuredAll();
const win = r.featuredWindowDays || 7;
const out = [];
for (let i = 0; i <= win; i++) {
const ds = dayStart(i);
const count = occupancyOn(list, ds);
out.push({ offset: i, day: new Date(ds).toISOString().slice(0, 10), count, cap, open: Math.max(0, cap - count) });
}
return out;
}
// is a run [start, start+days) bookable? every covered day must have an open slot
async function featuredAvailable(start, days) {
const r = rates();
const cap = r.featuredSlotsPerDay || 10;
const list = await featuredAll();
for (let i = 0; i < days; i++) {
const ds = start + i * DAY_MS;
if (occupancyOn(list, ds) >= cap) return { ok: false, fullDay: new Date(ds).toISOString().slice(0, 10) };
}
return { ok: true };
}
// live featured links: active AND currently within [starts, expires)
async function serveFeatured(viewer) {
const now = Date.now();
const list = await featuredAll();
const tier = viewer && viewer.tier;
return list.filter(c => (c.starts || c.created) <= now && (c.expires || 0) > now && geoOk(c, tier))
.map(c => ({ id: c.id, title: c.title, url: '/api/ads/click/' + c.id, memberId: c.memberId || 0, expires: c.expires }));
}
// featured strip impressions (2026-09-15): the strip is served on every Overview but never counted, so advertisers
// paying per day saw "0 views". One impression per viewer per campaign per hour, so a member refreshing all day
// does not inflate it; feeds the same imps + by-hour chart the other formats use.
const featuredSeen = new Map(); // viewerKey|campaignId -> hour key
async function noteFeaturedViews(items, viewerKey) {
const hk = new Date().toISOString().slice(0, 13); const k0 = String(viewerKey || 'anon');
if (featuredSeen.size > 100000) featuredSeen.clear();
for (const i of items || []) {
const k = k0 + '|' + i.id; if (featuredSeen.get(k) === hk) continue; featuredSeen.set(k, hk);
try {
if (db.enabled()) await db.q('UPDATE campaigns SET imps=imps+1 WHERE id=?', [i.id]);
else { const c = J.db && J.db.campaigns && J.db.campaigns.find(x => x.id === i.id); if (c) { c.imps = (c.imps || 0) + 1; J.save(); } }
bumpHour(i.id);
} catch (e) {}
}
}
async function featuredStats() {
const live = await serveFeatured();
const r = rates();
return { activeCount: live.length, perDay: r.featuredPerDay || 40, durations: r.featuredDurations || [1, 2, 7],
slotsPerDay: r.featuredSlotsPerDay || 10, occupancy: await featuredOccupancy() };
}
// ── verified visits: serve a pack the viewer hasn't completed, then on a
// dwell+captcha-verified visit record it uniquely, pay the viewer, and count it ──
async function visitDailyCount(email) {
const e = String(email || '').toLowerCase();
if (db.enabled()) { const r = await db.q('SELECT COUNT(*) n FROM visit_seen WHERE email=? AND day=?', [e, today()]); return r[0].n; }
if (!SVJ.db) SVJ.load();
return SVJ.db.items.filter(i => i.email === e && i.day === today()).length;
}
const SVJ = { db: null, FILE: () => path.join(DATA_DIR, 'visit-seen.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} } };
async function serveVisit(viewerEmail, viewer) {
const e = String(viewerEmail || '').toLowerCase();
const tier = viewer && viewer.tier, vcc = viewer && viewer.cc;
const now = Date.now();
let seen = new Set();
if (db.enabled()) {
(await db.q('SELECT campaign_id FROM visit_seen WHERE email=?', [e])).forEach(r => seen.add(r.campaign_id));
const rows = (await db.q("SELECT * FROM campaigns WHERE type='visits' AND status='active' AND owner_email<>?" + SCHED_SQL + GEO_SQL + " ORDER BY RAND() LIMIT 20", [e, Date.now(), Date.now(), String(tier || '')])).map(rowC);
const c = rows.find(x => (x.imps || 0) < Number(x.body || 0) && !seen.has(x.id));
if (c) bumpGeo(c.id, vcc);
return c ? { id: c.id, title: c.title, url: '/api/ads/click/' + c.id, target: c.targetUrl } : null;
}
if (!SVJ.db) SVJ.load();
SVJ.db.items.filter(i => i.email === e).forEach(i => seen.add(i.cid));
const pool = J.db.campaigns.filter(c => c.type === 'visits' && c.status === 'active' && c.owner !== e && inWindow(c) && geoOk(c, tier)
&& (c.imps || 0) < Number(c.body || 0) && !seen.has(c.id));
const c = pool[Math.floor(Math.random() * pool.length)];
if (c) bumpGeo(c.id, vcc);
return c ? { id: c.id, title: c.title, url: '/api/ads/click/' + c.id, target: c.targetUrl } : null;
}
async function visitStatus(email) {
const r = rates();
return { count: await visitDailyCount(email), cap: r.visitCapPerDay || 20, dwell: r.visitDwellSeconds || 8, reward: r.visitReward || 1 };
}
// record a verified visit: unique per (campaign, viewer), advance the count,
// reward the viewer, and complete the pack when the target is hit
async function completeVisit(email, campaignId) {
const e = String(email || '').toLowerCase();
const r = rates();
const cap = r.visitCapPerDay || 20;
if (await visitDailyCount(e) >= cap) return { error: 'Daily verified-visit cap reached. More tomorrow.' };
if (db.enabled()) {
const rows = await db.q("SELECT * FROM campaigns WHERE id=? AND type='visits' AND status='active'", [Number(campaignId)]);
if (!rows.length) return { error: 'That visit is no longer available.' };
const c = rowC(rows[0]);
if ((c.imps || 0) >= Number(c.body || 0)) return { error: 'That pack just filled up.' };
try { await db.q('INSERT INTO visit_seen (campaign_id,email,day,ts) VALUES (?,?,?,?)', [c.id, e, today(), Date.now()]); }
catch (er) { return { error: 'You already visited this one.' }; } // unique key
await db.q('UPDATE campaigns SET imps=imps+1 WHERE id=?', [c.id]); bumpHour(c.id);
await db.q("UPDATE campaigns SET status='out' WHERE id=? AND imps>=?", [c.id, Number(c.body || 0)]);
await addEarned(e, r.visitReward || 1, { log: { kind: 'earn', note: 'Verified visit' } });
return { ok: true, credited: r.visitReward || 1 };
}
if (!SVJ.db) SVJ.load();
const c = J.db.campaigns.find(x => x.id === Number(campaignId) && x.type === 'visits' && x.status === 'active');
if (!c) return { error: 'That visit is no longer available.' };
if ((c.imps || 0) >= Number(c.body || 0)) return { error: 'That pack just filled up.' };
if (SVJ.db.items.some(i => i.cid === c.id && i.email === e)) return { error: 'You already visited this one.' };
SVJ.db.items.push({ cid: c.id, email: e, day: today(), ts: Date.now() });
SVJ.save();
c.imps = (c.imps || 0) + 1; bumpHour(c.id);
if (c.imps >= Number(c.body || 0)) c.status = 'out';
J.save();
addEarned(e, r.visitReward || 1, { log: { kind: 'earn', note: 'Verified visit' } });
return { ok: true, credited: r.visitReward || 1 };
}
async function listCampaigns(owner) { return impl().list(owner); }
// ---- admin ----
async function campaignById(id) {
if (db.enabled()) { const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]); return rows.length ? rowC(rows[0]) : null; }
return J.db.campaigns.find(x => x.id === Number(id)) || null;
}
// every campaign on the site, newest first (owner included so the admin can see whose it is)
async function adminList(limit = 500) {
if (db.enabled()) { const rows = await db.q('SELECT * FROM campaigns ORDER BY id DESC LIMIT ?', [Number(limit) || 500]); return rows.map(r => pubC(rowC(r))); }
return J.db.campaigns.slice().sort((a, b) => b.id - a.id).slice(0, limit).map(pubC);
}
// pause/resume any campaign regardless of owner (NAS mirror rides the normal path)
async function adminSetStatus(id, status) {
const c = await campaignById(id);
if (!c) return { error: 'No such campaign.' };
return setStatus(c.owner, id, status);
}
// admin house ad: same validation and delivery as a member campaign, but no
// credit check and no charge. Budget is a delivery cap (default 100k credits).
async function createHouseCampaign(input) {
const budget = Math.floor(Number(input.budget) || 0);
const v = validate(Object.assign({}, input, { budget: budget > 0 ? budget : 100000 }));
if (v.error) return v;
if (v.c.type === 'featured' && v.c.featuredRun) {
const av = await featuredAvailable(v.c.featuredRun.start, v.c.featuredRun.days);
if (!av.ok) return { error: 'That run is full on ' + av.fullDay + '. Pick a different start day or duration.' };
}
delete v.c.featuredRun;
v.c.house = true;
const campaign = await impl().create(HOUSE_OWNER, 0, v.c);
if (campaign.type === 'visits' || campaign.type === 'featured') { // flat buys: mirror the member path (charge is a no-op for house)
await chargeCredits(campaign.id, v.c.budget);
if (db.enabled()) await db.q("UPDATE campaigns SET status='active' WHERE id=?", [campaign.id]);
else { const cc = J.db.campaigns.find(x => x.id === campaign.id); if (cc) { cc.status = 'active'; J.save(); } }
campaign.status = 'active';
}
if (nas.enabled() && nas.nasKind(campaign.type)) {
try {
const r = await nas.pushCampaign(campaign, { email: HOUSE_OWNER, name: input.advName || 'InstantAdPay' });
if (r && r.nasAdId) await setNasLink(campaign.id, r.nasAdId);
} catch (e) { console.error('nas push (house)', campaign.id, e.message); }
}
return { ok: true, campaign };
}
async function setStatus(owner, id, status) {
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
const r = await impl().setStatus(owner, id, status);
// Mirror pause/resume out to every syndication rail. Each rail is checked on its own: these
// used to sit inside the NAS branch, so with NAS off a member could pause a campaign here and
// have it keep running on the other rails.
if (!r.error && (nas.enabled() || adrevlnks.enabled() || dripoffers.enabled())) {
try {
const c = (await impl().list(owner)).find(x => x.id === Number(id));
const raw = db.enabled() ? rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]))[0])
: J.db.campaigns.find(x => x.id === Number(id));
if (raw && raw.nasAdId && nas.enabled()) {
if (status === 'paused') await stopNas(raw.id, raw.nasAdId);
else { // resume: restore remaining impressions for the budget still left
const left = (c.budget - c.spent) * (nas.impressionsPerCredit(c.type) || 0);
if (left > 0) await nas.topUp(raw.nasAdId, Math.floor(left), 30);
}
}
// keep the short-link server in step with the same pause/resume
if (raw && adrevlnks.enabled()) {
try { await (status === 'paused' ? adrevlnks.pause(raw.id) : adrevlnks.resume(raw.id)); }
catch (e) { console.error('adrevlnks ' + status, raw.id, e.message); }
}
// and the click rail
if (raw && dripoffers.enabled()) {
try { await (status === 'paused' ? dripoffers.pause(raw.id) : dripoffers.resume(raw.id)); }
catch (e) { console.error('dripoffers ' + status, raw.id, e.message); }
}
} catch (e) { console.error('syndication setStatus', id, e.message); }
}
return r;
}
// Archiving (Marty, 2026-09-23). A member asked to delete finished campaigns. A real delete would
// orphan the credit ledger, the hourly impression rows and the P&L, and lose the numbers they
// actually want to keep, so a campaign is archived instead: out of the working list, still readable
// in full, and reversible.
//
// The credits come back on their own. Reserved budget is COMPUTED from campaigns whose status is
// active or paused (committed / liveBudgets), never deducted from a balance, so moving a campaign
// to 'archived' releases its unspent remainder the moment the status changes. Nothing to refund and
// nothing that can double-refund.
const ARCHIVABLE = ['paused', 'out'];
function unspentOf(c) { return Math.max(0, Number(c.budget || 0) - Number(c.spent || 0) - Number(c.accrued || 0)); }
async function archive(owner, id) {
const c = (await impl().list(owner)).find(x => x.id === Number(id));
if (!c) return { error: 'No such campaign.' };
if (c.status === 'archived') return { ok: true, campaign: c, freed: 0, already: true };
if (!ARCHIVABLE.includes(c.status)) return { error: 'Pause this campaign first, then archive it.' };
const freed = unspentOf(c);
// a campaign that ran out was already stopped everywhere; a paused one had its rails stopped on
// pause. Stop them again anyway: it is idempotent and an archived ad must never surface on a rail.
await railsStop(owner, id).catch(e => console.error('archive rails', id, e.message));
const r = await impl().setStatus(owner, id, 'archived');
if (r.error) return r;
// no ledger line: nothing moved. The reservation was computed from the status, and the freed
// figure is reported back to the member instead.
return { ok: true, campaign: r.campaign, freed };
}
async function unarchive(owner, id) {
const c = (await impl().list(owner)).find(x => x.id === Number(id));
if (!c) return { error: 'No such campaign.' };
if (c.status !== 'archived') return { error: 'That campaign is not archived.' };
// back to paused, never straight to running: restarting is a separate, deliberate press
const r = await impl().setStatus(owner, id, unspentOf(c) > 0 ? 'paused' : 'out');
return r.error ? r : { ok: true, campaign: r.campaign };
}
async function railsStop(owner, id) {
if (!(nas.enabled() || adrevlnks.enabled() || dripoffers.enabled())) return;
const raw = db.enabled() ? rowC((await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]))[0] || {})
: J.db.campaigns.find(x => x.id === Number(id));
if (!raw) return;
if (raw.nasAdId && nas.enabled()) await stopNas(raw.id, raw.nasAdId).catch(() => {});
if (adrevlnks.enabled()) await adrevlnks.pause(raw.id).catch(() => {});
if (dripoffers.enabled()) await dripoffers.pause(raw.id).catch(() => {});
}
async function serve(type, opts) { return TYPES.includes(type) ? impl().serve(type, opts) : null; }
async function click(id) { return impl().click(id); }
async function targetOf(id) { return impl().targetOf(id); }
async function dailySweep() { await scheduleSweep(); return impl().dailySweep(); }
async function pendingBurns() { return impl().pendingBurns(); }
async function markBurned(id, tx) { return impl().markBurned(id, tx); }
// credits are pooled per account: when the position a campaign was pinned to runs dry on-chain
// (the shared credited pool covered a different campaign than expected), the burner settles the
// spend from another of the account's funded positions (Jim's #25, 2026-09-15)
async function burnOwner(ref) {
const m = /^campaign-(\d+)$/.exec(String(ref || '')); if (!m) return null;
const c = await campaignById(Number(m[1])); return c && c.owner ? String(c.owner) : null;
}
async function reassignBurn(id, memberId) {
if (db.enabled()) { const r = await db.q('UPDATE burns SET member_id=? WHERE id=? AND burned_tx IS NULL', [Number(memberId), String(id)]); return r.affectedRows > 0; }
if (!J.db) J.load(); const b = J.db.burnsPending.find(x => x.id === id && !x.burnedTx); if (!b) return false; b.memberId = Number(memberId); J.save(); return true;
}
async function unburnedFor(memberId) { return impl().unburned(memberId); }
// daily login bonus: once per day, gentle streak (base 5, +1/day up to +5, cap 10).
async function grantLoginBonus(email) {
const e = String(email || '').toLowerCase(); if (!e) return { granted: 0, streak: 0 };
const day = today(); const yest = ctDay(Date.now() - 86400000);
const now = Date.now();
let lastDay = '', streak = 0, lastTs = 0;
if (db.enabled()) {
const r = await db.q('SELECT login_day, login_streak, login_ts FROM earned_credits WHERE email=?', [e]);
if (r.length) { lastDay = r[0].login_day || ''; streak = r[0].login_streak || 0; lastTs = Number(r[0].login_ts || 0); }
} else { if (!EJ.db) EJ.load(); const rec = EJ.db[e] || {}; lastDay = rec.loginDay || ''; streak = rec.loginStreak || 0; lastTs = Number(rec.loginTs || 0); }
if (lastDay === day) return { granted: 0, streak, already: true };
// belt to the calendar day's braces: whatever a future timezone or DST edge does to the
// day string, two bonuses can never land inside the same waking day. 20h rather than 24
// so signing in at the same time each morning never skips a day.
if (lastTs && now - lastTs < 20 * 3600 * 1000) return { granted: 0, streak, already: true };
streak = (lastDay === yest) ? streak + 1 : 1;
const amount = 5 + Math.min(streak - 1, 5);
await addEarned(e, amount, { log: { kind: 'bonus', note: 'Sign-in bonus (' + streak + '-day streak)' } });
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated,login_day,login_streak,login_ts) VALUES (?,0,0,?,?,?,?)
ON DUPLICATE KEY UPDATE login_day=VALUES(login_day), login_streak=VALUES(login_streak), login_ts=VALUES(login_ts), updated=VALUES(updated)`, [e, now, day, streak, now]);
} else { if (!EJ.db) EJ.load(); EJ.db[e] = EJ.db[e] || { balance: 0 }; EJ.db[e].loginDay = day; EJ.db[e].loginStreak = streak; EJ.db[e].loginTs = now; EJ.save(); }
return { granted: amount, streak };
}
module.exports = { init, rates, setRates, bannerSizes: () => BANNER_SIZES, sanitizeRich: sanitizeSolo, createCampaign, listCampaigns, setStatus, archive, unarchive, grantLoginBonus,
createHouseCampaign, adminList, adminSetStatus, campaignById, HOUSE_OWNER,
serve, click, targetOf, dailySweep, scheduleSweep, hoursFor, geoFor, availableCredits, pooledCredits, balances, liveBudgets, earnedBalance, earnedSplit, chargeCredits, grantWelcome, welcomeGranted,
viewStatus, recordView, claimDaily, pendingBurns, markBurned, burnOwner, reassignBurn, unburnedFor, grantMilestones, milestonesOf, spendEarned,
inboxList, inboxOpen, markSoloVisit, claimSoloRead, unreadCount,
serveVideo, chargeVideoView, videoStatus, recordVideoWatch, markVideoSeen, hasWatchedVideoToday, addEarned, creditLog, creditActivity,
reconcileNas, nasEnabled: () => nas.enabled(), topUpCampaign, extendFeatured, serveFeatured, noteFeaturedViews, featuredStats, featuredOccupancy,
serveVisit, visitStatus, completeVisit };