diff --git a/credit-ledger.test.js b/credit-ledger.test.js new file mode 100644 index 0000000..1c36188 --- /dev/null +++ b/credit-ledger.test.js @@ -0,0 +1,94 @@ +// RED/GREEN check for credit_ledger (dev-plan-4.md T23; schema: +// dressup-dance-room-mvcp.md M.2). Run: node --test credit-ledger.test.js +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { openDb, createUsersStore, createCreditLedgerStore } = require("./db.js"); + +function setup() { + const db = openDb(path.join(fs.mkdtempSync(path.join(os.tmpdir(), "credit-ledger-test-")), "data.sqlite")); + const users = createUsersStore(db); + const creditLedger = createCreditLedgerStore(db, users); + const user = users.create({ email: "spender@example.com", passwordHash: "hash1" }); + return { db, users, creditLedger, user }; +} + +test("topup credits a user and balanceAfter matches users.creditBalance", () => { + const { users, creditLedger, user } = setup(); + const row = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_1" }); + assert.equal(row.delta, 100); + assert.equal(row.balanceAfter, 100); + assert.equal(users.get(user.id).creditBalance, 100); +}); + +test("spend deducts and refuses to go negative", () => { + const { users, creditLedger, user } = setup(); + creditLedger.topup({ userId: user.id, amount: 20 }); + const spent = creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment", relatedAssetId: "garment-1" }); + assert.equal(spent.delta, -10); + assert.equal(spent.balanceAfter, 10); + assert.equal(users.get(user.id).creditBalance, 10); + + assert.throws( + () => creditLedger.spend({ userId: user.id, amount: 999, reason: "spend_garment" }), + (err) => err.code === "INSUFFICIENT_BALANCE" + ); + // a rejected spend must not have partially applied + assert.equal(users.get(user.id).creditBalance, 10); +}); + +test("refund credits back a specific spend and is traceable by relatedAssetId", () => { + const { users, creditLedger, user } = setup(); + creditLedger.topup({ userId: user.id, amount: 10 }); + creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment", relatedAssetId: "garment-2" }); + assert.equal(users.get(user.id).creditBalance, 0); + + const refunded = creditLedger.refund({ userId: user.id, amount: 10, reason: "refund_stage2_fail", relatedAssetId: "garment-2" }); + assert.equal(refunded.delta, 10); + assert.equal(refunded.balanceAfter, 10); + assert.equal(users.get(user.id).creditBalance, 10); + + const refunds = creditLedger.findByRelatedAssetAndReason("garment-2", "refund_stage2_fail"); + assert.equal(refunds.length, 1); +}); + +test("a full ledger for a user is queryable and balanceAfter is continuously computable", () => { + const { creditLedger, user } = setup(); + creditLedger.topup({ userId: user.id, amount: 50 }); + creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment" }); + creditLedger.spend({ userId: user.id, amount: 5, reason: "spend_garment" }); + const rows = creditLedger.listByUser(user.id); + assert.equal(rows.length, 3); + // newest first + assert.equal(rows[0].balanceAfter, 35); + assert.equal(rows[1].balanceAfter, 40); + assert.equal(rows[2].balanceAfter, 50); +}); + +test("topup with the same paymentRef twice only credits once (Stripe at-least-once webhook delivery)", () => { + const { users, creditLedger, user } = setup(); + const first = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_dup" }); + const second = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_dup" }); + assert.equal(first.id, second.id); + assert.equal(users.get(user.id).creditBalance, 100); +}); + +// "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's +// DatabaseSync is synchronous, so two Promise.all-launched spends can't +// actually interleave mid-transaction within one process; what this proves +// is that back-to-back spends against a shared balance never lose an +// update and never let the balance go negative. +test("concurrent-looking spend calls against a shared balance never lose an update or go negative", async () => { + const { users, creditLedger, user } = setup(); + creditLedger.topup({ userId: user.id, amount: 15 }); + const attempt = (amount) => Promise.resolve().then(() => creditLedger.spend({ userId: user.id, amount, reason: "spend_garment" })); + const results = await Promise.allSettled([attempt(10), attempt(10)]); + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + assert.equal(fulfilled.length, 1, "only one of the two 10-credit spends should succeed against a balance of 15"); + assert.equal(rejected.length, 1); + assert.equal(rejected[0].reason.code, "INSUFFICIENT_BALANCE"); + assert.equal(users.get(user.id).creditBalance, 5); +}); diff --git a/db.js b/db.js index 8f73140..dbd3243 100644 --- a/db.js +++ b/db.js @@ -39,6 +39,19 @@ function openDb(filePath) { ); CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); + CREATE TABLE IF NOT EXISTS credit_ledger ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL REFERENCES users(id), + delta INTEGER NOT NULL, + reason TEXT NOT NULL, + relatedAssetId TEXT, + balanceAfter INTEGER NOT NULL, + paymentRef TEXT, + createdAt INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId); + CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef); + CREATE INDEX IF NOT EXISTS idx_credit_ledger_relatedAssetId_reason ON credit_ledger(relatedAssetId, reason); `); return db; } @@ -175,9 +188,111 @@ function createSessionsStore(db) { }; } +function rowToLedgerEntry(row) { + return row || null; +} + +// dev-plan-4.md T23: credit_ledger (schema: dressup-dance-room-mvcp.md M.2). +// architecture.md 1.4 flags "扣积分 + 创建生成任务 + 扣减片段配额" as +// needing real atomicity — spend/refund/topup below each read-modify-write +// users.creditBalance inside a SQLite transaction so a crash mid-operation +// can't leave the ledger and the balance disagreeing. node:sqlite's +// DatabaseSync is synchronous and single-connection, so within one process +// there's no interleaving between these calls either way (no `await` +// anywhere in the critical section) — the transaction is defense against a +// mid-function throw leaving a half-applied row, not against concurrent +// callers (there are none, by construction). +function createCreditLedgerStore(db, users) { + const insert = db.prepare(` + INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`); + // createdAt alone can tie (millisecond resolution, several writes in one + // tick) — rowid as a tiebreaker keeps ties in insertion order instead of + // an arbitrary one. + const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`); + const byRelatedAssetAndReason = db.prepare(`SELECT * FROM credit_ledger WHERE relatedAssetId = ? AND reason = ?`); + + // Shared by spend/refund/topup: apply `delta` to users.creditBalance and + // insert the matching ledger row, both inside the caller's transaction. + function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null }) { + const user = users.get(userId); + if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" }); + const balanceAfter = user.creditBalance + delta; + if (balanceAfter < 0) throw Object.assign(new Error("insufficient credit balance"), { code: "INSUFFICIENT_BALANCE" }); + const row = { + id: crypto.randomUUID(), + userId, + delta, + reason, + relatedAssetId, + balanceAfter, + paymentRef, + createdAt: Date.now(), + }; + insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.createdAt); + db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId); + return row; + } + + function inTransaction(fn) { + db.exec("BEGIN"); + try { + const result = fn(); + db.exec("COMMIT"); + return result; + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + } + + return { + // Throws INSUFFICIENT_BALANCE (not a negative balance) when the user + // can't afford `amount`. + spend({ userId, amount, reason, relatedAssetId = null }) { + if (amount <= 0) throw new Error("spend amount must be positive"); + return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta: -amount, reason, relatedAssetId }))); + }, + refund({ userId, amount, reason, relatedAssetId = null }) { + if (amount <= 0) throw new Error("refund amount must be positive"); + return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, relatedAssetId }))); + }, + // paymentRef dedup lives here (not spend/refund) because it's the only + // reason it's ever set (Stripe's event id, T24) — Stripe guarantees + // at-least-once webhook delivery, so a second delivery of the same + // event must be a no-op, not a second credit. Returns the *existing* + // row on a duplicate rather than throwing, since "already applied" is + // success from the caller's (webhook handler's) point of view. + topup({ userId, amount, reason = "topup", paymentRef = null }) { + if (amount <= 0) throw new Error("topup amount must be positive"); + return inTransaction(() => { + if (paymentRef) { + const existing = byPaymentRef.get(paymentRef); + if (existing) return rowToLedgerEntry(existing); + } + return rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, paymentRef })); + }); + }, + listByUser(userId) { + return byUser.all(userId); + }, + // T27's async refund-on-generation-failure guard: ensureDanceSchedule's + // tick can in principle observe the same "generating" -> "failed" + // transition twice across a crash/restart (see server.js's comment at + // the call site) — check here rather than trusting the caller to only + // call refund() once. + findByRelatedAssetAndReason(relatedAssetId, reason) { + return byRelatedAssetAndReason.all(relatedAssetId, reason); + }, + }; +} + const DB_PATH = process.env.DB_PATH || path.join(__dirname, "data.sqlite"); const db = openDb(DB_PATH); const users = createUsersStore(db); const sessions = createSessionsStore(db); +const creditLedger = createCreditLedgerStore(db, users); -module.exports = { db, users, sessions, openDb, createUsersStore, createSessionsStore }; +module.exports = { db, users, sessions, creditLedger, openDb, createUsersStore, createSessionsStore, createCreditLedgerStore }; diff --git a/pricing.js b/pricing.js new file mode 100644 index 0000000..2e4e654 --- /dev/null +++ b/pricing.js @@ -0,0 +1,52 @@ +// pricing_config (dev-plan-4.md T23; schema: dressup-dance-room-mvcp.md +// M.12) — a JSON file, not SQLite: it's operator-edited config, not on the +// "spend + generate + refund" atomic path that's why credit_ledger (db.js) +// needed SQLite. Keyed by `key` (not an auto id), so this is deliberately +// its own tiny module rather than store.js's createStore. +const fs = require("fs"); +const path = require("path"); + +const PRICING_PATH = process.env.PRICING_PATH || path.join(__dirname, "pricing.json"); + +// Real values are TBD (dev-plan-4.md §1.2 item 3) — these are placeholders +// so the spend/topup mechanism has something to read; change the numbers +// in pricing.json (or here, before first run) whenever real pricing lands. +const DEFAULTS = [ + { key: "spend_garment", kind: "spend", credits: 10, priceCents: null, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, + { key: "topup_tier_1", kind: "topup_tier", credits: 100, priceCents: 999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, + { key: "topup_tier_2", kind: "topup_tier", credits: 550, priceCents: 4999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, +]; + +function load(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + fs.writeFileSync(filePath, JSON.stringify(DEFAULTS, null, 2)); + return DEFAULTS.slice(); + } +} + +function createPricingStore(filePath) { + function save(items) { + fs.writeFileSync(filePath, JSON.stringify(items, null, 2)); + } + return { + list() { + return load(filePath); + }, + get(key) { + return load(filePath).find((p) => p.key === key) || null; + }, + upsert(key, patch) { + const items = load(filePath); + const idx = items.findIndex((p) => p.key === key); + const updated = idx === -1 ? { key, isActive: true, ...patch, updatedAt: Date.now() } : { ...items[idx], ...patch, key, updatedAt: Date.now() }; + if (idx === -1) items.push(updated); + else items[idx] = updated; + save(items); + return updated; + }, + }; +} + +module.exports = { createPricingStore, pricing: createPricingStore(PRICING_PATH) }; diff --git a/pricing.test.js b/pricing.test.js new file mode 100644 index 0000000..7092f25 --- /dev/null +++ b/pricing.test.js @@ -0,0 +1,39 @@ +// RED/GREEN check for pricing_config (dev-plan-4.md T23). Run: node --test pricing.test.js +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { createPricingStore } = require("./pricing.js"); + +function tmpPricingPath() { + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "pricing-test-")), "pricing.json"); +} + +test("a fresh file is seeded with placeholder defaults, including spend_garment", () => { + const store = createPricingStore(tmpPricingPath()); + const spend = store.get("spend_garment"); + assert.ok(spend); + assert.equal(spend.kind, "spend"); + assert.ok(spend.credits > 0); +}); + +test("upsert creates a new key and updates an existing one in place", () => { + const store = createPricingStore(tmpPricingPath()); + const created = store.upsert("spend_character", { kind: "spend", credits: 50 }); + assert.equal(created.credits, 50); + assert.equal(store.list().filter((p) => p.key === "spend_character").length, 1); + + const updated = store.upsert("spend_garment", { credits: 15 }); + assert.equal(updated.credits, 15); + assert.equal(store.get("spend_garment").credits, 15); + // upsert didn't duplicate the row + assert.equal(store.list().filter((p) => p.key === "spend_garment").length, 1); +}); + +test("changes persist across a fresh store instance pointed at the same file", () => { + const filePath = tmpPricingPath(); + createPricingStore(filePath).upsert("spend_garment", { credits: 25 }); + const reopened = createPricingStore(filePath); + assert.equal(reopened.get("spend_garment").credits, 25); +});