Files
martbost 415a5f3848 Partner-code credits arrive as a drip; unclaimed installments expire
Marty (2026-09-19): partner codes keep working with no monthly cap, but the credits stop
being a dump. A fifth is paid the day the code is redeemed, then a fifth on each day the
member finishes their daily ad set, five days in all, and whatever is unclaimed 30 days after
redemption is never paid. Same headline number for the partner to promote; paid only to
people who show up. 49 of the 52 PARTNER redemptions were still sitting on 500+ unspent.

Every redemption now records the site the ?promo= link was opened from (iap.promoref cookie
set from the Referer at link-open), so a code's traffic can be attributed to the partner page
it was supposed to come from. Member-funded codes charge the funder one installment at a
time; a short funder leaves the installment due, not lost.

Pre-drip redemptions were paid in full up front and are marked paid=credits at start-up, so
nothing is paid twice. Covered by a module test (13 checks: per-day cadence, gaps, rounding,
expiry, pre-drip rows, declined payer) and an end-to-end HTTP test on a local copy (9 checks).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 06:24:14 -05:00

103 lines
8.6 KiB
JavaScript

// 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 };