// Partner promo codes (Marty, 2026-09-12): a reusable code per partner site owner that gives // members who redeem it free ad credits (earned-grade) on top of whatever they already get. // Redeemed automatically when someone joins through a link carrying ?promo=CODE, or typed into // the dashboard. One redemption per code per account; every redemption is logged; optional // cap (max uses) and expiry; codes can be switched off. Storage: MySQL promo_codes + // promo_redemptions, or DATA_DIR/promos.json. const fs = require('fs'); const path = require('path'); const db = require('./db'); let DATA_DIR = null; const norm = c => String(c || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '').slice(0, 24); // The drip (Marty, 2026-09-19): a code's credits arrive in STEPS installments, the first on the // day it is redeemed and one more on each day the member finishes their daily ad set. Whatever // is still unclaimed WINDOW_DAYS after redemption is never paid. const STEPS = 5; const WINDOW_DAYS = 30; const stepOf = credits => Math.ceil(Number(credits) / STEPS); const J = { db: { v: 1, codes: {}, redemptions: [] }, FILE() { return path.join(DATA_DIR, 'promos.json'); }, load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} }, save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }, async get(code) { return this.db.codes[code] || null; }, async list() { return Object.values(this.db.codes).sort((a, b) => b.created - a.created); }, async put(c) { this.db.codes[c.code] = Object.assign(this.db.codes[c.code] || {}, c); this.save(); return this.db.codes[c.code]; }, async uses(code) { return this.db.redemptions.filter(r => r.code === code).length; }, async redeemed(code, email) { return this.db.redemptions.some(r => r.code === code && r.email === email); }, async addRedemption(r) { this.db.redemptions.push(r); this.save(); }, async redemptions(code, n) { return this.db.redemptions.filter(r => !code || r.code === code).slice(-(n || 200)).reverse(); }, async forEmail(email) { return this.db.redemptions.filter(r => r.email === email); }, async markPaid(code, email, paid, lastDay) { const r = this.db.redemptions.find(x => x.code === code && x.email === email); if (r) { r.paid = paid; r.lastDay = lastDay; this.save(); } } }; const D = { async get(code) { const r = await db.q('SELECT * FROM promo_codes WHERE code=?', [code]); return r[0] ? row(r[0]) : null; }, async list() { return (await db.q('SELECT * FROM promo_codes ORDER BY created DESC')).map(row); }, async put(c) { await db.q('INSERT INTO promo_codes (code,credits,partner,note,max_uses,expires,active,created,funder) VALUES (?,?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE funder=VALUES(funder), credits=VALUES(credits), partner=VALUES(partner), note=VALUES(note), max_uses=VALUES(max_uses), expires=VALUES(expires), active=VALUES(active)', [c.code, c.credits, c.partner || null, c.note || null, c.maxUses || 0, c.expires || 0, c.active ? 1 : 0, c.created || Date.now(), c.funder || null]); return this.get(c.code); }, async uses(code) { const r = await db.q('SELECT COUNT(*) n FROM promo_redemptions WHERE code=?', [code]); return Number(r[0].n); }, async redeemed(code, email) { const r = await db.q('SELECT 1 FROM promo_redemptions WHERE code=? AND email=?', [code, email]); return r.length > 0; }, async addRedemption(r) { await db.q('INSERT IGNORE INTO promo_redemptions (code,email,credits,via,ts,paid,last_day,expires,ref) VALUES (?,?,?,?,?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts, r.paid || 0, r.lastDay || '', r.expires || 0, r.ref || null]); }, async forEmail(email) { return (await db.q('SELECT * FROM promo_redemptions WHERE email=?', [email])).map(x => ({ code: x.code, email: x.email, credits: Number(x.credits), via: x.via, ts: Number(x.ts), paid: Number(x.paid) || 0, lastDay: x.last_day || '', expires: Number(x.expires) || 0, ref: x.ref || '' })); }, async markPaid(code, email, paid, lastDay) { await db.q('UPDATE promo_redemptions SET paid=?, last_day=? WHERE code=? AND email=?', [paid, lastDay, code, email]); }, async redemptions(code, n) { const rows = code ? await db.q('SELECT * FROM promo_redemptions WHERE code=? ORDER BY ts DESC LIMIT ?', [code, Number(n) || 200]) : await db.q('SELECT * FROM promo_redemptions ORDER BY ts DESC LIMIT ?', [Number(n) || 200]); return rows.map(r => ({ code: r.code, email: r.email, credits: Number(r.credits), via: r.via, ts: Number(r.ts) })); } }; const row = r => ({ code: r.code, funder: r.funder || null, credits: Number(r.credits), partner: r.partner || '', note: r.note || '', maxUses: Number(r.max_uses) || 0, expires: Number(r.expires) || 0, active: !!Number(r.active), created: Number(r.created) }); const impl = () => db.enabled() ? D : J; function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); } async function create(c) { const code = norm(c.code); if (!code || code.length < 3) return { error: 'Code must be 3 to 24 letters or numbers.' }; const credits = Math.floor(Number(c.credits)); if (!(credits > 0) || credits > 100000) return { error: 'Credits must be between 1 and 100,000.' }; const cur = await impl().get(code); const saved = await impl().put({ code, credits, funder: c.funder ? String(c.funder).toLowerCase() : (cur && cur.funder) || null, partner: String(c.partner || '').slice(0, 80), note: String(c.note || '').slice(0, 200), maxUses: Math.max(0, Math.floor(Number(c.maxUses) || 0)), expires: c.expires ? Number(new Date(c.expires)) || 0 : 0, active: c.active !== false && c.active !== 0 && c.active !== '0', created: cur ? cur.created : Date.now() }); return { ok: true, code: saved }; } async function setActive(code, active) { const c = await impl().get(norm(code)); if (!c) return { error: 'No such code.' }; await impl().put(Object.assign(c, { active: !!active })); return { ok: true }; } // why a code cannot be used right now, or null when it can (email optional: skips the per-account check) async function check(code, email) { const k = norm(code); if (!k) return { error: 'Enter a promo code.' }; const c = await impl().get(k); if (!c || !c.active) return { error: 'That promo code is not valid.' }; if (c.expires && Date.now() > c.expires) return { error: 'That promo code has expired.' }; if (email && await impl().redeemed(k, email)) return { error: 'You already used that promo code.' }; if (c.maxUses && (await impl().uses(k)) >= c.maxUses) return { error: 'That promo code has been fully redeemed.' }; return null; } // redeem for an account: records the redemption; the caller then pays what is due today with // payDue(). via = 'link' | 'dashboard'; ref = the site the ?promo= link was opened from. async function redeem(code, email, via, ref) { const k = norm(code); const e = String(email || '').toLowerCase(); const bad = await check(k, e); if (bad) return bad; const c = await impl().get(k); const ts = Date.now(); await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts, paid: 0, lastDay: '', expires: ts + WINDOW_DAYS * 86400000, ref: String(ref || '').slice(0, 80) }); return { ok: true, credits: c.credits, step: stepOf(c.credits), steps: STEPS, code: k, partner: c.partner, funder: c.funder || null }; } // Pay every installment that is due today for this member. pay(rec, amount, n) moves the credits // (and charges a funder) and returns true when it did; a false leaves the installment for another // day. day is the member's calendar day (Central), so one installment per day, never two. async function payDue(email, day, pay) { const e = String(email || '').toLowerCase(); const out = []; for (const r of await impl().forEmail(e)) { if (!r.expires || r.paid >= r.credits || r.lastDay === day || Date.now() > r.expires) continue; const c = await impl().get(r.code); if (!c) continue; const amount = Math.min(stepOf(r.credits), r.credits - r.paid); const n = Math.floor(r.paid / stepOf(r.credits)) + 1; if (!(await pay(Object.assign({}, r, { funder: c.funder || null, partner: c.partner }), amount, n))) continue; await impl().markPaid(r.code, e, r.paid + amount, day); out.push({ code: r.code, amount, n, of: STEPS, total: r.credits, partner: c.partner }); } return out; } async function adminView() { const codes = await impl().list(); for (const c of codes) c.uses = await impl().uses(c.code); return { codes, recent: await impl().redemptions(null, 100) }; } async function byFunder(email) { return (await impl().list()).filter(c => c.funder === String(email || '').toLowerCase()); } module.exports = { init, create, setActive, check, redeem, payDue, adminView, norm, byFunder, STEPS, WINDOW_DAYS };