diff --git a/src/lib/wallet.js b/src/lib/wallet.js new file mode 100644 index 0000000..c0fde63 --- /dev/null +++ b/src/lib/wallet.js @@ -0,0 +1,70 @@ +"use strict"; + +class WalletStore { + constructor() { + this.balances = new Map(); + this.transactions = []; + this.byIdempotencyKey = new Map(); + this.nextId = 1; + } + + getBalance(userId) { + return this.balances.get(userId) || 0; + } + + listTransactions(userId) { + return this.transactions.filter((tx) => tx.userId === userId); + } + + applyTransaction({ userId, type, amount, reason, idempotencyKey }) { + if (!userId) { + throw new Error("userId_required"); + } + + if (!["credit", "debit", "refund"].includes(type)) { + throw new Error("invalid_transaction_type"); + } + + if (!Number.isInteger(amount) || amount <= 0) { + throw new Error("invalid_amount"); + } + + if (!idempotencyKey) { + throw new Error("idempotency_key_required"); + } + + const existing = this.byIdempotencyKey.get(idempotencyKey); + if (existing) { + return existing; + } + + const currentBalance = this.getBalance(userId); + + if (type === "debit" && currentBalance < amount) { + throw new Error("insufficient_credits"); + } + + const delta = (type === "debit") ? -amount : amount; + const newBalance = currentBalance + delta; + + const tx = { + id: String(this.nextId++), + userId, + type, + amount, + reason: reason || null, + idempotencyKey, + createdAt: new Date().toISOString(), + balanceAfter: newBalance, + }; + + this.transactions.push(tx); + this.byIdempotencyKey.set(idempotencyKey, tx); + this.balances.set(userId, newBalance); + return tx; + } +} + +module.exports = { + WalletStore, +}; diff --git a/test/wallet.test.js b/test/wallet.test.js new file mode 100644 index 0000000..5532869 --- /dev/null +++ b/test/wallet.test.js @@ -0,0 +1,63 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { WalletStore } = require("../src/lib/wallet"); + +test("applies credit and debit transactions", () => { + const wallet = new WalletStore(); + + wallet.applyTransaction({ + userId: "u1", + type: "credit", + amount: 5, + reason: "topup", + idempotencyKey: "evt-1", + }); + + wallet.applyTransaction({ + userId: "u1", + type: "debit", + amount: 2, + reason: "article_generation", + idempotencyKey: "evt-2", + }); + + assert.equal(wallet.getBalance("u1"), 3); +}); + +test("prevents overdraft debits", () => { + const wallet = new WalletStore(); + assert.throws(() => { + wallet.applyTransaction({ + userId: "u1", + type: "debit", + amount: 1, + reason: "article_generation", + idempotencyKey: "evt-3", + }); + }, /insufficient_credits/); +}); + +test("is idempotent by idempotency key", () => { + const wallet = new WalletStore(); + + const first = wallet.applyTransaction({ + userId: "u1", + type: "credit", + amount: 4, + reason: "topup", + idempotencyKey: "evt-4", + }); + + const second = wallet.applyTransaction({ + userId: "u1", + type: "credit", + amount: 999, + reason: "topup", + idempotencyKey: "evt-4", + }); + + assert.equal(first.id, second.id); + assert.equal(wallet.getBalance("u1"), 4); +});