Commit 9ea1cfce51e857538af73bbb1a220b593d153f3f
1 parent
a36f15ac
Add T23: credit_ledger + pricing_config
credit_ledger (dev-plan-4.md T23; schema M.2) joins users/sessions in
the same SQLite db.js — architecture.md 1.4 named this as the other
half of why SQLite got picked in T18 ("扣积分 + 创建生成任务 + 扣减片段
配额" needs real atomicity, T18 only used the credential-storage half).
spend()/refund()/topup() each read-modify-write users.creditBalance
inside a transaction; spend() refuses to take a balance negative
(INSUFFICIENT_BALANCE) instead of allowing debt, and topup() dedupes
by paymentRef so a Stripe webhook's guaranteed at-least-once delivery
can't double-credit (T24 will rely on this).
pricing_config (M.12) stays a JSON file via a new pricing.js — it's
operator-edited config, not on the atomic spend path, so SQLite would
be over-engineering here; keyed by `key` rather than an id, hence its
own tiny module instead of store.js's createStore. Seeded with
placeholder credit amounts (dev-plan-4.md §1.2 item 3 — real numbers
are still TBD).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
4 changed files
with
301 additions
and
1 deletions
credit-ledger.test.js
0 → 100644
| 1 | +// RED/GREEN check for credit_ledger (dev-plan-4.md T23; schema: | ||
| 2 | +// dressup-dance-room-mvcp.md M.2). Run: node --test credit-ledger.test.js | ||
| 3 | +const test = require("node:test"); | ||
| 4 | +const assert = require("node:assert/strict"); | ||
| 5 | +const fs = require("node:fs"); | ||
| 6 | +const os = require("node:os"); | ||
| 7 | +const path = require("node:path"); | ||
| 8 | +const { openDb, createUsersStore, createCreditLedgerStore } = require("./db.js"); | ||
| 9 | + | ||
| 10 | +function setup() { | ||
| 11 | + const db = openDb(path.join(fs.mkdtempSync(path.join(os.tmpdir(), "credit-ledger-test-")), "data.sqlite")); | ||
| 12 | + const users = createUsersStore(db); | ||
| 13 | + const creditLedger = createCreditLedgerStore(db, users); | ||
| 14 | + const user = users.create({ email: "spender@example.com", passwordHash: "hash1" }); | ||
| 15 | + return { db, users, creditLedger, user }; | ||
| 16 | +} | ||
| 17 | + | ||
| 18 | +test("topup credits a user and balanceAfter matches users.creditBalance", () => { | ||
| 19 | + const { users, creditLedger, user } = setup(); | ||
| 20 | + const row = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_1" }); | ||
| 21 | + assert.equal(row.delta, 100); | ||
| 22 | + assert.equal(row.balanceAfter, 100); | ||
| 23 | + assert.equal(users.get(user.id).creditBalance, 100); | ||
| 24 | +}); | ||
| 25 | + | ||
| 26 | +test("spend deducts and refuses to go negative", () => { | ||
| 27 | + const { users, creditLedger, user } = setup(); | ||
| 28 | + creditLedger.topup({ userId: user.id, amount: 20 }); | ||
| 29 | + const spent = creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment", relatedAssetId: "garment-1" }); | ||
| 30 | + assert.equal(spent.delta, -10); | ||
| 31 | + assert.equal(spent.balanceAfter, 10); | ||
| 32 | + assert.equal(users.get(user.id).creditBalance, 10); | ||
| 33 | + | ||
| 34 | + assert.throws( | ||
| 35 | + () => creditLedger.spend({ userId: user.id, amount: 999, reason: "spend_garment" }), | ||
| 36 | + (err) => err.code === "INSUFFICIENT_BALANCE" | ||
| 37 | + ); | ||
| 38 | + // a rejected spend must not have partially applied | ||
| 39 | + assert.equal(users.get(user.id).creditBalance, 10); | ||
| 40 | +}); | ||
| 41 | + | ||
| 42 | +test("refund credits back a specific spend and is traceable by relatedAssetId", () => { | ||
| 43 | + const { users, creditLedger, user } = setup(); | ||
| 44 | + creditLedger.topup({ userId: user.id, amount: 10 }); | ||
| 45 | + creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment", relatedAssetId: "garment-2" }); | ||
| 46 | + assert.equal(users.get(user.id).creditBalance, 0); | ||
| 47 | + | ||
| 48 | + const refunded = creditLedger.refund({ userId: user.id, amount: 10, reason: "refund_stage2_fail", relatedAssetId: "garment-2" }); | ||
| 49 | + assert.equal(refunded.delta, 10); | ||
| 50 | + assert.equal(refunded.balanceAfter, 10); | ||
| 51 | + assert.equal(users.get(user.id).creditBalance, 10); | ||
| 52 | + | ||
| 53 | + const refunds = creditLedger.findByRelatedAssetAndReason("garment-2", "refund_stage2_fail"); | ||
| 54 | + assert.equal(refunds.length, 1); | ||
| 55 | +}); | ||
| 56 | + | ||
| 57 | +test("a full ledger for a user is queryable and balanceAfter is continuously computable", () => { | ||
| 58 | + const { creditLedger, user } = setup(); | ||
| 59 | + creditLedger.topup({ userId: user.id, amount: 50 }); | ||
| 60 | + creditLedger.spend({ userId: user.id, amount: 10, reason: "spend_garment" }); | ||
| 61 | + creditLedger.spend({ userId: user.id, amount: 5, reason: "spend_garment" }); | ||
| 62 | + const rows = creditLedger.listByUser(user.id); | ||
| 63 | + assert.equal(rows.length, 3); | ||
| 64 | + // newest first | ||
| 65 | + assert.equal(rows[0].balanceAfter, 35); | ||
| 66 | + assert.equal(rows[1].balanceAfter, 40); | ||
| 67 | + assert.equal(rows[2].balanceAfter, 50); | ||
| 68 | +}); | ||
| 69 | + | ||
| 70 | +test("topup with the same paymentRef twice only credits once (Stripe at-least-once webhook delivery)", () => { | ||
| 71 | + const { users, creditLedger, user } = setup(); | ||
| 72 | + const first = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_dup" }); | ||
| 73 | + const second = creditLedger.topup({ userId: user.id, amount: 100, paymentRef: "evt_dup" }); | ||
| 74 | + assert.equal(first.id, second.id); | ||
| 75 | + assert.equal(users.get(user.id).creditBalance, 100); | ||
| 76 | +}); | ||
| 77 | + | ||
| 78 | +// "并发发起两次扣积分请求" per T23's acceptance criteria — node:sqlite's | ||
| 79 | +// DatabaseSync is synchronous, so two Promise.all-launched spends can't | ||
| 80 | +// actually interleave mid-transaction within one process; what this proves | ||
| 81 | +// is that back-to-back spends against a shared balance never lose an | ||
| 82 | +// update and never let the balance go negative. | ||
| 83 | +test("concurrent-looking spend calls against a shared balance never lose an update or go negative", async () => { | ||
| 84 | + const { users, creditLedger, user } = setup(); | ||
| 85 | + creditLedger.topup({ userId: user.id, amount: 15 }); | ||
| 86 | + const attempt = (amount) => Promise.resolve().then(() => creditLedger.spend({ userId: user.id, amount, reason: "spend_garment" })); | ||
| 87 | + const results = await Promise.allSettled([attempt(10), attempt(10)]); | ||
| 88 | + const fulfilled = results.filter((r) => r.status === "fulfilled"); | ||
| 89 | + const rejected = results.filter((r) => r.status === "rejected"); | ||
| 90 | + assert.equal(fulfilled.length, 1, "only one of the two 10-credit spends should succeed against a balance of 15"); | ||
| 91 | + assert.equal(rejected.length, 1); | ||
| 92 | + assert.equal(rejected[0].reason.code, "INSUFFICIENT_BALANCE"); | ||
| 93 | + assert.equal(users.get(user.id).creditBalance, 5); | ||
| 94 | +}); |
db.js
| @@ -39,6 +39,19 @@ function openDb(filePath) { | @@ -39,6 +39,19 @@ function openDb(filePath) { | ||
| 39 | ); | 39 | ); |
| 40 | CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); | 40 | CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); |
| 41 | CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); | 41 | CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); |
| 42 | + CREATE TABLE IF NOT EXISTS credit_ledger ( | ||
| 43 | + id TEXT PRIMARY KEY, | ||
| 44 | + userId TEXT NOT NULL REFERENCES users(id), | ||
| 45 | + delta INTEGER NOT NULL, | ||
| 46 | + reason TEXT NOT NULL, | ||
| 47 | + relatedAssetId TEXT, | ||
| 48 | + balanceAfter INTEGER NOT NULL, | ||
| 49 | + paymentRef TEXT, | ||
| 50 | + createdAt INTEGER NOT NULL | ||
| 51 | + ); | ||
| 52 | + CREATE INDEX IF NOT EXISTS idx_credit_ledger_userId ON credit_ledger(userId); | ||
| 53 | + CREATE INDEX IF NOT EXISTS idx_credit_ledger_paymentRef ON credit_ledger(paymentRef); | ||
| 54 | + CREATE INDEX IF NOT EXISTS idx_credit_ledger_relatedAssetId_reason ON credit_ledger(relatedAssetId, reason); | ||
| 42 | `); | 55 | `); |
| 43 | return db; | 56 | return db; |
| 44 | } | 57 | } |
| @@ -175,9 +188,111 @@ function createSessionsStore(db) { | @@ -175,9 +188,111 @@ function createSessionsStore(db) { | ||
| 175 | }; | 188 | }; |
| 176 | } | 189 | } |
| 177 | 190 | ||
| 191 | +function rowToLedgerEntry(row) { | ||
| 192 | + return row || null; | ||
| 193 | +} | ||
| 194 | + | ||
| 195 | +// dev-plan-4.md T23: credit_ledger (schema: dressup-dance-room-mvcp.md M.2). | ||
| 196 | +// architecture.md 1.4 flags "扣积分 + 创建生成任务 + 扣减片段配额" as | ||
| 197 | +// needing real atomicity — spend/refund/topup below each read-modify-write | ||
| 198 | +// users.creditBalance inside a SQLite transaction so a crash mid-operation | ||
| 199 | +// can't leave the ledger and the balance disagreeing. node:sqlite's | ||
| 200 | +// DatabaseSync is synchronous and single-connection, so within one process | ||
| 201 | +// there's no interleaving between these calls either way (no `await` | ||
| 202 | +// anywhere in the critical section) — the transaction is defense against a | ||
| 203 | +// mid-function throw leaving a half-applied row, not against concurrent | ||
| 204 | +// callers (there are none, by construction). | ||
| 205 | +function createCreditLedgerStore(db, users) { | ||
| 206 | + const insert = db.prepare(` | ||
| 207 | + INSERT INTO credit_ledger (id, userId, delta, reason, relatedAssetId, balanceAfter, paymentRef, createdAt) | ||
| 208 | + VALUES (?, ?, ?, ?, ?, ?, ?, ?) | ||
| 209 | + `); | ||
| 210 | + const byPaymentRef = db.prepare(`SELECT * FROM credit_ledger WHERE paymentRef = ?`); | ||
| 211 | + // createdAt alone can tie (millisecond resolution, several writes in one | ||
| 212 | + // tick) — rowid as a tiebreaker keeps ties in insertion order instead of | ||
| 213 | + // an arbitrary one. | ||
| 214 | + const byUser = db.prepare(`SELECT * FROM credit_ledger WHERE userId = ? ORDER BY createdAt DESC, rowid DESC`); | ||
| 215 | + const byRelatedAssetAndReason = db.prepare(`SELECT * FROM credit_ledger WHERE relatedAssetId = ? AND reason = ?`); | ||
| 216 | + | ||
| 217 | + // Shared by spend/refund/topup: apply `delta` to users.creditBalance and | ||
| 218 | + // insert the matching ledger row, both inside the caller's transaction. | ||
| 219 | + function applyDelta({ userId, delta, reason, relatedAssetId = null, paymentRef = null }) { | ||
| 220 | + const user = users.get(userId); | ||
| 221 | + if (!user) throw Object.assign(new Error("no such user"), { code: "NO_SUCH_USER" }); | ||
| 222 | + const balanceAfter = user.creditBalance + delta; | ||
| 223 | + if (balanceAfter < 0) throw Object.assign(new Error("insufficient credit balance"), { code: "INSUFFICIENT_BALANCE" }); | ||
| 224 | + const row = { | ||
| 225 | + id: crypto.randomUUID(), | ||
| 226 | + userId, | ||
| 227 | + delta, | ||
| 228 | + reason, | ||
| 229 | + relatedAssetId, | ||
| 230 | + balanceAfter, | ||
| 231 | + paymentRef, | ||
| 232 | + createdAt: Date.now(), | ||
| 233 | + }; | ||
| 234 | + insert.run(row.id, row.userId, row.delta, row.reason, row.relatedAssetId, row.balanceAfter, row.paymentRef, row.createdAt); | ||
| 235 | + db.prepare(`UPDATE users SET creditBalance = ? WHERE id = ?`).run(balanceAfter, userId); | ||
| 236 | + return row; | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + function inTransaction(fn) { | ||
| 240 | + db.exec("BEGIN"); | ||
| 241 | + try { | ||
| 242 | + const result = fn(); | ||
| 243 | + db.exec("COMMIT"); | ||
| 244 | + return result; | ||
| 245 | + } catch (err) { | ||
| 246 | + db.exec("ROLLBACK"); | ||
| 247 | + throw err; | ||
| 248 | + } | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + return { | ||
| 252 | + // Throws INSUFFICIENT_BALANCE (not a negative balance) when the user | ||
| 253 | + // can't afford `amount`. | ||
| 254 | + spend({ userId, amount, reason, relatedAssetId = null }) { | ||
| 255 | + if (amount <= 0) throw new Error("spend amount must be positive"); | ||
| 256 | + return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta: -amount, reason, relatedAssetId }))); | ||
| 257 | + }, | ||
| 258 | + refund({ userId, amount, reason, relatedAssetId = null }) { | ||
| 259 | + if (amount <= 0) throw new Error("refund amount must be positive"); | ||
| 260 | + return inTransaction(() => rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, relatedAssetId }))); | ||
| 261 | + }, | ||
| 262 | + // paymentRef dedup lives here (not spend/refund) because it's the only | ||
| 263 | + // reason it's ever set (Stripe's event id, T24) — Stripe guarantees | ||
| 264 | + // at-least-once webhook delivery, so a second delivery of the same | ||
| 265 | + // event must be a no-op, not a second credit. Returns the *existing* | ||
| 266 | + // row on a duplicate rather than throwing, since "already applied" is | ||
| 267 | + // success from the caller's (webhook handler's) point of view. | ||
| 268 | + topup({ userId, amount, reason = "topup", paymentRef = null }) { | ||
| 269 | + if (amount <= 0) throw new Error("topup amount must be positive"); | ||
| 270 | + return inTransaction(() => { | ||
| 271 | + if (paymentRef) { | ||
| 272 | + const existing = byPaymentRef.get(paymentRef); | ||
| 273 | + if (existing) return rowToLedgerEntry(existing); | ||
| 274 | + } | ||
| 275 | + return rowToLedgerEntry(applyDelta({ userId, delta: amount, reason, paymentRef })); | ||
| 276 | + }); | ||
| 277 | + }, | ||
| 278 | + listByUser(userId) { | ||
| 279 | + return byUser.all(userId); | ||
| 280 | + }, | ||
| 281 | + // T27's async refund-on-generation-failure guard: ensureDanceSchedule's | ||
| 282 | + // tick can in principle observe the same "generating" -> "failed" | ||
| 283 | + // transition twice across a crash/restart (see server.js's comment at | ||
| 284 | + // the call site) — check here rather than trusting the caller to only | ||
| 285 | + // call refund() once. | ||
| 286 | + findByRelatedAssetAndReason(relatedAssetId, reason) { | ||
| 287 | + return byRelatedAssetAndReason.all(relatedAssetId, reason); | ||
| 288 | + }, | ||
| 289 | + }; | ||
| 290 | +} | ||
| 291 | + | ||
| 178 | const DB_PATH = process.env.DB_PATH || path.join(__dirname, "data.sqlite"); | 292 | const DB_PATH = process.env.DB_PATH || path.join(__dirname, "data.sqlite"); |
| 179 | const db = openDb(DB_PATH); | 293 | const db = openDb(DB_PATH); |
| 180 | const users = createUsersStore(db); | 294 | const users = createUsersStore(db); |
| 181 | const sessions = createSessionsStore(db); | 295 | const sessions = createSessionsStore(db); |
| 296 | +const creditLedger = createCreditLedgerStore(db, users); | ||
| 182 | 297 | ||
| 183 | -module.exports = { db, users, sessions, openDb, createUsersStore, createSessionsStore }; | 298 | +module.exports = { db, users, sessions, creditLedger, openDb, createUsersStore, createSessionsStore, createCreditLedgerStore }; |
pricing.js
0 → 100644
| 1 | +// pricing_config (dev-plan-4.md T23; schema: dressup-dance-room-mvcp.md | ||
| 2 | +// M.12) — a JSON file, not SQLite: it's operator-edited config, not on the | ||
| 3 | +// "spend + generate + refund" atomic path that's why credit_ledger (db.js) | ||
| 4 | +// needed SQLite. Keyed by `key` (not an auto id), so this is deliberately | ||
| 5 | +// its own tiny module rather than store.js's createStore. | ||
| 6 | +const fs = require("fs"); | ||
| 7 | +const path = require("path"); | ||
| 8 | + | ||
| 9 | +const PRICING_PATH = process.env.PRICING_PATH || path.join(__dirname, "pricing.json"); | ||
| 10 | + | ||
| 11 | +// Real values are TBD (dev-plan-4.md §1.2 item 3) — these are placeholders | ||
| 12 | +// so the spend/topup mechanism has something to read; change the numbers | ||
| 13 | +// in pricing.json (or here, before first run) whenever real pricing lands. | ||
| 14 | +const DEFAULTS = [ | ||
| 15 | + { key: "spend_garment", kind: "spend", credits: 10, priceCents: null, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, | ||
| 16 | + { key: "topup_tier_1", kind: "topup_tier", credits: 100, priceCents: 999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, | ||
| 17 | + { key: "topup_tier_2", kind: "topup_tier", credits: 550, priceCents: 4999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, | ||
| 18 | +]; | ||
| 19 | + | ||
| 20 | +function load(filePath) { | ||
| 21 | + try { | ||
| 22 | + return JSON.parse(fs.readFileSync(filePath, "utf8")); | ||
| 23 | + } catch { | ||
| 24 | + fs.writeFileSync(filePath, JSON.stringify(DEFAULTS, null, 2)); | ||
| 25 | + return DEFAULTS.slice(); | ||
| 26 | + } | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +function createPricingStore(filePath) { | ||
| 30 | + function save(items) { | ||
| 31 | + fs.writeFileSync(filePath, JSON.stringify(items, null, 2)); | ||
| 32 | + } | ||
| 33 | + return { | ||
| 34 | + list() { | ||
| 35 | + return load(filePath); | ||
| 36 | + }, | ||
| 37 | + get(key) { | ||
| 38 | + return load(filePath).find((p) => p.key === key) || null; | ||
| 39 | + }, | ||
| 40 | + upsert(key, patch) { | ||
| 41 | + const items = load(filePath); | ||
| 42 | + const idx = items.findIndex((p) => p.key === key); | ||
| 43 | + const updated = idx === -1 ? { key, isActive: true, ...patch, updatedAt: Date.now() } : { ...items[idx], ...patch, key, updatedAt: Date.now() }; | ||
| 44 | + if (idx === -1) items.push(updated); | ||
| 45 | + else items[idx] = updated; | ||
| 46 | + save(items); | ||
| 47 | + return updated; | ||
| 48 | + }, | ||
| 49 | + }; | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +module.exports = { createPricingStore, pricing: createPricingStore(PRICING_PATH) }; |
pricing.test.js
0 → 100644
| 1 | +// RED/GREEN check for pricing_config (dev-plan-4.md T23). Run: node --test pricing.test.js | ||
| 2 | +const test = require("node:test"); | ||
| 3 | +const assert = require("node:assert/strict"); | ||
| 4 | +const fs = require("node:fs"); | ||
| 5 | +const os = require("node:os"); | ||
| 6 | +const path = require("node:path"); | ||
| 7 | +const { createPricingStore } = require("./pricing.js"); | ||
| 8 | + | ||
| 9 | +function tmpPricingPath() { | ||
| 10 | + return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "pricing-test-")), "pricing.json"); | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +test("a fresh file is seeded with placeholder defaults, including spend_garment", () => { | ||
| 14 | + const store = createPricingStore(tmpPricingPath()); | ||
| 15 | + const spend = store.get("spend_garment"); | ||
| 16 | + assert.ok(spend); | ||
| 17 | + assert.equal(spend.kind, "spend"); | ||
| 18 | + assert.ok(spend.credits > 0); | ||
| 19 | +}); | ||
| 20 | + | ||
| 21 | +test("upsert creates a new key and updates an existing one in place", () => { | ||
| 22 | + const store = createPricingStore(tmpPricingPath()); | ||
| 23 | + const created = store.upsert("spend_character", { kind: "spend", credits: 50 }); | ||
| 24 | + assert.equal(created.credits, 50); | ||
| 25 | + assert.equal(store.list().filter((p) => p.key === "spend_character").length, 1); | ||
| 26 | + | ||
| 27 | + const updated = store.upsert("spend_garment", { credits: 15 }); | ||
| 28 | + assert.equal(updated.credits, 15); | ||
| 29 | + assert.equal(store.get("spend_garment").credits, 15); | ||
| 30 | + // upsert didn't duplicate the row | ||
| 31 | + assert.equal(store.list().filter((p) => p.key === "spend_garment").length, 1); | ||
| 32 | +}); | ||
| 33 | + | ||
| 34 | +test("changes persist across a fresh store instance pointed at the same file", () => { | ||
| 35 | + const filePath = tmpPricingPath(); | ||
| 36 | + createPricingStore(filePath).upsert("spend_garment", { credits: 25 }); | ||
| 37 | + const reopened = createPricingStore(filePath); | ||
| 38 | + assert.equal(reopened.get("spend_garment").credits, 25); | ||
| 39 | +}); |