Commit 9ea1cfce51e857538af73bbb1a220b593d153f3f

Authored by yibo
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
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 +});
... ...
... ... @@ -39,6 +39,19 @@ function openDb(filePath) {
39 39 );
40 40 CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
41 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 56 return db;
44 57 }
... ... @@ -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 292 const DB_PATH = process.env.DB_PATH || path.join(__dirname, "data.sqlite");
179 293 const db = openDb(DB_PATH);
180 294 const users = createUsersStore(db);
181 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 +});
... ...