diff --git a/db.js b/db.js index c0c3c8f..fb71476 100644 --- a/db.js +++ b/db.js @@ -156,6 +156,11 @@ async function bootstrap() { code VARCHAR(24) NOT NULL, email VARCHAR(190) NOT NULL, credits INT NOT NULL, via VARCHAR(12) NOT NULL, ts BIGINT NOT NULL, PRIMARY KEY (code, email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN paid INT NOT NULL DEFAULT 0'); // credits paid so far (the drip) + await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN last_day CHAR(10) NULL'); // Central day of the last installment + await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN expires BIGINT NOT NULL DEFAULT 0'); // unclaimed installments stop here; 0 = pre-drip row + await alterSafe0('ALTER TABLE promo_redemptions ADD COLUMN ref VARCHAR(80) NULL'); // site the ?promo= link was opened from + await q('UPDATE promo_redemptions SET paid=credits WHERE expires=0 AND paid 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 }; diff --git a/public/assets/my.js b/public/assets/my.js index 69bb540..ea5c3d2 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -2822,7 +2822,9 @@ try { const r = await (await fetch('/api/my/promo/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })).json(); if (r.error) { say(r.error, false); return; } - say('Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true); + say(r.step + ? Number(r.now || 0).toLocaleString() + ' credits added now' + (r.partner ? ' from ' + r.partner : '') + '. The rest of the ' + Number(r.credits).toLocaleString() + ' arrive ' + r.step + ' at a time, one for each day you finish your daily ads, for the next 30 days.' + : 'Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true); inp.value = ''; if (typeof loadDashboard === 'function') loadDashboard(); } catch (e) { say('Could not apply that code. Try again.', false); } finally { btn.disabled = false; } diff --git a/public/my.html b/public/my.html index dae27ac..51a2668 100644 --- a/public/my.html +++ b/public/my.html @@ -1037,7 +1037,7 @@ - + diff --git a/server.js b/server.js index 7712d76..2024c24 100644 --- a/server.js +++ b/server.js @@ -76,6 +76,7 @@ function dropAdminSession(req) { const t = adminTokenOf(req); if (t && adminSess function adminCookie(t) { return 'iap.adm=' + encodeURIComponent(t) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (ADMIN_TTL / 1000) + (IS_PROD ? '; Secure' : ''); } function clearAdminCookie() { return 'iap.adm=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; } const IS_PROD = process.env.NODE_ENV === 'production'; +const todayCT = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); // Marty's day, never UTC const SITE_FILE = path.join(DATA_DIR, 'site.json'); const db = require('./db'); @@ -701,6 +702,20 @@ async function sponsorGainNudge(sp, buyerName, lostNames) { await messages.deliver(1, ADMIN_EMAIL || 'house@instantadpay.com', [sp.email], subject, html, 'notice'); } catch (e) {} } +// Partner-code credits arrive as a drip (Marty, 2026-09-19): a fifth on the day the code is +// redeemed, then a fifth on each day the member finishes their daily ad set, five days in all, +// nothing after 30 days. Same headline number for the partner to promote; paid only to people +// who show up. Member-funded codes charge the funder one installment at a time. +async function payPromo(email, day) { + return promos.payDue(email, day, async (r, amount, n) => { + if (r.funder && r.funder !== email) { + const ok = await ads.spendEarned(r.funder, amount, { log: { kind: 'promo', note: 'Partner code ' + r.code + ' funded for ' + email + ' (day ' + n + ' of ' + promos.STEPS + ')' } }); + if (!ok) { console.log('promo funder short', r.code, r.funder); return false; } + } + await ads.addEarned(email, amount, { log: { kind: 'promo', note: 'Partner code ' + r.code + ': ' + amount + ' of ' + r.credits + ' (day ' + n + ' of ' + promos.STEPS + ')' } }); + return true; + }); +} // anti-fraud admin alert (Telegram admin chat, else email): who, which flags, and whether the sign-up was blocked function fraudAlert(email, fc, spAcct, blocked) { try { @@ -1053,6 +1068,7 @@ const server = http.createServer(async (req, res) => { const set = []; set.push('iap.sponsor=' + tok + cookieTail); // last touch wins const promo = promos.norm(u.searchParams.get('promo')); if (promo) set.push('iap.promo=' + promo + cookieTail); // partner code, redeemed at signup + if (promo) { const pref = String(req.headers.referer || '').replace(/^https?:\/\//, '').split('/')[0].toLowerCase().slice(0, 80); if (pref) set.push('iap.promoref=' + encodeURIComponent(pref) + cookieTail); } // which partner page it came from if (ang) set.push('iap.angle=' + angle + cookieTail); if (!cookies['iap.ref']) set.push('iap.ref=' + encodeURIComponent(coach.refHost(req.headers.referer)) + cookieTail); // first-touch source return serveJoinPage(res, tok, ang ? angle : '', ang, set); @@ -1305,7 +1321,11 @@ const server = http.createServer(async (req, res) => { // partner promo code carried on the join link: redeem once per account (ignored if invalid/used) try { const pc = parseCookies(req)['iap.promo']; - if (pc) { const g = await promos.redeem(pc, e, 'link'); if (g.ok) { if (g.funder && g.funder !== e) { if (await ads.spendEarned(g.funder, g.credits, { log: { kind: 'promo', note: 'Partner code ' + g.code + ' funded for ' + e } })) { await ads.addEarned(e, g.credits, { log: { kind: 'promo', note: 'Partner code ' + g.code } }); console.log('promo redeemed (member-funded)', g.code, g.credits, e, 'by', g.funder); } else console.log('promo funder short', g.code, g.funder); } else { await ads.addEarned(e, g.credits, { log: { kind: 'promo', note: 'Partner code ' + g.code } }); console.log('promo redeemed', g.code, g.credits, e); } } } + if (pc) { + const pref = parseCookies(req)['iap.promoref'] ? decodeURIComponent(parseCookies(req)['iap.promoref']) : ''; + const g = await promos.redeem(pc, e, 'link', pref); + if (g.ok) { const paid = await payPromo(e, todayCT()); console.log('promo redeemed', g.code, 'day 1:', paid.map(x => x.amount).join('+') || 0, 'of', g.credits, e, pref ? 'from ' + pref : ''); } + } } catch (err) { console.error('promo redeem', err.message); } // legacy bridge: a listed former Faucet Wave / Tier One Ads member gets welcome-back credits once if (r.created && /^(fw|t1)-(adv|earn)$/.test(via)) { @@ -1561,10 +1581,10 @@ const server = http.createServer(async (req, res) => { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const b = await readBody(req); - const g = await promos.redeem(b.code, s.email, 'dashboard'); + const g = await promos.redeem(b.code, s.email, 'dashboard', ''); if (g.error) return json(res, 400, g); - await ads.addEarned(s.email, g.credits, { log: { kind: 'promo', note: 'Partner code ' + String(b.code || '').toUpperCase() } }); - return json(res, 200, { ok: true, credits: g.credits, code: g.code, partner: g.partner }); + const paid = await payPromo(s.email, todayCT()); + return json(res, 200, { ok: true, credits: g.credits, now: paid.reduce((n, x) => n + x.amount, 0), step: g.step, steps: g.steps, code: g.code, partner: g.partner }); } // -- admin: member card. GET ?q= resolves email / @username / #id / share code / wallet; // PATCH edits username, sponsor, main wallet or grants credits; DELETE removes a free account (2026-09-13) @@ -2085,6 +2105,7 @@ const server = http.createServer(async (req, res) => { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const r = await ads.claimDaily(s.email); + if (!r.error) { try { const paid = await payPromo(s.email, todayCT()); if (paid.length) r.promo = paid; } catch (e) { console.error('promo drip', e.message); } } return json(res, r.error ? 400 : 200, r); } // -- downline lineage: 3 levels, usernames+IDs; email only for directs