Files
instantadpay/friday.js
T
martbost 742c271469 Chain-event side effects happen once across running instances
A Coolify deploy starts the new container and stops the old one only once
the new one is healthy, so for about half a minute two copies of this app
watch the chain. On 25 September, 06:26 CT, both saw Marty's $5 purchase:
two confirmation emails, and the Five Dollar Friday bonus granted twice
(+100 and +100, nineteen seconds apart). Every dedupe until now lived in
process memory or a JSON file on the volume; a second process shares
neither in time.

marks.js is the shared version: a row in MySQL that only one INSERT IGNORE
can win. The chain-event fan-out claims 'ev:<tx>:<logIndex>' before the
email, Telegram post, Friday bonus and sponsor sync; the in-memory feed
stays per instance. The Friday scheduler claims its Thursday email, kickoff
and wrap-up the same way, with the file marker kept as the second line of
defence and the thing an operator can edit by hand. Without a database it
degrades to a per-process Set, which is exactly the old single-instance
behaviour.

Proven on the production MySQL before deploy: first insert 1 row, second 0.
Marty's duplicate 100 credits reversed with a labelled adjust row; no other
member was double-granted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 06:40:11 -05:00

153 lines
10 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.
// Five Dollar Friday (Marty, 2026-09-20). Every Friday, Central time, any ad package of $5 or more
// earns +20% bonus credits (purchased-grade, so they run login ads too), credited the moment the
// purchase indexes and labelled in the credit log. The day's wave is shown live (landing strip,
// Overview card, /api/friday), posted to Telegram at every ten packs and wrapped up at midnight,
// and five Fridays in a row earn the Five Fridays badge. Thursday 6 PM CT: an on-site notice and
// an email to active members (drip unsubscribes honoured). Friday 9 AM CT: the Telegram kickoff.
//
// Settings (siteConfig): fridayPromo '1'|'0', fridayBonusPct (20), fridayStart 'YYYY-MM-DD' (first
// Friday, 2026-09-25). Everything reads the chain index, never a marketing counter.
'use strict';
const fs = require('fs');
const path = require('path');
let X = {}, FILE = null, S = null;
const TZ = 'America/Chicago';
const dayOf = ts => new Date(ts).toLocaleDateString('en-CA', { timeZone: TZ });
const weekdayOf = ts => new Date(ts).toLocaleDateString('en-US', { timeZone: TZ, weekday: 'short' });
const hourOf = ts => Number(new Date(ts).toLocaleString('en-US', { timeZone: TZ, hour: 'numeric', hour12: false }));
const isFriday = ts => weekdayOf(ts) === 'Fri';
function init(opts) { X = opts; FILE = path.join(opts.dataDir, 'friday.json'); S = load(); }
function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { granted: {}, posts: {}, badges: {}, sent: {} }; } }
function save() { try { fs.writeFileSync(FILE, JSON.stringify(S)); } catch (e) {} }
// shared across instances when the app is wired with marks (server.js); true when nobody else has it
async function claimOnce(key) { try { return X.marks ? await X.marks.claim(key) : true; } catch (e) { return true; } }
function cfg() {
const c = (X.siteConfig && X.siteConfig()) || {};
return {
on: String(c.fridayPromo == null ? '1' : c.fridayPromo) === '1',
pct: Math.max(0, Number(c.fridayBonusPct) || 20),
start: String(c.fridayStart || '2026-09-25'),
};
}
// the next Friday on or after today (Central), as a day key and a label
function nextFriday(now) {
const t0 = now || Date.now();
for (let d = 0; d < 8; d++) { const ts = t0 + d * 86400000; if (isFriday(ts) && dayOf(ts) >= cfg().start) return dayOf(ts); }
return null;
}
function labelOf(dayKey) { const [y, m, d] = dayKey.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d, 12)).toLocaleDateString('en-US', { month: 'long', day: 'numeric', timeZone: 'UTC' }); }
// is the promo live right now (a Friday on or after the start, switched on)
function liveNow(now) { const c = cfg(); const t = now || Date.now(); return c.on && isFriday(t) && dayOf(t) >= c.start; }
function counts(dayKey) { return purchasesOn(dayKey).length; }
// every $5+ purchase on a given Central day, from the chain index
function purchasesOn(dayKey) {
const out = [];
try { for (const ev of X.chain.recentEvents(1e9)) if (ev.type === 'Purchase' && ev.priceCents >= 500 && dayOf(ev.ts) === dayKey) out.push(ev); } catch (e) {}
return out;
}
function paidOn(dayKey) {
let wei = 0n, n = 0;
try { for (const ev of X.chain.recentEvents(1e9)) if ((ev.type === 'TierPaid' || ev.type === 'AwardPaid') && dayOf(ev.ts) === dayKey) { wei += BigInt(ev.amountWei); n++; } } catch (e) {}
return { pol: Number(wei / 10n ** 14n) / 10000, n };
}
function stats(dayKey) {
const day = dayKey || dayOf(Date.now());
const p = purchasesOn(day); const paid = paidOn(day);
const usd = p.reduce((s, e) => s + e.priceCents, 0) / 100;
const buyers = new Set(p.map(e => e.buyerId)).size;
const bonus = Object.values(S.granted || {}).filter(g => g.day === day).reduce((s, g) => s + g.bonus, 0);
return { day, packs: p.length, usd, buyers, polPaid: paid.pol, payouts: paid.n, bonusCredits: bonus };
}
// public view
function view() {
const c = cfg(); const now = Date.now(); const live = liveNow(now); const next = nextFriday(now);
return { on: c.on, pct: c.pct, live, today: live ? stats(dayOf(now)) : null, nextFriday: next, nextLabel: next ? labelOf(next) : null, start: c.start };
}
// the bonus: once per purchase tx, on a Friday on or after the start
async function onEvent(ev) {
if (!ev || ev.type !== 'Purchase' || !liveNow(ev.ts) || ev.priceCents < 500) return null;
if (S.granted[ev.tx]) return null;
const c = cfg(); const bonus = Math.round((Number(ev.creditAmount) || 0) * c.pct / 100);
if (bonus <= 0) return null;
const a = await X.accounts.byMemberId(ev.buyerId); if (!a || !a.email) return null;
const usd = ('$' + (ev.priceCents / 100).toFixed(2)).replace(/\.00$/, '');
await X.ads.addEarned(a.email, bonus, { purchased: true, log: { kind: 'bonus', note: 'Five Dollar Friday: +' + c.pct + '% on your ' + usd + ' package', ref: 'fdf:' + String(ev.tx).slice(0, 30) } });
S.granted[ev.tx] = { day: dayOf(ev.ts), memberId: ev.buyerId, bonus, at: Date.now() }; save();
try { await X.notify(ev.buyerId, 'Five Dollar Friday: +' + bonus + ' bonus credits', 'Your ' + usd + ' package landed on a Five Dollar Friday, so ' + bonus + ' bonus credits (+' + c.pct + '%) were added to your balance. Every pack bought on a Friday gets this. See your balance: https://instantadpay.com/my#campaigns', 'notice'); } catch (e) {}
// the wave, every ten packs
const st = stats(dayOf(ev.ts));
if (st.packs > 0 && st.packs % 10 === 0 && !S.posts[st.day + ':' + st.packs]) {
S.posts[st.day + ':' + st.packs] = Date.now(); save();
X.telegram('\u{1F4B5} <b>Five Dollar Friday</b> · <b>' + st.packs + ' packs</b> so far today from ' + st.buyers + ' buyers, <b>' + st.polPaid.toFixed(2) + ' POL</b> paid to members in the same transactions.\nEvery $5+ pack today earns +' + c.pct + '% credits: https://instantadpay.com/my#buy').catch(() => {});
}
return bonus;
}
// five Fridays in a row (the most recent five promo Fridays that have happened), any $5+ pack each
function fridayStreak(memberId) {
const c = cfg(); const now = Date.now(); const days = [];
for (let d = 0; d < 7 * 8 && days.length < 5; d++) { const ts = now - d * 86400000; const k = dayOf(ts); if (isFriday(ts) && k >= c.start && (k < dayOf(now) || liveNow(now))) days.push(k); }
let streak = 0;
for (const k of days) { if (purchasesOn(k).some(e => e.buyerId === Number(memberId))) streak++; else break; }
return streak;
}
function earnedBadge(memberId) {
if (S.badges[memberId]) return true;
if (fridayStreak(memberId) >= 5) { S.badges[memberId] = Date.now(); save(); return true; }
return false;
}
// scheduled moments, checked every few minutes: Thursday 18:00 CT notice + email, Friday 09:00 CT kickoff,
// Saturday 00:xx CT wrap-up of the Friday that just ended
async function tick() {
const c = cfg(); if (!c.on) return;
const now = Date.now(); const day = dayOf(now); const wd = weekdayOf(now); const h = hourOf(now);
const next = nextFriday(now);
if (wd === 'Thu' && h >= 18 && next && !S.sent['thu:' + next] && await claimOnce('fri:thu:' + next)) {
S.sent['thu:' + next] = now; save();
const subject = 'Tomorrow is Five Dollar Friday';
const text = 'Tomorrow is Five Dollar Friday on InstantAdPay.\n\nAny ad package of $5 or more bought on Friday (Central time) earns +' + c.pct + '% bonus credits, added the moment the purchase settles. Your $5 pays your sponsor line the second it clears, and your line’s $5s pay you. Friday is when we all push at once.\n\nLine up your pack: https://instantadpay.com/my#buy\nWatch the wave on the live ledger: https://instantadpay.com/ledger';
await broadcast(subject, text);
}
if (wd === 'Fri' && h >= 9 && day >= c.start && !S.sent['kick:' + day] && await claimOnce('fri:kick:' + day)) {
S.sent['kick:' + day] = now; save();
X.telegram('\u{1F4B5} <b>It’s Five Dollar Friday.</b> Any ad package from $5 up earns <b>+' + c.pct + '% credits</b> today, and every pack pays its sponsor line in the same transaction. Buy yours, then watch the ledger fill up: https://instantadpay.com/my#buy').catch(() => {});
}
if (wd === 'Sat' && h < 3) {
const fri = dayOf(now - 86400000);
if (isFriday(now - 86400000) && fri >= c.start && !S.sent['wrap:' + fri] && await claimOnce('fri:wrap:' + fri)) {
S.sent['wrap:' + fri] = now; save();
const st = stats(fri); const top = await topLines(fri);
X.telegram('\u{1F3C1} <b>Five Dollar Friday wrap-up</b> (' + labelOf(fri) + ')\n<b>' + st.packs + ' packs</b> from ' + st.buyers + ' buyers · $' + st.usd.toFixed(0) + ' in ad packages · <b>' + st.polPaid.toFixed(2) + ' POL</b> paid to members · ' + st.bonusCredits.toLocaleString() + ' bonus credits handed out'
+ (top.length ? '\nBiggest lines today: ' + top.map(t => t.who + ' (' + t.packs + ')').join(', ') : '') + '\nNext one is a week away. Line it up: https://instantadpay.com/my#buy').catch(() => {});
}
}
}
// most packs bought by a sponsor's directs on a day (the line that pushed hardest)
async function topLines(dayKey) {
const by = new Map();
for (const ev of purchasesOn(dayKey)) { let sp = 0; try { const m = await X.chain.member(ev.buyerId); sp = m && m.sponsorId ? Number(m.sponsorId) : 0; } catch (e) {} if (!sp) continue; by.set(sp, (by.get(sp) || 0) + 1); }
const rows = [...by.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
const out = []; for (const [id, packs] of rows) { let who = '#' + id; try { const a = await X.accounts.byMemberId(id); if (a && a.username) who = '@' + a.username; } catch (e) {} out.push({ memberId: id, who, packs }); }
return out;
}
// on-site notice + email to every active member (quiet 45+ days skipped, drip unsubscribes honoured)
async function broadcast(subject, text) {
let all = []; try { all = await X.accounts.listAll(5000); } catch (e) { return; }
const now = Date.now(); let n = 0;
for (const a of all) {
if (!a.email) continue;
if (now - (a.lastSeen || a.created || 0) > 45 * 86400000) continue;
try { await X.notify(a.memberId, subject, text, 'notice'); } catch (e) {}
try { if (X.mailer && X.mailer.hasKey() && !(X.drip && await X.drip.isUnsubscribed(a.email))) { await X.mailer.send(a.email, subject, text + '\n\nInstantAdPay'); n++; } } catch (e) {}
}
return n;
}
module.exports = { init, cfg, view, stats, onEvent, tick, fridayStreak, earnedBadge, liveNow, nextFriday, isFriday, labelOf, topLines };