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>
This commit is contained in:
@@ -9,6 +9,12 @@ 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: [] },
|
||||
@@ -21,7 +27,9 @@ const J = {
|
||||
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 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; },
|
||||
@@ -33,7 +41,9 @@ const D = {
|
||||
},
|
||||
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) VALUES (?,?,?,?,?)', [r.code, r.email, r.credits, r.via, r.ts]); },
|
||||
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) });
|
||||
@@ -57,13 +67,31 @@ async function check(code, email) {
|
||||
if (c.maxUses && (await impl().uses(k)) >= c.maxUses) return { error: 'That promo code has been fully redeemed.' };
|
||||
return null;
|
||||
}
|
||||
// redeem for an account; the caller adds the credits. via = 'link' | 'dashboard'
|
||||
async function redeem(code, email, via) {
|
||||
// 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);
|
||||
await impl().addRedemption({ code: k, email: e, credits: c.credits, via: via || 'dashboard', ts: Date.now() });
|
||||
return { ok: true, credits: c.credits, code: k, partner: c.partner, funder: c.funder || null };
|
||||
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();
|
||||
@@ -71,4 +99,4 @@ async function adminView() {
|
||||
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, adminView, norm, byFunder };
|
||||
module.exports = { init, create, setActive, check, redeem, payDue, adminView, norm, byFunder, STEPS, WINDOW_DAYS };
|
||||
|
||||
Reference in New Issue
Block a user