Welcome credits (spec 8b earned pool v1) + official rehearsal rate card

Every account gets welcomeCredits (25, rate-card configurable) via an
idempotent lazy grant on dashboard load, so existing members receive theirs
retroactively. earned_credits table with JSON fallback; Overview stat shows
the purchased/welcome split. Chatbot facts and canned answers synced;
homepage free-membership list mentions the welcome batch. Rate card
promoted from placeholder to decided and persisted in the volume.
Spending integration for the earned pool is the next engine build.
Assets v=20260905b.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 05:48:42 -05:00
parent 013a88b7d0
commit d5bb74db7a
9 changed files with 73 additions and 25 deletions
+40 -2
View File
@@ -20,7 +20,8 @@ function rates() {
bannerBatch: 10, bannerCreditsPerBatch: 2,
textBatch: 10, textCreditsPerBatch: 1,
loginCreditsPerDay: 100,
burnBatchMin: 50
burnBatchMin: 50,
welcomeCredits: 25 // PLACEHOLDER until Marty sets the rate card (spec §8b earned pool)
}, saved);
}
function setRates(patch) {
@@ -240,6 +241,43 @@ const D = {
}
};
// ---- earned/welcome credits (spec §8b second pool: engine-side, never
// on-chain; welcome grant is idempotent and lazy so existing accounts get
// theirs on next dashboard load) ----
const EJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'earned.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
async function earnedBalance(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
if (db.enabled()) {
const r = await db.q('SELECT balance FROM earned_credits WHERE email=?', [e]);
return r.length ? r[0].balance : 0;
}
if (!EJ.db) EJ.load();
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
async function grantWelcome(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
const amount = rates().welcomeCredits || 0;
if (db.enabled()) {
await db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,1,?)
ON DUPLICATE KEY UPDATE balance = balance + IF(granted_welcome=0, ?, 0),
granted_welcome = 1, updated = VALUES(updated)`, [e, amount, Date.now(), amount]);
return earnedBalance(e);
}
if (!EJ.db) EJ.load();
if (!EJ.db[e] || !EJ.db[e].welcomed) {
EJ.db[e] = { balance: ((EJ.db[e] && EJ.db[e].balance) || 0) + amount, welcomed: true };
EJ.save();
}
return (EJ.db[e] && EJ.db[e].balance) || 0;
}
const impl = () => db.enabled() ? D : J;
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); }
@@ -266,4 +304,4 @@ async function pendingBurns() { return impl().pendingBurns(); }
async function markBurned(id, tx) { return impl().markBurned(id, tx); }
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
serve, click, dailySweep, availableCredits, pendingBurns, markBurned };
serve, click, dailySweep, availableCredits, earnedBalance, grantWelcome, pendingBurns, markBurned };