diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 76aae95..52546a0 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -37,6 +37,13 @@ jobs: sudo apt-get install k6 - name: Start server + env: + PORT: '3000' + # 10 VUs × 3 endpoints over ~2 min exceeds the production defaults + # (100 req/min general, 10 req/min evaluate) and turns the smoke + # into a rate-limit test. CI needs the paths themselves to pass. + RATE_LIMIT_MAX_REQUESTS: '100000' + RATE_LIMIT_MAX_EVALUATE: '100000' run: | npm start & echo $! > server.pid diff --git a/migrations/003_add_utilized_to_credit_lines.sql b/migrations/003_add_utilized_to_credit_lines.sql new file mode 100644 index 0000000..1cb96a6 --- /dev/null +++ b/migrations/003_add_utilized_to_credit_lines.sql @@ -0,0 +1,9 @@ +-- Persist the off-chain utilized balance on credit_lines so draw/repay can +-- update state, ledger, and audit in one transaction without deriving the +-- figure solely from an eventually-consistent SUM of transactions. + +ALTER TABLE credit_lines +ADD COLUMN utilized NUMERIC(28,8) NOT NULL DEFAULT 0; + +COMMENT ON COLUMN credit_lines.utilized IS + 'Current utilized credit; mutated atomically with ledger and audit writes on draw/repay'; diff --git a/scripts/load/smoke.js b/scripts/load/smoke.js index 5050d7b..512aa6b 100644 --- a/scripts/load/smoke.js +++ b/scripts/load/smoke.js @@ -20,19 +20,28 @@ export const options = { }; const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +// Valid Ed25519-strkey shape (G + 55 base32 chars). Used only as a payload; +// the rules-engine provider does not talk to Horizon in CI. +const VALID_WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +function envelopeData(body) { + try { + const parsed = JSON.parse(body); + if (parsed && typeof parsed === 'object' && parsed.data != null) { + return parsed.data; + } + return parsed; + } catch { + return null; + } +} export default function () { // Test 1: Health check let healthRes = http.get(`${BASE_URL}/health`); check(healthRes, { 'health status is 200': (r) => r.status === 200, - 'health response has status ok': (r) => { - try { - return JSON.parse(r.body).status === 'ok'; - } catch { - return false; - } - }, + 'health response has status ok': (r) => envelopeData(r.body)?.status === 'ok', }) || errorRate.add(1); sleep(0.5); @@ -41,21 +50,15 @@ export default function () { let listRes = http.get(`${BASE_URL}/api/credit/lines?offset=0&limit=10`); check(listRes, { 'list credit lines status is 200': (r) => r.status === 200, - 'list response has creditLines array': (r) => { - try { - const body = JSON.parse(r.body); - return Array.isArray(body.creditLines); - } catch { - return false; - } - }, + 'list response has creditLines array': (r) => + Array.isArray(envelopeData(r.body)?.creditLines), }) || errorRate.add(1); sleep(0.5); // Test 3: Risk evaluation const riskPayload = JSON.stringify({ - walletAddress: 'GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDE', + walletAddress: VALID_WALLET, }); const riskParams = { @@ -67,14 +70,8 @@ export default function () { let riskRes = http.post(`${BASE_URL}/api/risk/evaluate`, riskPayload, riskParams); check(riskRes, { 'risk evaluate status is 200': (r) => r.status === 200, - 'risk response has walletAddress': (r) => { - try { - const body = JSON.parse(r.body); - return body.walletAddress !== undefined; - } catch { - return false; - } - }, + 'risk response has walletAddress': (r) => + envelopeData(r.body)?.walletAddress !== undefined, }) || errorRate.add(1); sleep(1); diff --git a/src/container/Container.ts b/src/container/Container.ts index ffe1b39..bd00594 100644 --- a/src/container/Container.ts +++ b/src/container/Container.ts @@ -19,11 +19,20 @@ import { type CreditLineRepository } from "../repositories/interfaces/CreditLineRepository.js"; import { type RiskEvaluationRepository } from "../repositories/interfaces/RiskEvaluationRepository.js"; import { type TransactionRepository } from "../repositories/interfaces/TransactionRepository.js"; +import { type AuditEventRepository } from "../repositories/interfaces/AuditEventRepository.js"; import { getConnection, type DbClient } from "../db/client.js"; +import { + createDbTransactionRunner, + passthroughTransactionRunner, + type TransactionRunner, +} from "../db/transaction.js"; import { InMemoryCreditLineRepository } from "../repositories/memory/InMemoryCreditLineRepository.js"; import { InMemoryRiskEvaluationRepository } from "../repositories/memory/InMemoryRiskEvaluationRepository.js"; import { InMemoryTransactionRepository } from "../repositories/memory/InMemoryTransactionRepository.js"; +import { InMemoryAuditEventRepository } from "../repositories/memory/InMemoryAuditEventRepository.js"; import { PostgresCreditLineRepository } from "../repositories/postgres/PostgresCreditLineRepository.js"; +import { PostgresTransactionRepository } from "../repositories/postgres/PostgresTransactionRepository.js"; +import { PostgresAuditEventRepository } from "../repositories/postgres/PostgresAuditEventRepository.js"; import { CreditLineService } from "../services/CreditLineService.js"; import { RiskEvaluationService } from "../services/RiskEvaluationService.js"; import { createRiskProvider } from "../services/providers/providerFactory.js"; @@ -42,9 +51,11 @@ export class Container { private _creditLineRepository!: CreditLineRepository; private _riskEvaluationRepository!: RiskEvaluationRepository; private _transactionRepository!: TransactionRepository; + private _auditEventRepository!: AuditEventRepository; + private _runInTransaction: TransactionRunner = passthroughTransactionRunner; // Services - private _creditLineService: CreditLineService; + private _creditLineService!: CreditLineService; private _riskEvaluationService: RiskEvaluationService; private _reconciliationService: ReconciliationService; private _reconciliationWorker: ReconciliationWorker; @@ -53,8 +64,7 @@ export class Container { // Initialize repositories based on environment this.initializeRepositories(); - // Initialize services - this._creditLineService = new CreditLineService(this._creditLineRepository); + this.rebuildCreditLineService(); this._riskEvaluationService = new RiskEvaluationService( this._riskEvaluationRepository, createRiskProvider(), @@ -78,20 +88,31 @@ export class Container { const useDatabase = process.env.DATABASE_URL && process.env.NODE_ENV !== 'test'; if (useDatabase) { - // Use PostgreSQL repositories + // Use PostgreSQL repositories sharing one client so BEGIN/COMMIT covers + // credit-line state, ledger rows, and audit events together. this._dbClient = getConnection(); + this._runInTransaction = createDbTransactionRunner(this._dbClient); this._creditLineRepository = new PostgresCreditLineRepository(this._dbClient); - // TODO: Implement PostgreSQL versions of other repositories this._riskEvaluationRepository = new InMemoryRiskEvaluationRepository(); - this._transactionRepository = new InMemoryTransactionRepository(); + this._transactionRepository = new PostgresTransactionRepository(this._dbClient); + this._auditEventRepository = new PostgresAuditEventRepository(this._dbClient); } else { // Use in-memory repositories (for development/testing) this._creditLineRepository = new InMemoryCreditLineRepository(); this._riskEvaluationRepository = new InMemoryRiskEvaluationRepository(); this._transactionRepository = new InMemoryTransactionRepository(); + this._auditEventRepository = new InMemoryAuditEventRepository(); } } + private rebuildCreditLineService(): void { + this._creditLineService = new CreditLineService(this._creditLineRepository, { + transactionRepository: this._transactionRepository, + auditEventRepository: this._auditEventRepository, + runInTransaction: this._runInTransaction, + }); + } + public static getInstance(): Container { if (!Container.instance) { Container.instance = new Container(); @@ -137,9 +158,6 @@ export class Container { }): void { if (repositories.creditLineRepository) { this._creditLineRepository = repositories.creditLineRepository; - this._creditLineService = new CreditLineService( - this._creditLineRepository, - ); } if (repositories.riskEvaluationRepository) { @@ -153,6 +171,10 @@ export class Container { if (repositories.transactionRepository) { this._transactionRepository = repositories.transactionRepository; } + + if (repositories.creditLineRepository || repositories.transactionRepository) { + this.rebuildCreditLineService(); + } } /** diff --git a/src/db/migrations.test.ts b/src/db/migrations.test.ts index c366b53..65fd04d 100644 --- a/src/db/migrations.test.ts +++ b/src/db/migrations.test.ts @@ -92,12 +92,14 @@ describe('applyMigration', () => { describe('runPendingMigrations', () => { it('skips already applied migrations', async () => { const client = createMockClient(); - vi.mocked(client.query) - .mockResolvedValueOnce({ rows: [] }) - .mockResolvedValueOnce({ rows: [{ version: '001_initial_schema' }, { version: '002_add_interest_rate_to_credit_lines' }] }); const migrationsDir = await import('path').then((p) => p.join(process.cwd(), 'migrations') ); + const files = await listMigrationFiles(migrationsDir); + const applied = files.map((f) => versionFromFilename(f)).map((version) => ({ version })); + vi.mocked(client.query) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: applied }); const run = await runPendingMigrations(client, migrationsDir); expect(run).toEqual([]); }); @@ -113,7 +115,8 @@ describe('runPendingMigrations', () => { const run = await runPendingMigrations(client, migrationsDir); expect(run).toContain('001_initial_schema'); expect(run).toContain('002_add_interest_rate_to_credit_lines'); - expect(run.length).toBeGreaterThanOrEqual(2); + expect(run).toContain('003_add_utilized_to_credit_lines'); + expect(run.length).toBeGreaterThanOrEqual(3); }); it('applies only new migrations when some are already applied', async () => { diff --git a/src/db/transaction.test.ts b/src/db/transaction.test.ts new file mode 100644 index 0000000..bced252 --- /dev/null +++ b/src/db/transaction.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { DbClient } from './client.js'; +import { + withTransaction, + createDbTransactionRunner, + passthroughTransactionRunner, +} from './transaction.js'; + +function createRecordingClient(options?: { + /** Fail when this SQL fragment is seen (case-insensitive). */ + failOn?: string | RegExp; + /** Fail on the Nth non-control query (1-based). Control = BEGIN/COMMIT/ROLLBACK. */ + failOnDataQuery?: number; +}): DbClient & { statements: string[] } { + const statements: string[] = []; + let dataQueryCount = 0; + + const client: DbClient & { statements: string[] } = { + statements, + async query(text: string) { + statements.push(text); + const normalized = text.trim().toUpperCase(); + const isControl = + normalized === 'BEGIN' || + normalized === 'COMMIT' || + normalized === 'ROLLBACK'; + + if (!isControl) { + dataQueryCount += 1; + if ( + options?.failOnDataQuery !== undefined && + dataQueryCount === options.failOnDataQuery + ) { + throw new Error(`injected failure on data query #${dataQueryCount}`); + } + } + + if (options?.failOn) { + const pattern = + typeof options.failOn === 'string' + ? new RegExp(options.failOn, 'i') + : options.failOn; + if (pattern.test(text)) { + throw new Error(`injected failure on: ${text}`); + } + } + + return { rows: [] }; + }, + async end() { + /* no-op */ + }, + }; + + return client; +} + +describe('withTransaction', () => { + it('runs work without control statements when client is undefined', async () => { + const result = await withTransaction(undefined, async () => 42); + expect(result).toBe(42); + }); + + it('commits after successful work', async () => { + const client = createRecordingClient(); + const result = await withTransaction(client, async () => { + await client.query('INSERT INTO t VALUES (1)'); + return 'ok'; + }); + + expect(result).toBe('ok'); + expect(client.statements).toEqual([ + 'BEGIN', + 'INSERT INTO t VALUES (1)', + 'COMMIT', + ]); + }); + + it('rolls back and rethrows when work fails', async () => { + const client = createRecordingClient(); + + await expect( + withTransaction(client, async () => { + await client.query('INSERT INTO t VALUES (1)'); + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + expect(client.statements).toEqual([ + 'BEGIN', + 'INSERT INTO t VALUES (1)', + 'ROLLBACK', + ]); + expect(client.statements).not.toContain('COMMIT'); + }); + + it('rolls back when a mid-flow data query fails (failure injection)', async () => { + const client = createRecordingClient({ failOnDataQuery: 2 }); + + await expect( + withTransaction(client, async () => { + await client.query('UPDATE credit_lines SET utilized = $1'); + await client.query('INSERT INTO transactions ...'); + return 'unreachable'; + }), + ).rejects.toThrow('injected failure on data query #2'); + + expect(client.statements[0]).toBe('BEGIN'); + expect(client.statements.at(-1)).toBe('ROLLBACK'); + expect(client.statements).not.toContain('COMMIT'); + }); + + it('prefers the original error if ROLLBACK itself fails', async () => { + const statements: string[] = []; + const client: DbClient = { + async query(text: string) { + statements.push(text); + const n = text.trim().toUpperCase(); + if (n === 'ROLLBACK') { + throw new Error('rollback failed'); + } + return { rows: [] }; + }, + async end() { + /* no-op */ + }, + }; + + await expect( + withTransaction(client, async () => { + throw new Error('work failed'); + }), + ).rejects.toThrow('work failed'); + + expect(statements).toEqual(['BEGIN', 'ROLLBACK']); + }); +}); + +describe('createDbTransactionRunner', () => { + it('delegates to withTransaction on the given client', async () => { + const client = createRecordingClient(); + const run = createDbTransactionRunner(client); + const value = await run(async () => { + await client.query('SELECT 1'); + return 7; + }); + expect(value).toBe(7); + expect(client.statements).toEqual(['BEGIN', 'SELECT 1', 'COMMIT']); + }); +}); + +describe('passthroughTransactionRunner', () => { + it('executes work without a database client', async () => { + const spy = vi.fn(async () => 'done'); + await expect(passthroughTransactionRunner(spy)).resolves.toBe('done'); + expect(spy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/db/transaction.ts b/src/db/transaction.ts new file mode 100644 index 0000000..cc02680 --- /dev/null +++ b/src/db/transaction.ts @@ -0,0 +1,59 @@ +import type { DbClient } from './client.js'; + +/** + * Run `work` inside a PostgreSQL transaction on `client`. + * + * Lifecycle: + * 1. `BEGIN` + * 2. await `work()` + * 3. `COMMIT` on success, or `ROLLBACK` on any thrown error (re-thrown) + * + * When `client` is `undefined` (in-memory / test wiring without Postgres), + * `work` runs without BEGIN/COMMIT — there is no multi-connection ledger to + * keep consistent. Callers that need true atomicity must supply a real + * {@link DbClient}. + * + * Nested usage on the same connection is **not** supported (plain BEGIN, no + * SAVEPOINT). Service methods that already call `withTransaction` must not + * nest another transaction on the same client. + * + * Keep `work` short: no HTTP, Soroban RPC, or webhook I/O inside the boundary. + */ +export async function withTransaction( + client: DbClient | undefined, + work: () => Promise, +): Promise { + if (!client) { + return work(); + } + + await client.query('BEGIN'); + try { + const result = await work(); + await client.query('COMMIT'); + return result; + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch { + // Prefer the original failure over a secondary rollback error. + } + throw error; + } +} + +/** + * Injectable transaction boundary used by domain services. + * + * Production wires {@link createDbTransactionRunner}; tests inject a no-op + * or a failure-injecting runner without needing a live Postgres connection. + */ +export type TransactionRunner = (work: () => Promise) => Promise; + +/** No-op runner: executes work immediately (in-memory repositories / unit tests). */ +export const passthroughTransactionRunner: TransactionRunner = async (work) => work(); + +/** Build a runner that wraps work in `BEGIN`/`COMMIT`/`ROLLBACK` on `client`. */ +export function createDbTransactionRunner(client: DbClient): TransactionRunner { + return (work: () => Promise) => withTransaction(client, work); +} diff --git a/src/models/AuditEvent.ts b/src/models/AuditEvent.ts new file mode 100644 index 0000000..c68274f --- /dev/null +++ b/src/models/AuditEvent.ts @@ -0,0 +1,52 @@ +/** + * Append-only audit record for a credit-line mutation. + * + * Written in the same transaction as the balance update and ledger row so a + * crash cannot leave a utilized figure without a matching audit payload + * (or vice versa). + */ +export type CreditMutationAction = 'draw' | 'repay'; + +export const DRAW_CONFIRMED_EVENT = 'credit.draw_confirmed'; +export const REPAY_CONFIRMED_EVENT = 'credit.repay_confirmed'; + +export interface CreditMutationAuditPayload { + action: CreditMutationAction; + walletAddress: string; + /** Requested amount (always > 0). */ + amount: string; + /** Amount actually applied to utilized (≤ amount; 0 when already paid off). */ + appliedAmount: string; + utilizedBefore: string; + utilizedAfter: string; +} + +export interface AuditEvent { + id: string; + eventType: string; + aggregateType: string; + aggregateId: string; + payload: CreditMutationAuditPayload; + idempotencyKey?: string; + createdAt: Date; +} + +export interface AppendAuditEventRequest { + eventType: string; + aggregateType: string; + aggregateId: string; + payload: CreditMutationAuditPayload; + idempotencyKey?: string; +} + +/** + * Raised when an append hits a unique `idempotency_key`. + * The service treats this as a successful replay: roll back the current + * unit of work (if a sibling write raced) and return the already-committed line. + */ +export class DuplicateIdempotencyKeyError extends Error { + constructor(public readonly key: string) { + super(`Idempotency key already used: ${key}`); + this.name = 'DuplicateIdempotencyKeyError'; + } +} diff --git a/src/repositories/interfaces/AuditEventRepository.ts b/src/repositories/interfaces/AuditEventRepository.ts new file mode 100644 index 0000000..e6ebf8e --- /dev/null +++ b/src/repositories/interfaces/AuditEventRepository.ts @@ -0,0 +1,22 @@ +import type { AppendAuditEventRequest, AuditEvent } from '../../models/AuditEvent.js'; + +/** + * Persistence for immutable credit-lifecycle audit events. + * + * Implementations must participate in the caller's open database transaction + * (same {@link import('../../db/client.js').DbClient} session) so a failed + * append rolls back the paired balance and ledger writes. + */ +export interface AuditEventRepository { + /** + * Append an audit event. Throws {@link import('../../models/AuditEvent.js').DuplicateIdempotencyKeyError} + * when `idempotencyKey` collides with an existing row. + */ + append(request: AppendAuditEventRequest): Promise; + + /** Lookup by unique idempotency key; `null` when unused. */ + findByIdempotencyKey(key: string): Promise; + + /** Time-ordered (oldest first) events for an aggregate. */ + listByAggregate(aggregateType: string, aggregateId: string): Promise; +} diff --git a/src/repositories/interfaces/CreditLineRepository.ts b/src/repositories/interfaces/CreditLineRepository.ts index aa62e4e..4c32ba4 100644 --- a/src/repositories/interfaces/CreditLineRepository.ts +++ b/src/repositories/interfaces/CreditLineRepository.ts @@ -17,6 +17,13 @@ export interface CreditLineRepository { */ findById(id: string): Promise; + /** + * Lock the row for the remainder of the current transaction (Postgres + * `SELECT … FOR UPDATE`). In-memory implementations alias {@link findById}. + * Optional so existing test doubles keep compiling. + */ + lockById?(id: string): Promise; + /** * Find credit lines by wallet address */ diff --git a/src/repositories/memory/InMemoryAuditEventRepository.ts b/src/repositories/memory/InMemoryAuditEventRepository.ts new file mode 100644 index 0000000..639f635 --- /dev/null +++ b/src/repositories/memory/InMemoryAuditEventRepository.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'crypto'; +import { + DuplicateIdempotencyKeyError, + type AppendAuditEventRequest, + type AuditEvent, +} from '../../models/AuditEvent.js'; +import type { AuditEventRepository } from '../interfaces/AuditEventRepository.js'; + +export class InMemoryAuditEventRepository implements AuditEventRepository { + private readonly events: Map = new Map(); + private readonly byIdempotencyKey: Map = new Map(); + + async append(request: AppendAuditEventRequest): Promise { + if (request.idempotencyKey) { + const existingId = this.byIdempotencyKey.get(request.idempotencyKey); + if (existingId) { + throw new DuplicateIdempotencyKeyError(request.idempotencyKey); + } + } + + const event: AuditEvent = { + id: randomUUID(), + eventType: request.eventType, + aggregateType: request.aggregateType, + aggregateId: request.aggregateId, + payload: { ...request.payload }, + idempotencyKey: request.idempotencyKey, + createdAt: new Date(), + }; + + this.events.set(event.id, event); + if (event.idempotencyKey) { + this.byIdempotencyKey.set(event.idempotencyKey, event.id); + } + return event; + } + + async findByIdempotencyKey(key: string): Promise { + const id = this.byIdempotencyKey.get(key); + if (!id) { + return null; + } + return this.events.get(id) ?? null; + } + + async listByAggregate(aggregateType: string, aggregateId: string): Promise { + // Map iteration is insertion-ordered; keep that so concurrent tests can + // reconstruct the serializable applied-amount chain even when Date.now() + // collides across back-to-back writes. + return Array.from(this.events.values()).filter( + (event) => event.aggregateType === aggregateType && event.aggregateId === aggregateId, + ); + } + + /** Test helper. */ + clear(): void { + this.events.clear(); + this.byIdempotencyKey.clear(); + } + + /** Test helper — snapshot for injected-failure rollback harnesses. */ + exportState(): AuditEvent[] { + return Array.from(this.events.values()).map((event) => ({ + ...event, + payload: { ...event.payload }, + })); + } + + /** Test helper — restore a snapshot taken by {@link exportState}. */ + importState(events: AuditEvent[]): void { + this.events.clear(); + this.byIdempotencyKey.clear(); + for (const event of events) { + const copy: AuditEvent = { ...event, payload: { ...event.payload } }; + this.events.set(copy.id, copy); + if (copy.idempotencyKey) { + this.byIdempotencyKey.set(copy.idempotencyKey, copy.id); + } + } + } +} diff --git a/src/repositories/memory/InMemoryCreditLineRepository.ts b/src/repositories/memory/InMemoryCreditLineRepository.ts index 33c9622..ecb3890 100644 --- a/src/repositories/memory/InMemoryCreditLineRepository.ts +++ b/src/repositories/memory/InMemoryCreditLineRepository.ts @@ -37,6 +37,10 @@ export class InMemoryCreditLineRepository implements CreditLineRepository { return this.creditLines.get(id) || null; } + async lockById(id: string): Promise { + return this.findById(id); + } + async findByWalletAddress(walletAddress: string): Promise { const filtered = Array.from(this.creditLines.values()).filter(cl => cl.walletAddress === walletAddress); return this.sortByNewest(filtered); @@ -147,4 +151,17 @@ export class InMemoryCreditLineRepository implements CreditLineRepository { clear(): void { this.creditLines.clear(); } + + /** Test helper — snapshot for injected-failure rollback harnesses. */ + exportState(): CreditLine[] { + return Array.from(this.creditLines.values()).map((line) => ({ ...line })); + } + + /** Test helper — restore a snapshot taken by {@link exportState}. */ + importState(lines: CreditLine[]): void { + this.creditLines.clear(); + for (const line of lines) { + this.creditLines.set(line.id, { ...line }); + } + } } \ No newline at end of file diff --git a/src/repositories/memory/InMemoryTransactionRepository.ts b/src/repositories/memory/InMemoryTransactionRepository.ts index dd20d84..7b091e6 100644 --- a/src/repositories/memory/InMemoryTransactionRepository.ts +++ b/src/repositories/memory/InMemoryTransactionRepository.ts @@ -90,4 +90,17 @@ export class InMemoryTransactionRepository implements TransactionRepository { clear(): void { this.transactions.clear(); } + + /** Test helper — snapshot for injected-failure rollback harnesses. */ + exportState(): Transaction[] { + return Array.from(this.transactions.values()).map((tx) => ({ ...tx })); + } + + /** Test helper — restore a snapshot taken by {@link exportState}. */ + importState(rows: Transaction[]): void { + this.transactions.clear(); + for (const tx of rows) { + this.transactions.set(tx.id, { ...tx }); + } + } } diff --git a/src/repositories/memory/__tests__/InMemoryAuditEventRepository.test.ts b/src/repositories/memory/__tests__/InMemoryAuditEventRepository.test.ts new file mode 100644 index 0000000..9792e0a --- /dev/null +++ b/src/repositories/memory/__tests__/InMemoryAuditEventRepository.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryAuditEventRepository } from '../InMemoryAuditEventRepository.js'; +import { + DuplicateIdempotencyKeyError, + DRAW_CONFIRMED_EVENT, + REPAY_CONFIRMED_EVENT, +} from '../../../models/AuditEvent.js'; + +function payload(utilizedAfter: string) { + return { + action: 'draw' as const, + walletAddress: 'GTEST', + amount: '10', + appliedAmount: '10', + utilizedBefore: '0', + utilizedAfter, + }; +} + +describe('InMemoryAuditEventRepository', () => { + let repository: InMemoryAuditEventRepository; + + beforeEach(() => { + repository = new InMemoryAuditEventRepository(); + }); + + it('appends and lists events for an aggregate in insertion order', async () => { + await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('10'), + }); + await repository.append({ + eventType: REPAY_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: { ...payload('5'), action: 'repay' }, + }); + await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-other', + payload: payload('1'), + }); + + const events = await repository.listByAggregate('credit_line', 'cl-1'); + expect(events).toHaveLength(2); + expect(events.map((e) => e.payload.utilizedAfter)).toEqual(['10', '5']); + }); + + it('looks up events by idempotency key', async () => { + const created = await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('10'), + idempotencyKey: 'k1', + }); + + expect(await repository.findByIdempotencyKey('k1')).toEqual(created); + expect(await repository.findByIdempotencyKey('missing')).toBeNull(); + }); + + it('rejects a duplicate idempotency key', async () => { + await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('10'), + idempotencyKey: 'k1', + }); + + await expect( + repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('20'), + idempotencyKey: 'k1', + }), + ).rejects.toBeInstanceOf(DuplicateIdempotencyKeyError); + }); + + it('exportState / importState round-trips rows', async () => { + await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('10'), + idempotencyKey: 'k1', + }); + const snap = repository.exportState(); + await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('20'), + }); + repository.importState(snap); + expect(await repository.listByAggregate('credit_line', 'cl-1')).toHaveLength(1); + expect(await repository.findByIdempotencyKey('k1')).not.toBeNull(); + }); + + it('clear wipes all rows', async () => { + await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: payload('10'), + }); + repository.clear(); + expect(await repository.listByAggregate('credit_line', 'cl-1')).toEqual([]); + }); +}); diff --git a/src/repositories/memory/__tests__/InMemoryCreditLineRepository.test.ts b/src/repositories/memory/__tests__/InMemoryCreditLineRepository.test.ts index 8c27912..bdb08f6 100644 --- a/src/repositories/memory/__tests__/InMemoryCreditLineRepository.test.ts +++ b/src/repositories/memory/__tests__/InMemoryCreditLineRepository.test.ts @@ -425,4 +425,28 @@ describe('InMemoryCreditLineRepository', () => { expect(result.nextCursor).toBeNull(); }); }); + + describe('lock and snapshot helpers', () => { + it('lockById aliases findById', async () => { + const created = await repository.create({ + walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + creditLimit: '1000.00', + interestRateBps: 500 + }); + expect(await repository.lockById(created.id)).toEqual(created); + expect(await repository.lockById('missing')).toBeNull(); + }); + + it('exportState / importState round-trips rows', async () => { + const created = await repository.create({ + walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + creditLimit: '1000.00', + interestRateBps: 500 + }); + const snap = repository.exportState(); + await repository.update(created.id, { utilized: '50' }); + repository.importState(snap); + expect((await repository.findById(created.id))?.utilized).toBe('0'); + }); + }); }); diff --git a/src/repositories/memory/__tests__/InMemoryTransactionRepository.test.ts b/src/repositories/memory/__tests__/InMemoryTransactionRepository.test.ts index cf169dc..c89e94a 100644 --- a/src/repositories/memory/__tests__/InMemoryTransactionRepository.test.ts +++ b/src/repositories/memory/__tests__/InMemoryTransactionRepository.test.ts @@ -349,4 +349,22 @@ describe('InMemoryTransactionRepository', () => { expect(repository['transactions'].size).toBe(0); }); }); + + describe('snapshot helpers', () => { + it('exportState / importState round-trips rows', async () => { + await repository.create({ + creditLineId: 'cl-123', + amount: '100.00', + type: TransactionType.BORROW + }); + const snap = repository.exportState(); + await repository.create({ + creditLineId: 'cl-123', + amount: '50.00', + type: TransactionType.REPAY + }); + repository.importState(snap); + expect(await repository.count()).toBe(1); + }); + }); }); diff --git a/src/repositories/postgres/PostgresAuditEventRepository.ts b/src/repositories/postgres/PostgresAuditEventRepository.ts new file mode 100644 index 0000000..17284c3 --- /dev/null +++ b/src/repositories/postgres/PostgresAuditEventRepository.ts @@ -0,0 +1,103 @@ +import { + DuplicateIdempotencyKeyError, + type AppendAuditEventRequest, + type AuditEvent, + type CreditMutationAuditPayload, +} from '../../models/AuditEvent.js'; +import type { AuditEventRepository } from '../interfaces/AuditEventRepository.js'; +import type { DbClient } from '../../db/client.js'; + +interface EventRow { + id: string; + event_type: string; + aggregate_type: string | null; + aggregate_id: string | null; + payload: CreditMutationAuditPayload | string | null; + idempotency_key: string | null; + created_at: Date; +} + +function isUniqueViolation(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error + && (error as { code?: string }).code === '23505'; +} + +function parsePayload(raw: EventRow['payload']): CreditMutationAuditPayload { + if (raw && typeof raw === 'object') { + return raw; + } + if (typeof raw === 'string') { + return JSON.parse(raw) as CreditMutationAuditPayload; + } + return { + action: 'draw', + walletAddress: '', + amount: '0', + appliedAmount: '0', + utilizedBefore: '0', + utilizedAfter: '0', + }; +} + +export class PostgresAuditEventRepository implements AuditEventRepository { + constructor(private client: DbClient) {} + + async append(request: AppendAuditEventRequest): Promise { + const query = ` + INSERT INTO events (event_type, aggregate_type, aggregate_id, payload, idempotency_key) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, event_type, aggregate_type, aggregate_id, payload, idempotency_key, created_at + `; + try { + const result = await this.client.query(query, [ + request.eventType, + request.aggregateType, + request.aggregateId, + request.payload, + request.idempotencyKey ?? null, + ]); + return this.toModel(result.rows[0] as EventRow); + } catch (error) { + if (request.idempotencyKey && isUniqueViolation(error)) { + throw new DuplicateIdempotencyKeyError(request.idempotencyKey); + } + throw error; + } + } + + async findByIdempotencyKey(key: string): Promise { + const result = await this.client.query( + `SELECT id, event_type, aggregate_type, aggregate_id, payload, idempotency_key, created_at + FROM events + WHERE idempotency_key = $1`, + [key], + ); + if (result.rows.length === 0) { + return null; + } + return this.toModel(result.rows[0] as EventRow); + } + + async listByAggregate(aggregateType: string, aggregateId: string): Promise { + const result = await this.client.query( + `SELECT id, event_type, aggregate_type, aggregate_id, payload, idempotency_key, created_at + FROM events + WHERE aggregate_type = $1 AND aggregate_id = $2 + ORDER BY created_at ASC, id ASC`, + [aggregateType, aggregateId], + ); + return (result.rows as EventRow[]).map((row) => this.toModel(row)); + } + + private toModel(row: EventRow): AuditEvent { + return { + id: row.id, + eventType: row.event_type, + aggregateType: row.aggregate_type ?? '', + aggregateId: row.aggregate_id ?? '', + payload: parsePayload(row.payload), + idempotencyKey: row.idempotency_key ?? undefined, + createdAt: row.created_at, + }; + } +} diff --git a/src/repositories/postgres/PostgresCreditLineRepository.ts b/src/repositories/postgres/PostgresCreditLineRepository.ts index 188873d..4059e22 100644 --- a/src/repositories/postgres/PostgresCreditLineRepository.ts +++ b/src/repositories/postgres/PostgresCreditLineRepository.ts @@ -8,11 +8,24 @@ interface CreditLineRow { currency: string; status: string; interest_rate_bps: number; + utilized?: string; created_at: Date; updated_at: Date; wallet_address: string; } +const CREDIT_LINE_SELECT = ` + cl.id, + cl.credit_limit, + cl.currency, + cl.status, + cl.interest_rate_bps, + cl.utilized, + cl.created_at, + cl.updated_at, + b.wallet_address +`; + export class PostgresCreditLineRepository implements CreditLineRepository { constructor(private client: DbClient) {} @@ -63,46 +76,36 @@ export class PostgresCreditLineRepository implements CreditLineRepository { } async findById(id: string): Promise { + return this.loadById(id, false); + } + + async lockById(id: string): Promise { + return this.loadById(id, true); + } + + private async loadById(id: string, forUpdate: boolean): Promise { const query = ` - SELECT - cl.id, - cl.credit_limit, - cl.currency, - cl.status, - cl.interest_rate_bps, - cl.created_at, - cl.updated_at, - b.wallet_address + SELECT + ${CREDIT_LINE_SELECT} FROM credit_lines cl JOIN borrowers b ON cl.borrower_id = b.id WHERE cl.id = $1 + ${forUpdate ? 'FOR UPDATE OF cl' : ''} `; const result = await this.client.query(query, [id]); - + if (result.rows.length === 0) { return null; } - const row = result.rows[0] as CreditLineRow; - - // Calculate available credit by subtracting total draws - const availableCredit = await this.calculateAvailableCredit(id, row.credit_limit); - - return this.toCreditLine(row, availableCredit); + return this.toCreditLine(result.rows[0] as CreditLineRow); } async findByWalletAddress(walletAddress: string): Promise { const query = ` - SELECT - cl.id, - cl.credit_limit, - cl.currency, - cl.status, - cl.interest_rate_bps, - cl.created_at, - cl.updated_at, - b.wallet_address + SELECT + ${CREDIT_LINE_SELECT} FROM credit_lines cl JOIN borrowers b ON cl.borrower_id = b.id WHERE b.wallet_address = $1 @@ -110,27 +113,13 @@ export class PostgresCreditLineRepository implements CreditLineRepository { `; const result = await this.client.query(query, [walletAddress]); - const creditLines: CreditLine[] = []; - - for (const row of result.rows as CreditLineRow[]) { - const availableCredit = await this.calculateAvailableCredit(row.id, row.credit_limit); - creditLines.push(this.toCreditLine(row, availableCredit)); - } - - return creditLines; + return (result.rows as CreditLineRow[]).map((row) => this.toCreditLine(row)); } async findAll(offset = 0, limit = 100): Promise { const query = ` - SELECT - cl.id, - cl.credit_limit, - cl.currency, - cl.status, - cl.interest_rate_bps, - cl.created_at, - cl.updated_at, - b.wallet_address + SELECT + ${CREDIT_LINE_SELECT} FROM credit_lines cl JOIN borrowers b ON cl.borrower_id = b.id ORDER BY cl.created_at DESC @@ -138,14 +127,7 @@ export class PostgresCreditLineRepository implements CreditLineRepository { `; const result = await this.client.query(query, [limit, offset]); - const creditLines: CreditLine[] = []; - - for (const row of result.rows as CreditLineRow[]) { - const availableCredit = await this.calculateAvailableCredit(row.id, row.credit_limit); - creditLines.push(this.toCreditLine(row, availableCredit)); - } - - return creditLines; + return (result.rows as CreditLineRow[]).map((row) => this.toCreditLine(row)); } async findAllWithCursor(cursor?: string, limit = 100): Promise { @@ -176,14 +158,7 @@ export class PostgresCreditLineRepository implements CreditLineRepository { const query = ` SELECT - cl.id, - cl.credit_limit, - cl.currency, - cl.status, - cl.interest_rate_bps, - cl.created_at, - cl.updated_at, - b.wallet_address + ${CREDIT_LINE_SELECT} FROM credit_lines cl JOIN borrowers b ON cl.borrower_id = b.id ${whereClause} @@ -192,13 +167,7 @@ export class PostgresCreditLineRepository implements CreditLineRepository { `; const result = await this.client.query(query, values); - const rows = result.rows as CreditLineRow[]; - const creditLines: CreditLine[] = []; - - for (const row of rows) { - const availableCredit = await this.calculateAvailableCredit(row.id, row.credit_limit); - creditLines.push(this.toCreditLine(row, availableCredit)); - } + const creditLines = (result.rows as CreditLineRow[]).map((row) => this.toCreditLine(row)); const hasMore = creditLines.length > limit; const items = creditLines.slice(0, limit); @@ -233,6 +202,11 @@ export class PostgresCreditLineRepository implements CreditLineRepository { values.push(request.status); } + if (request.utilized !== undefined) { + setParts.push(`utilized = $${paramIndex++}`); + values.push(request.utilized); + } + if (setParts.length === 0) { // No updates requested, return current record return this.findById(id); @@ -317,30 +291,23 @@ export class PostgresCreditLineRepository implements CreditLineRepository { return row.wallet_address; } - /** - * Calculate available credit by subtracting total draws from credit limit. - * For now, returns the full credit limit since we don't have transaction tracking yet. - */ - private async calculateAvailableCredit(_creditLineId: string, creditLimit: string): Promise { - // TODO: When transaction repository is implemented, calculate: - // creditLimit - SUM(transactions where type = 'draw' and credit_line_id = creditLineId) - - // For now, return full credit limit - return creditLimit; - } - - private calculateUtilized(creditLimit: string, availableCredit: string): string { - const utilized = Number.parseFloat(creditLimit) - Number.parseFloat(availableCredit); - return Number.isFinite(utilized) ? Math.max(0, utilized).toString() : '0'; + private calculateAvailable(creditLimit: string, utilized: string): string { + const utilizedNum = Number.parseFloat(utilized); + if (!Number.isFinite(utilizedNum) || utilizedNum === 0) { + return creditLimit; + } + const available = Number.parseFloat(creditLimit) - utilizedNum; + return Number.isFinite(available) ? available.toString() : creditLimit; } - private toCreditLine(row: CreditLineRow, availableCredit: string): CreditLine { + private toCreditLine(row: CreditLineRow): CreditLine { + const utilized = row.utilized ?? '0'; return { id: row.id, walletAddress: row.wallet_address, creditLimit: row.credit_limit, - availableCredit, - utilized: this.calculateUtilized(row.credit_limit, availableCredit), + availableCredit: this.calculateAvailable(row.credit_limit, utilized), + utilized, interestRateBps: row.interest_rate_bps, status: row.status as CreditLineStatus, createdAt: row.created_at, diff --git a/src/repositories/postgres/PostgresTransactionRepository.ts b/src/repositories/postgres/PostgresTransactionRepository.ts new file mode 100644 index 0000000..bea5141 --- /dev/null +++ b/src/repositories/postgres/PostgresTransactionRepository.ts @@ -0,0 +1,134 @@ +import { + TransactionStatus, + type CreateTransactionRequest, + type Transaction, + type TransactionType, +} from '../../models/Transaction.js'; +import type { TransactionRepository } from '../interfaces/TransactionRepository.js'; +import type { DbClient } from '../../db/client.js'; + +interface TransactionRow { + id: string; + credit_line_id: string; + wallet_address: string; + amount: string; + type: string; + currency: string; + created_at: Date; +} + +/** + * Postgres-backed ledger. Maps onto the `transactions` table from + * `migrations/001_initial_schema.sql`. Status is not a persisted column in + * the current schema; reads report {@link TransactionStatus.PENDING}. + * + * Must share the same {@link DbClient} as credit-line and audit writes so + * draw/repay can commit (or roll back) all three together. + */ +export class PostgresTransactionRepository implements TransactionRepository { + constructor(private client: DbClient) {} + + async create(request: CreateTransactionRequest): Promise { + const query = ` + INSERT INTO transactions (credit_line_id, type, amount, currency) + VALUES ($1, $2, $3, $4) + RETURNING id, credit_line_id, type, amount, currency, created_at + `; + const result = await this.client.query(query, [ + request.creditLineId, + request.type, + request.amount, + 'USDC', + ]); + const row = result.rows[0] as Omit; + return { + id: row.id, + creditLineId: row.credit_line_id, + walletAddress: '', + amount: String(row.amount), + type: row.type as TransactionType, + status: TransactionStatus.PENDING, + blockchainTxHash: request.blockchainTxHash, + createdAt: row.created_at, + }; + } + + async findById(id: string): Promise { + const rows = await this.select('WHERE t.id = $1', [id]); + return rows[0] ?? null; + } + + async findByCreditLineId(creditLineId: string, offset = 0, limit = 100): Promise { + return this.select( + 'WHERE t.credit_line_id = $1', + [creditLineId, limit, offset], + 'ORDER BY t.created_at DESC LIMIT $2 OFFSET $3', + ); + } + + async findByWalletAddress(walletAddress: string, offset = 0, limit = 100): Promise { + return this.select( + 'WHERE b.wallet_address = $1', + [walletAddress, limit, offset], + 'ORDER BY t.created_at DESC LIMIT $2 OFFSET $3', + ); + } + + async updateStatus(id: string, status: TransactionStatus, processedAt?: Date): Promise { + // Status is not a column on the current schema; return the row if present + // so callers can still observe the requested transition in-process. + const existing = await this.findById(id); + if (!existing) { + return null; + } + return { + ...existing, + status, + processedAt: processedAt ?? new Date(), + }; + } + + async findAll(offset = 0, limit = 100): Promise { + return this.select('', [limit, offset], 'ORDER BY t.created_at DESC LIMIT $1 OFFSET $2'); + } + + async count(): Promise { + const result = await this.client.query('SELECT COUNT(*) as count FROM transactions'); + const row = result.rows[0] as { count: string }; + return parseInt(row.count, 10); + } + + async findByStatus(_status: TransactionStatus, offset = 0, limit = 100): Promise { + // No persisted status column — every row is treated as pending at rest. + if (_status !== TransactionStatus.PENDING) { + return []; + } + return this.findAll(offset, limit); + } + + private async select(where: string, values: unknown[], tail = ''): Promise { + const query = ` + SELECT t.id, t.credit_line_id, b.wallet_address, t.amount, t.type, + t.currency, t.created_at + FROM transactions t + JOIN credit_lines cl ON t.credit_line_id = cl.id + JOIN borrowers b ON cl.borrower_id = b.id + ${where} + ${tail} + `; + const result = await this.client.query(query, values); + return (result.rows as TransactionRow[]).map((row) => this.toModel(row)); + } + + private toModel(row: TransactionRow): Transaction { + return { + id: row.id, + creditLineId: row.credit_line_id, + walletAddress: row.wallet_address, + amount: String(row.amount), + type: row.type as TransactionType, + status: TransactionStatus.PENDING, + createdAt: row.created_at, + }; + } +} diff --git a/src/repositories/postgres/__tests__/PostgresAuditEventRepository.test.ts b/src/repositories/postgres/__tests__/PostgresAuditEventRepository.test.ts new file mode 100644 index 0000000..d52e4aa --- /dev/null +++ b/src/repositories/postgres/__tests__/PostgresAuditEventRepository.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DbClient } from '../../../db/client.js'; +import { PostgresAuditEventRepository } from '../PostgresAuditEventRepository.js'; +import { + DuplicateIdempotencyKeyError, + DRAW_CONFIRMED_EVENT, +} from '../../../models/AuditEvent.js'; + +function createMockClient(overrides: Partial = {}): DbClient { + return { + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +const payload = { + action: 'draw' as const, + walletAddress: 'GTEST', + amount: '10', + appliedAmount: '10', + utilizedBefore: '0', + utilizedAfter: '10', +}; + +describe('PostgresAuditEventRepository', () => { + let client: DbClient; + let repository: PostgresAuditEventRepository; + + beforeEach(() => { + client = createMockClient(); + repository = new PostgresAuditEventRepository(client); + }); + + it('inserts an event and maps the returned row', async () => { + const now = new Date(); + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'evt-1', + event_type: DRAW_CONFIRMED_EVENT, + aggregate_type: 'credit_line', + aggregate_id: 'cl-1', + payload, + idempotency_key: 'k1', + created_at: now, + }], + }); + + const result = await repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload, + idempotencyKey: 'k1', + }); + + expect(result).toMatchObject({ + id: 'evt-1', + eventType: DRAW_CONFIRMED_EVENT, + aggregateId: 'cl-1', + payload, + idempotencyKey: 'k1', + }); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO events'), + [DRAW_CONFIRMED_EVENT, 'credit_line', 'cl-1', payload, 'k1'], + ); + }); + + it('maps a unique-violation into DuplicateIdempotencyKeyError', async () => { + const error = Object.assign(new Error('duplicate'), { code: '23505' }); + vi.mocked(client.query).mockRejectedValueOnce(error); + + await expect( + repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload, + idempotencyKey: 'k1', + }), + ).rejects.toBeInstanceOf(DuplicateIdempotencyKeyError); + }); + + it('rethrows non-unique failures', async () => { + vi.mocked(client.query).mockRejectedValueOnce(new Error('connection lost')); + + await expect( + repository.append({ + eventType: DRAW_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload, + }), + ).rejects.toThrow('connection lost'); + }); + + it('returns null when the idempotency key is unused', async () => { + expect(await repository.findByIdempotencyKey('missing')).toBeNull(); + }); + + it('parses a JSON-string payload on read', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'evt-1', + event_type: DRAW_CONFIRMED_EVENT, + aggregate_type: 'credit_line', + aggregate_id: 'cl-1', + payload: JSON.stringify(payload), + idempotency_key: null, + created_at: new Date(), + }], + }); + + const result = await repository.findByIdempotencyKey('k1'); + expect(result?.payload.utilizedAfter).toBe('10'); + }); + + it('maps a null payload to a zeroed default', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'evt-empty', + event_type: DRAW_CONFIRMED_EVENT, + aggregate_type: null, + aggregate_id: null, + payload: null, + idempotency_key: null, + created_at: new Date(), + }], + }); + + const events = await repository.listByAggregate('credit_line', 'cl-1'); + expect(events[0]?.payload.utilizedAfter).toBe('0'); + expect(events[0]?.aggregateType).toBe(''); + }); + + it('lists events for an aggregate', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'evt-1', + event_type: DRAW_CONFIRMED_EVENT, + aggregate_type: 'credit_line', + aggregate_id: 'cl-1', + payload, + idempotency_key: null, + created_at: new Date(), + }], + }); + + const events = await repository.listByAggregate('credit_line', 'cl-1'); + expect(events).toHaveLength(1); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining('aggregate_type = $1'), + ['credit_line', 'cl-1'], + ); + }); +}); diff --git a/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts b/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts index 2e91818..8ce189c 100644 --- a/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts +++ b/src/repositories/postgres/__tests__/PostgresCreditLineRepository.test.ts @@ -275,6 +275,36 @@ describe('PostgresCreditLineRepository', () => { expect(result).toBeNull(); }); + it('should persist utilized on update', async () => { + const creditLineId = 'credit-line-123'; + const now = new Date(); + + vi.mocked(mockClient.query) + .mockResolvedValueOnce({ rows: [{ id: creditLineId }] }) + .mockResolvedValueOnce({ + rows: [{ + id: creditLineId, + credit_limit: '10000.00', + currency: 'USDC', + status: 'active', + interest_rate_bps: 500, + utilized: '250', + created_at: now, + updated_at: now, + wallet_address: 'GTEST123' + }] + }); + + const result = await repository.update(creditLineId, { utilized: '250' }); + + expect(result?.utilized).toBe('250'); + expect(result?.availableCredit).toBe('9750'); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('utilized = $'), + ['250', creditLineId] + ); + }); + it('should return current record when no updates provided', async () => { const creditLineId = 'credit-line-123'; const now = new Date(); @@ -325,6 +355,20 @@ describe('PostgresCreditLineRepository', () => { }); }); + describe('lockById', () => { + it('issues SELECT … FOR UPDATE OF cl', async () => { + vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [] }); + + const result = await repository.lockById('credit-line-123'); + + expect(result).toBeNull(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining('FOR UPDATE OF cl'), + ['credit-line-123'] + ); + }); + }); + describe('exists', () => { it('should return true when credit line exists', async () => { vi.mocked(mockClient.query).mockResolvedValueOnce({ rows: [{ '?column?': 1 }] }); diff --git a/src/repositories/postgres/__tests__/PostgresTransactionRepository.test.ts b/src/repositories/postgres/__tests__/PostgresTransactionRepository.test.ts new file mode 100644 index 0000000..4cf081b --- /dev/null +++ b/src/repositories/postgres/__tests__/PostgresTransactionRepository.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DbClient } from '../../../db/client.js'; +import { PostgresTransactionRepository } from '../PostgresTransactionRepository.js'; +import { TransactionStatus, TransactionType } from '../../../models/Transaction.js'; + +function createMockClient(overrides: Partial = {}): DbClient { + return { + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('PostgresTransactionRepository', () => { + let client: DbClient; + let repository: PostgresTransactionRepository; + + beforeEach(() => { + client = createMockClient(); + repository = new PostgresTransactionRepository(client); + }); + + it('inserts a ledger row and returns the mapped transaction', async () => { + const now = new Date(); + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'tx-1', + credit_line_id: 'cl-1', + type: TransactionType.BORROW, + amount: '100', + currency: 'USDC', + created_at: now, + }], + }); + + const result = await repository.create({ + creditLineId: 'cl-1', + amount: '100', + type: TransactionType.BORROW, + blockchainTxHash: 'hash', + }); + + expect(result).toMatchObject({ + id: 'tx-1', + creditLineId: 'cl-1', + amount: '100', + type: TransactionType.BORROW, + status: TransactionStatus.PENDING, + blockchainTxHash: 'hash', + }); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO transactions'), + ['cl-1', TransactionType.BORROW, '100', 'USDC'], + ); + }); + + it('returns null from findById when missing', async () => { + expect(await repository.findById('missing')).toBeNull(); + }); + + it('counts rows', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ rows: [{ count: '3' }] }); + expect(await repository.count()).toBe(3); + }); + + it('findByCreditLineId parameterizes the lookup', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ rows: [] }); + await repository.findByCreditLineId('cl-1', 5, 10); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining('t.credit_line_id = $1'), + ['cl-1', 10, 5], + ); + }); + + it('findByWalletAddress joins borrowers', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ rows: [] }); + await repository.findByWalletAddress('GTEST'); + expect(client.query).toHaveBeenCalledWith( + expect.stringContaining('b.wallet_address = $1'), + ['GTEST', 100, 0], + ); + }); + + it('updateStatus returns null when the row does not exist', async () => { + expect(await repository.updateStatus('missing', TransactionStatus.CONFIRMED)).toBeNull(); + }); + + it('updateStatus returns the in-process status when the row exists', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'tx-1', + credit_line_id: 'cl-1', + wallet_address: 'GTEST', + amount: '10', + type: TransactionType.REPAY, + currency: 'USDC', + created_at: new Date(), + }], + }); + + const updated = await repository.updateStatus('tx-1', TransactionStatus.CONFIRMED); + expect(updated?.status).toBe(TransactionStatus.CONFIRMED); + expect(updated?.processedAt).toBeInstanceOf(Date); + }); + + it('findByStatus returns nothing for non-pending statuses', async () => { + expect(await repository.findByStatus(TransactionStatus.FAILED)).toEqual([]); + }); + + it('findByStatus(PENDING) delegates to findAll', async () => { + vi.mocked(client.query).mockResolvedValueOnce({ rows: [] }); + await repository.findByStatus(TransactionStatus.PENDING, 0, 5); + expect(client.query).toHaveBeenCalled(); + }); + + it('findAll maps joined rows', async () => { + const now = new Date(); + vi.mocked(client.query).mockResolvedValueOnce({ + rows: [{ + id: 'tx-1', + credit_line_id: 'cl-1', + wallet_address: 'GTEST', + amount: '25', + type: TransactionType.BORROW, + currency: 'USDC', + created_at: now, + }], + }); + const rows = await repository.findAll(0, 10); + expect(rows[0]).toMatchObject({ + id: 'tx-1', + walletAddress: 'GTEST', + amount: '25', + status: TransactionStatus.PENDING, + }); + }); +}); diff --git a/src/routes/credit.ts b/src/routes/credit.ts index 0ba7003..f4ecbf8 100644 --- a/src/routes/credit.ts +++ b/src/routes/credit.ts @@ -70,6 +70,14 @@ function handleServiceError(err: unknown, res: Response): void { return; } const message = err instanceof Error ? err.message : 'Internal server error'; + if (message === 'Credit line not found') { + res.status(404).json({ error: message }); + return; + } + if (message === 'Unauthorized') { + res.status(403).json({ error: message }); + return; + } res.status(500).json({ error: message }); } @@ -253,21 +261,21 @@ creditRouter.post( }, ); -creditRouter.post('/lines/:id/draw', validateBody(drawSchema), async (req, res, next) => { +creditRouter.post('/lines/:id/draw', validateBody(drawSchema), async (req, res) => { try { const result = await submitDrawRequest(req.params.id, req.body as DrawBody); res.json(result); } catch (err) { - next(err); + handleServiceError(err, res); } }); -creditRouter.post('/lines/:id/repay', validateBody(repaySchema), async (req, res, next) => { +creditRouter.post('/lines/:id/repay', validateBody(repaySchema), async (req, res) => { try { const result = await submitRepayRequest(req.params.id, req.body as RepayBody); res.json(result); } catch (err) { - next(err); + handleServiceError(err, res); } }); diff --git a/src/services/CreditLineService.ts b/src/services/CreditLineService.ts index 84bd11f..99c1f2b 100644 --- a/src/services/CreditLineService.ts +++ b/src/services/CreditLineService.ts @@ -1,5 +1,41 @@ import { type CreditLine, type CreateCreditLineRequest, type UpdateCreditLineRequest, CreditLineStatus } from '../models/CreditLine.js'; import type { CreditLineRepository, CursorPaginationResult } from '../repositories/interfaces/CreditLineRepository.js'; +import type { TransactionRepository } from '../repositories/interfaces/TransactionRepository.js'; +import type { AuditEventRepository } from '../repositories/interfaces/AuditEventRepository.js'; +import { TransactionType } from '../models/Transaction.js'; +import { + DRAW_CONFIRMED_EVENT, + DuplicateIdempotencyKeyError, + REPAY_CONFIRMED_EVENT, + type CreditMutationAction, +} from '../models/AuditEvent.js'; +import { + passthroughTransactionRunner, + type TransactionRunner, +} from '../db/transaction.js'; + +/** + * Optional dependencies that enable atomic multi-write credit mutations. + * + * - `transactionRepository` — ledger writes paired with draw/repay + * - `auditEventRepository` — audit events paired with the committed balance + * - `runInTransaction` — BEGIN/COMMIT/ROLLBACK boundary (Postgres) or + * passthrough (in-memory). Tests inject a snapshot runner to prove rollback. + */ +export interface CreditLineServiceDeps { + transactionRepository?: TransactionRepository; + auditEventRepository?: AuditEventRepository; + runInTransaction?: TransactionRunner; +} + +/** Extra options for draw / repay (idempotent retries). */ +export interface DrawRepayOptions { + /** + * When set, a second call with the same key is a no-op that returns the + * already-committed credit line. Stored on the audit row (unique). + */ + idempotencyKey?: string; +} /** * Domain service for credit-line CRUD plus the `draw` / `repay` operations. @@ -14,6 +50,12 @@ import type { CreditLineRepository, CursorPaginationResult } from '../repositori * - `interestRateBps` is clamped to the basis-points range `0..10000` * - Pagination `limit` is clamped to `1..100`; `offset` must be `≥ 0` * + * **Transaction boundaries.** `draw` and `repay` write credit-line state, + * a ledger row, and an audit event inside one {@link TransactionRunner} so a + * mid-flow failure rolls back every write. The line is locked (`lockById` / + * `SELECT … FOR UPDATE`) and serialized per id so concurrent calls cannot + * lose updates or drive utilized negative. + * * Errors are thrown as plain {@link Error} with human-readable messages so * the route layer can map them to the `{ data, error }` response envelope. * @@ -21,7 +63,19 @@ import type { CreditLineRepository, CursorPaginationResult } from '../repositori * the surfaces that call into this service. */ export class CreditLineService { - constructor(private creditLineRepository: CreditLineRepository) {} + private readonly transactionRepository?: TransactionRepository; + private readonly auditEventRepository?: AuditEventRepository; + private readonly runInTransaction: TransactionRunner; + private readonly lineLocks = new Map>(); + + constructor( + private creditLineRepository: CreditLineRepository, + deps: CreditLineServiceDeps = {}, + ) { + this.transactionRepository = deps.transactionRepository; + this.auditEventRepository = deps.auditEventRepository; + this.runInTransaction = deps.runInTransaction ?? passthroughTransactionRunner; + } /** * Create a new credit line for `walletAddress` with an explicit credit limit @@ -132,35 +186,31 @@ export class CreditLineService { * - line `status` must be {@link CreditLineStatus.ACTIVE} * - `utilized + amount` must not exceed `creditLimit` * - * On success the persisted `utilized` field is incremented atomically by - * the repository. The on-chain transaction is submitted separately via - * `SorobanRpcClient` — confirmation flows back through the indexer. + * **Atomicity.** Balance update, ledger row (`borrow`), and audit event + * commit together. If any write fails, all three roll back. + * + * The on-chain transaction is submitted separately via `SorobanRpcClient` + * — confirmation flows back through the indexer. */ - async draw(id: string, borrowerId: string, amount: string): Promise { - const line = await this.creditLineRepository.findById(id); - if (!line) { - throw new Error('Credit line not found'); - } - - if (line.walletAddress !== borrowerId) { - throw new Error('Unauthorized'); - } - - if (line.status !== CreditLineStatus.ACTIVE) { - throw new Error('Credit line is not active'); - } - - const amountNum = parseFloat(amount); - const limitNum = parseFloat(line.creditLimit); - const utilizedNum = parseFloat(line.utilized || '0'); - - if (utilizedNum + amountNum > limitNum) { - throw new Error('Credit limit exceeded'); - } - - return await this.creditLineRepository.update(id, { - utilized: (utilizedNum + amountNum).toString(), - }) as CreditLine; + async draw( + id: string, + borrowerId: string, + amount: string, + options: DrawRepayOptions = {}, + ): Promise { + const amountNum = parsePositiveAmount(amount, 'Draw amount'); + return this.withLineLock(id, () => + this.commitMutation({ + id, + action: 'draw', + amount, + amountNum, + actorWallet: borrowerId, + requireOwner: true, + requireActive: true, + idempotencyKey: options.idempotencyKey, + }), + ); } /** @@ -168,21 +218,181 @@ export class CreditLineService { * balance. The utilized amount is floored at `0` so a stray overpayment * can never produce negative utilization on the persisted row. * - * Like {@link draw}, this method only manipulates the off-chain mirror of - * state. The on-chain repay transaction is broadcast separately and - * confirmed by the indexer. + * Repeated and concurrent repayments are serialized per credit line and + * re-read inside the transaction, so the final utilized is deterministic: + * `max(0, start − Σ applied)`. An optional {@link DrawRepayOptions.idempotencyKey} + * makes a retried request a no-op. + * + * **Atomicity.** Balance update, ledger row (`repay`), and audit event + * commit in one transaction — same guarantees as {@link draw}. + */ + async repay( + id: string, + walletAddress: string, + amount: string, + options: DrawRepayOptions = {}, + ): Promise { + const amountNum = parsePositiveAmount(amount, 'Repay amount'); + return this.withLineLock(id, () => + this.commitMutation({ + id, + action: 'repay', + amount, + amountNum, + actorWallet: walletAddress, + requireOwner: true, + requireActive: false, + idempotencyKey: options.idempotencyKey, + }), + ); + } + + /** + * Serialize mutations per credit-line id so concurrent in-process calls + * cannot both read the same utilized and both commit. Postgres deployments + * additionally take `SELECT … FOR UPDATE` inside the transaction. */ - async repay(id: string, _walletAddress: string, amount: string): Promise { - const line = await this.creditLineRepository.findById(id); - if (!line) { - throw new Error('Credit line not found'); + private async withLineLock(id: string, work: () => Promise): Promise { + const previous = this.lineLocks.get(id) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + this.lineLocks.set( + id, + previous.then(() => gate, () => gate), + ); + await previous.then(() => undefined, () => undefined); + try { + return await work(); + } finally { + release(); + } + } + + private async loadLine(id: string): Promise { + if (this.creditLineRepository.lockById) { + return this.creditLineRepository.lockById(id); + } + return this.creditLineRepository.findById(id); + } + + private async commitMutation(input: { + id: string; + action: CreditMutationAction; + amount: string; + amountNum: number; + actorWallet: string; + requireOwner: boolean; + requireActive: boolean; + idempotencyKey?: string; + }): Promise { + try { + return await this.runInTransaction(async () => { + if (input.idempotencyKey && this.auditEventRepository) { + const existing = await this.auditEventRepository.findByIdempotencyKey(input.idempotencyKey); + if (existing) { + const current = await this.creditLineRepository.findById(input.id); + if (!current) { + throw new Error('Credit line not found'); + } + return current; + } + } + + const line = await this.loadLine(input.id); + if (!line) { + throw new Error('Credit line not found'); + } + + if (input.requireOwner && line.walletAddress !== input.actorWallet) { + throw new Error('Unauthorized'); + } + + if (input.requireActive && line.status !== CreditLineStatus.ACTIVE) { + throw new Error('Credit line is not active'); + } + + const limitNum = parseFloat(line.creditLimit); + const parsedUtilized = parseFloat(line.utilized || '0'); + const utilizedBeforeNum = Number.isFinite(parsedUtilized) ? parsedUtilized : 0; + const utilizedBefore = formatAmount(utilizedBeforeNum); + + let appliedNum: number; + let utilizedAfterNum: number; + + if (input.action === 'draw') { + if (utilizedBeforeNum + input.amountNum > limitNum) { + throw new Error('Credit limit exceeded'); + } + appliedNum = input.amountNum; + utilizedAfterNum = utilizedBeforeNum + input.amountNum; + } else { + appliedNum = Math.min(input.amountNum, Math.max(0, utilizedBeforeNum)); + utilizedAfterNum = utilizedBeforeNum - appliedNum; + } + + const utilizedAfter = formatAmount(utilizedAfterNum); + const appliedAmount = formatAmount(appliedNum); + + const updated = await this.creditLineRepository.update(input.id, { + utilized: utilizedAfter, + }); + if (!updated) { + throw new Error('Credit line not found'); + } + + if (this.transactionRepository) { + await this.transactionRepository.create({ + creditLineId: input.id, + amount: input.amount, + type: input.action === 'draw' ? TransactionType.BORROW : TransactionType.REPAY, + }); + } + + if (this.auditEventRepository) { + await this.auditEventRepository.append({ + eventType: input.action === 'draw' ? DRAW_CONFIRMED_EVENT : REPAY_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: input.id, + payload: { + action: input.action, + walletAddress: line.walletAddress, + amount: input.amount, + appliedAmount, + utilizedBefore, + utilizedAfter: updated.utilized, + }, + idempotencyKey: input.idempotencyKey, + }); + } + + return updated; + }); + } catch (error) { + if (error instanceof DuplicateIdempotencyKeyError) { + const current = await this.creditLineRepository.findById(input.id); + if (!current) { + throw new Error('Credit line not found'); + } + return current; + } + throw error; } + } +} - const amountNum = parseFloat(amount); - const utilizedNum = parseFloat(line.utilized || '0'); +function parsePositiveAmount(amount: string, label: string): number { + const amountNum = parseFloat(amount); + if (!Number.isFinite(amountNum) || amountNum <= 0) { + throw new Error(`${label} must be greater than 0`); + } + return amountNum; +} - return await this.creditLineRepository.update(id, { - utilized: Math.max(0, utilizedNum - amountNum).toString(), - }) as CreditLine; +function formatAmount(value: number): string { + if (!Number.isFinite(value) || value === 0 || Object.is(value, -0)) { + return '0'; } + return String(value); } diff --git a/src/services/__tests__/CreditLineService.atomic.test.ts b/src/services/__tests__/CreditLineService.atomic.test.ts new file mode 100644 index 0000000..3f669c7 --- /dev/null +++ b/src/services/__tests__/CreditLineService.atomic.test.ts @@ -0,0 +1,683 @@ +/** + * Atomic draw/repay tests. + * + * Covers the issue #301 contract: + * - no partial state after a failed mutation + * - audit records match the committed utilized balance + * - repeated repayment is deterministic and safe (floor at 0, idempotent keys) + * - injected database failures roll back + * - concurrent repayments serialize without going negative + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { CreditLineService } from '../CreditLineService.js'; +import { InMemoryCreditLineRepository } from '../../repositories/memory/InMemoryCreditLineRepository.js'; +import { InMemoryTransactionRepository } from '../../repositories/memory/InMemoryTransactionRepository.js'; +import { InMemoryAuditEventRepository } from '../../repositories/memory/InMemoryAuditEventRepository.js'; +import type { TransactionRepository } from '../../repositories/interfaces/TransactionRepository.js'; +import type { AuditEventRepository } from '../../repositories/interfaces/AuditEventRepository.js'; +import type { CreditLineRepository } from '../../repositories/interfaces/CreditLineRepository.js'; +import { CreditLineStatus, type CreditLine } from '../../models/CreditLine.js'; +import { TransactionStatus, TransactionType } from '../../models/Transaction.js'; +import { + DRAW_CONFIRMED_EVENT, + DuplicateIdempotencyKeyError, + REPAY_CONFIRMED_EVENT, + type AuditEvent, +} from '../../models/AuditEvent.js'; +import type { TransactionRunner } from '../../db/transaction.js'; +import type { DbClient } from '../../db/client.js'; +import { createDbTransactionRunner } from '../../db/transaction.js'; + +const WALLET = 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1'; +const OTHER = 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCX'; + +function createSnapshotRunner( + lines: InMemoryCreditLineRepository, + ledger: InMemoryTransactionRepository, + audit: InMemoryAuditEventRepository, +): TransactionRunner { + return async (work) => { + const lineSnap = lines.exportState(); + const ledgerSnap = ledger.exportState(); + const auditSnap = audit.exportState(); + try { + return await work(); + } catch (error) { + lines.importState(lineSnap); + ledger.importState(ledgerSnap); + audit.importState(auditSnap); + throw error; + } + }; +} + +async function seedLine( + lines: InMemoryCreditLineRepository, + utilized = '0', + creditLimit = '1000', +): Promise { + const created = await lines.create({ + walletAddress: WALLET, + creditLimit, + interestRateBps: 500, + }); + if (utilized === '0') { + return created; + } + const updated = await lines.update(created.id, { utilized }); + if (!updated) { + throw new Error('failed to seed utilized'); + } + return updated; +} + +function wrapLedgerFailAfterWrite( + inner: InMemoryTransactionRepository, +): TransactionRepository { + return { + create: async (request) => { + await inner.create(request); + throw new Error('injected ledger failure'); + }, + findById: (id) => inner.findById(id), + findByCreditLineId: (id, offset, limit) => inner.findByCreditLineId(id, offset, limit), + findByWalletAddress: (w, offset, limit) => inner.findByWalletAddress(w, offset, limit), + updateStatus: (id, status, processedAt) => inner.updateStatus(id, status, processedAt), + findAll: (offset, limit) => inner.findAll(offset, limit), + count: () => inner.count(), + findByStatus: (status, offset, limit) => inner.findByStatus(status, offset, limit), + }; +} + +function wrapAuditFailAfterWrite( + inner: InMemoryAuditEventRepository, +): AuditEventRepository { + return { + append: async (request) => { + await inner.append(request); + throw new Error('injected audit failure'); + }, + findByIdempotencyKey: (key) => inner.findByIdempotencyKey(key), + listByAggregate: (type, id) => inner.listByAggregate(type, id), + }; +} + +function wrapStateFailAfterWrite( + inner: InMemoryCreditLineRepository, +): CreditLineRepository { + return { + create: (req) => inner.create(req), + findById: (id) => inner.findById(id), + lockById: (id) => inner.lockById(id), + findByWalletAddress: (w) => inner.findByWalletAddress(w), + findAll: (offset, limit) => inner.findAll(offset, limit), + findAllWithCursor: (cursor, limit) => inner.findAllWithCursor(cursor, limit), + update: async (id, request) => { + await inner.update(id, request); + throw new Error('injected state failure'); + }, + delete: (id) => inner.delete(id), + exists: (id) => inner.exists(id), + count: () => inner.count(), + }; +} + +describe('CreditLineService atomic draw/repay', () => { + let lines: InMemoryCreditLineRepository; + let ledger: InMemoryTransactionRepository; + let audit: InMemoryAuditEventRepository; + let service: CreditLineService; + + beforeEach(() => { + lines = new InMemoryCreditLineRepository(); + ledger = new InMemoryTransactionRepository(); + audit = new InMemoryAuditEventRepository(); + service = new CreditLineService(lines, { + transactionRepository: ledger, + auditEventRepository: audit, + runInTransaction: createSnapshotRunner(lines, ledger, audit), + }); + }); + + describe('happy path', () => { + it('draw updates utilized, writes a borrow ledger row, and audits the new balance', async () => { + const line = await seedLine(lines); + + const updated = await service.draw(line.id, WALLET, '250'); + + expect(updated.utilized).toBe('250'); + const persisted = await lines.findById(line.id); + expect(persisted?.utilized).toBe('250'); + + const txs = await ledger.findByCreditLineId(line.id); + expect(txs).toHaveLength(1); + expect(txs[0]?.type).toBe(TransactionType.BORROW); + expect(txs[0]?.amount).toBe('250'); + + const events = await audit.listByAggregate('credit_line', line.id); + expect(events).toHaveLength(1); + expect(events[0]?.eventType).toBe(DRAW_CONFIRMED_EVENT); + expect(events[0]?.payload).toMatchObject({ + action: 'draw', + amount: '250', + appliedAmount: '250', + utilizedBefore: '0', + utilizedAfter: '250', + }); + expect(events[0]?.payload.utilizedAfter).toBe(persisted?.utilized); + }); + + it('repay reduces utilized, writes a repay ledger row, and audits the new balance', async () => { + const line = await seedLine(lines, '400'); + + const updated = await service.repay(line.id, WALLET, '150'); + + expect(updated.utilized).toBe('250'); + const events = await audit.listByAggregate('credit_line', line.id); + expect(events[0]?.eventType).toBe(REPAY_CONFIRMED_EVENT); + expect(events[0]?.payload).toMatchObject({ + action: 'repay', + amount: '150', + appliedAmount: '150', + utilizedBefore: '400', + utilizedAfter: '250', + }); + expect(events[0]?.payload.utilizedAfter).toBe(updated.utilized); + }); + }); + + describe('authorization and validation', () => { + it('rejects draw from a non-owner', async () => { + const line = await seedLine(lines); + await expect(service.draw(line.id, OTHER, '10')).rejects.toThrow('Unauthorized'); + expect(await ledger.count()).toBe(0); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(0); + }); + + it('rejects repay from a non-owner', async () => { + const line = await seedLine(lines, '50'); + await expect(service.repay(line.id, OTHER, '10')).rejects.toThrow('Unauthorized'); + expect((await lines.findById(line.id))?.utilized).toBe('50'); + expect(await ledger.count()).toBe(0); + }); + + it('rejects draw on a suspended line without writing', async () => { + const line = await seedLine(lines); + await lines.update(line.id, { status: CreditLineStatus.SUSPENDED }); + await expect(service.draw(line.id, WALLET, '10')).rejects.toThrow('Credit line is not active'); + expect(await ledger.count()).toBe(0); + }); + + it('rejects over-limit draw without writing', async () => { + const line = await seedLine(lines, '900'); + await expect(service.draw(line.id, WALLET, '101')).rejects.toThrow('Credit limit exceeded'); + expect((await lines.findById(line.id))?.utilized).toBe('900'); + expect(await ledger.count()).toBe(0); + }); + + it('rejects non-positive amounts', async () => { + const line = await seedLine(lines, '10'); + await expect(service.draw(line.id, WALLET, '0')).rejects.toThrow('Draw amount must be greater than 0'); + await expect(service.repay(line.id, WALLET, '-5')).rejects.toThrow('Repay amount must be greater than 0'); + await expect(service.repay(line.id, WALLET, 'n/a')).rejects.toThrow('Repay amount must be greater than 0'); + }); + + it('rejects draw when the credit line does not exist', async () => { + await expect(service.draw('missing', WALLET, '10')).rejects.toThrow('Credit line not found'); + }); + + it('treats a vanishing row during update as not found', async () => { + const line = await seedLine(lines); + const vanishing: CreditLineRepository = { + create: (req) => lines.create(req), + findById: (id) => lines.findById(id), + lockById: (id) => lines.lockById(id), + findByWalletAddress: (w) => lines.findByWalletAddress(w), + findAll: (offset, limit) => lines.findAll(offset, limit), + findAllWithCursor: (cursor, limit) => lines.findAllWithCursor(cursor, limit), + update: async () => null, + delete: (id) => lines.delete(id), + exists: (id) => lines.exists(id), + count: () => lines.count(), + }; + const vanishingService = new CreditLineService(vanishing, { + transactionRepository: ledger, + auditEventRepository: audit, + runInTransaction: createSnapshotRunner(lines, ledger, audit), + }); + await expect(vanishingService.draw(line.id, WALLET, '10')).rejects.toThrow('Credit line not found'); + expect(await ledger.count()).toBe(0); + }); + }); + + describe('no partial state after injected failures', () => { + it('rolls back state when the ledger write fails', async () => { + const line = await seedLine(lines); + const failing = new CreditLineService(lines, { + transactionRepository: wrapLedgerFailAfterWrite(ledger), + auditEventRepository: audit, + runInTransaction: createSnapshotRunner(lines, ledger, audit), + }); + + await expect(failing.draw(line.id, WALLET, '100')).rejects.toThrow('injected ledger failure'); + + expect((await lines.findById(line.id))?.utilized).toBe('0'); + expect(await ledger.count()).toBe(0); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(0); + }); + + it('rolls back state and ledger when the audit write fails', async () => { + const line = await seedLine(lines); + const failing = new CreditLineService(lines, { + transactionRepository: ledger, + auditEventRepository: wrapAuditFailAfterWrite(audit), + runInTransaction: createSnapshotRunner(lines, ledger, audit), + }); + + await expect(failing.draw(line.id, WALLET, '100')).rejects.toThrow('injected audit failure'); + + expect((await lines.findById(line.id))?.utilized).toBe('0'); + expect(await ledger.count()).toBe(0); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(0); + }); + + it('leaves no ledger or audit row when the state write fails', async () => { + const durableLines = new InMemoryCreditLineRepository(); + const line = await seedLine(durableLines); + const failing = new CreditLineService(wrapStateFailAfterWrite(durableLines), { + transactionRepository: ledger, + auditEventRepository: audit, + runInTransaction: createSnapshotRunner(durableLines, ledger, audit), + }); + + await expect(failing.repay(line.id, WALLET, '10')).rejects.toThrow('injected state failure'); + + expect(await ledger.count()).toBe(0); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(0); + }); + + it('rolls back repay the same way as draw when the ledger write fails', async () => { + const line = await seedLine(lines, '200'); + const failing = new CreditLineService(lines, { + transactionRepository: wrapLedgerFailAfterWrite(ledger), + auditEventRepository: audit, + runInTransaction: createSnapshotRunner(lines, ledger, audit), + }); + + await expect(failing.repay(line.id, WALLET, '50')).rejects.toThrow('injected ledger failure'); + + expect((await lines.findById(line.id))?.utilized).toBe('200'); + expect(await ledger.count()).toBe(0); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(0); + }); + }); + + describe('audit records match final balances exactly', () => { + it('each committed audit utilizedAfter equals the persisted line after that mutation', async () => { + const line = await seedLine(lines); + + const afterDraw = await service.draw(line.id, WALLET, '300'); + const afterRepay = await service.repay(line.id, WALLET, '80'); + + const events = await audit.listByAggregate('credit_line', line.id); + expect(events.map((e) => e.payload.utilizedAfter)).toEqual(['300', '220']); + expect(events[0]?.payload.utilizedAfter).toBe(afterDraw.utilized); + expect(events[1]?.payload.utilizedAfter).toBe(afterRepay.utilized); + expect((await lines.findById(line.id))?.utilized).toBe(events[1]?.payload.utilizedAfter); + }); + + it('does not persist an audit row whose utilizedAfter disagrees with the line', async () => { + const line = await seedLine(lines, '100'); + await service.repay(line.id, WALLET, '100'); + const events = await audit.listByAggregate('credit_line', line.id); + const persisted = await lines.findById(line.id); + expect(events).toHaveLength(1); + expect(events[0]?.payload.utilizedAfter).toBe('0'); + expect(persisted?.utilized).toBe('0'); + expect(events[0]?.payload.utilizedAfter).toBe(persisted?.utilized); + }); + }); + + describe('repeated repayment is deterministic and safe', () => { + it('floors utilized at 0 on over-repayment and records appliedAmount', async () => { + const line = await seedLine(lines, '40'); + const updated = await service.repay(line.id, WALLET, '100'); + expect(updated.utilized).toBe('0'); + + const events = await audit.listByAggregate('credit_line', line.id); + expect(events[0]?.payload.appliedAmount).toBe('40'); + expect(events[0]?.payload.amount).toBe('100'); + expect(events[0]?.payload.utilizedAfter).toBe('0'); + }); + + it('a second repay on a zero-utilized line stays at 0', async () => { + const line = await seedLine(lines, '10'); + await service.repay(line.id, WALLET, '10'); + const again = await service.repay(line.id, WALLET, '25'); + expect(again.utilized).toBe('0'); + + const events = await audit.listByAggregate('credit_line', line.id); + expect(events).toHaveLength(2); + expect(events[1]?.payload.appliedAmount).toBe('0'); + expect(events[1]?.payload.utilizedBefore).toBe('0'); + expect(events[1]?.payload.utilizedAfter).toBe('0'); + expect(events.every((e) => parseFloat(e.payload.utilizedAfter) >= 0)).toBe(true); + }); + + it('replays with the same idempotency key do not double-apply', async () => { + const line = await seedLine(lines, '80'); + const first = await service.repay(line.id, WALLET, '30', { idempotencyKey: 'repay-1' }); + const second = await service.repay(line.id, WALLET, '30', { idempotencyKey: 'repay-1' }); + + expect(first.utilized).toBe('50'); + expect(second.utilized).toBe('50'); + expect(await ledger.count()).toBe(1); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(1); + }); + + it('distinct idempotency keys still apply sequentially', async () => { + const line = await seedLine(lines, '80'); + await service.repay(line.id, WALLET, '30', { idempotencyKey: 'a' }); + await service.repay(line.id, WALLET, '30', { idempotencyKey: 'b' }); + expect((await lines.findById(line.id))?.utilized).toBe('20'); + expect(await ledger.count()).toBe(2); + }); + + it('returns the committed line when append races on the unique idempotency key', async () => { + const line = await seedLine(lines, '80'); + const racingAudit: AuditEventRepository = { + append: async () => { + throw new DuplicateIdempotencyKeyError('race-key'); + }, + findByIdempotencyKey: async () => null, + listByAggregate: (type, id) => audit.listByAggregate(type, id), + }; + const racing = new CreditLineService(lines, { + transactionRepository: ledger, + auditEventRepository: racingAudit, + runInTransaction: createSnapshotRunner(lines, ledger, audit), + }); + + const result = await racing.repay(line.id, WALLET, '20', { idempotencyKey: 'race-key' }); + expect(result.utilized).toBe('80'); + expect(await ledger.count()).toBe(0); + }); + + it('surfaces not-found when a duplicate-key race happens after the line is deleted', async () => { + const ghost: CreditLine = { + id: 'cl-gone', + walletAddress: WALLET, + creditLimit: '100', + availableCredit: '100', + utilized: '10', + interestRateBps: 0, + status: CreditLineStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + }; + const missing: CreditLineRepository = { + create: (req) => lines.create(req), + findById: async () => null, + lockById: async () => ghost, + findByWalletAddress: (w) => lines.findByWalletAddress(w), + findAll: (offset, limit) => lines.findAll(offset, limit), + findAllWithCursor: (cursor, limit) => lines.findAllWithCursor(cursor, limit), + update: async () => ghost, + delete: (id) => lines.delete(id), + exists: (id) => lines.exists(id), + count: () => lines.count(), + }; + const racingAudit: AuditEventRepository = { + append: async () => { + throw new DuplicateIdempotencyKeyError('gone'); + }, + findByIdempotencyKey: async () => null, + listByAggregate: (type, id) => audit.listByAggregate(type, id), + }; + const racing = new CreditLineService(missing, { + transactionRepository: ledger, + auditEventRepository: racingAudit, + }); + await expect( + racing.repay('cl-gone', WALLET, '10', { idempotencyKey: 'gone' }), + ).rejects.toThrow('Credit line not found'); + }); + + it('returns not-found when an idempotent replay cannot load the line', async () => { + const missing: CreditLineRepository = { + create: (req) => lines.create(req), + findById: async () => null, + lockById: async () => null, + findByWalletAddress: (w) => lines.findByWalletAddress(w), + findAll: (offset, limit) => lines.findAll(offset, limit), + findAllWithCursor: (cursor, limit) => lines.findAllWithCursor(cursor, limit), + update: async () => null, + delete: (id) => lines.delete(id), + exists: (id) => lines.exists(id), + count: () => lines.count(), + }; + const existingAudit: AuditEventRepository = { + append: (req) => audit.append(req), + findByIdempotencyKey: async () => ({ + id: 'evt', + eventType: REPAY_CONFIRMED_EVENT, + aggregateType: 'credit_line', + aggregateId: 'cl-1', + payload: { + action: 'repay', + walletAddress: WALLET, + amount: '10', + appliedAmount: '10', + utilizedBefore: '10', + utilizedAfter: '0', + }, + createdAt: new Date(), + }), + listByAggregate: (type, id) => audit.listByAggregate(type, id), + }; + const replay = new CreditLineService(missing, { + auditEventRepository: existingAudit, + }); + await expect( + replay.repay('cl-1', WALLET, '10', { idempotencyKey: 'replay' }), + ).rejects.toThrow('Credit line not found'); + }); + }); + + describe('concurrent calls', () => { + it('concurrent repayments never go negative and sum of applied equals start utilized', async () => { + const line = await seedLine(lines, '100'); + + const results = await Promise.all([ + service.repay(line.id, WALLET, '30'), + service.repay(line.id, WALLET, '30'), + service.repay(line.id, WALLET, '30'), + service.repay(line.id, WALLET, '30'), + ]); + + const final = await lines.findById(line.id); + expect(final?.utilized).toBe('0'); + expect(results.every((row) => parseFloat(row.utilized) >= 0)).toBe(true); + + const events = await audit.listByAggregate('credit_line', line.id); + expect(events).toHaveLength(4); + const applied = events.reduce((sum, event) => sum + parseFloat(event.payload.appliedAmount), 0); + expect(applied).toBe(100); + expect(events.every((event) => parseFloat(event.payload.utilizedAfter) >= 0)).toBe(true); + expect(events.at(-1)?.payload.utilizedAfter).toBe(final?.utilized); + assertSerializableAuditChain(events); + }); + + it('concurrent draws cannot exceed the credit limit', async () => { + const line = await seedLine(lines, '0', '100'); + + const outcomes = await Promise.allSettled([ + service.draw(line.id, WALLET, '60'), + service.draw(line.id, WALLET, '60'), + service.draw(line.id, WALLET, '60'), + ]); + + const fulfilled = outcomes.filter((o) => o.status === 'fulfilled'); + const rejected = outcomes.filter((o) => o.status === 'rejected'); + expect(fulfilled.length).toBe(1); + expect(rejected.length).toBe(2); + expect((await lines.findById(line.id))?.utilized).toBe('60'); + expect(await ledger.count()).toBe(1); + const events = await audit.listByAggregate('credit_line', line.id); + expect(events).toHaveLength(1); + expect(events[0]?.payload.utilizedAfter).toBe('60'); + }); + + it('concurrent identical idempotency keys apply the repayment once', async () => { + const line = await seedLine(lines, '90'); + + const [a, b] = await Promise.all([ + service.repay(line.id, WALLET, '40', { idempotencyKey: 'same' }), + service.repay(line.id, WALLET, '40', { idempotencyKey: 'same' }), + ]); + + expect(a.utilized).toBe('50'); + expect(b.utilized).toBe('50'); + expect(await ledger.count()).toBe(1); + expect(await audit.listByAggregate('credit_line', line.id)).toHaveLength(1); + }); + }); +}); + +function assertSerializableAuditChain(events: AuditEvent[]): void { + let expectedBefore = events[0]?.payload.utilizedBefore; + for (const event of events) { + expect(event.payload.utilizedBefore).toBe(expectedBefore); + const next = + parseFloat(event.payload.utilizedBefore) - + (event.payload.action === 'repay' ? parseFloat(event.payload.appliedAmount) : -parseFloat(event.payload.appliedAmount)); + expect(event.payload.utilizedAfter).toBe(String(next === 0 ? 0 : next)); + expectedBefore = event.payload.utilizedAfter; + } +} + +describe('CreditLineService + createDbTransactionRunner (SQL control flow)', () => { + const line: CreditLine = { + id: 'cl-1', + walletAddress: WALLET, + creditLimit: '1000', + availableCredit: '1000', + utilized: '0', + interestRateBps: 500, + status: CreditLineStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + }; + + function mockRepos(overrides: { + update?: CreditLineRepository['update']; + createTx?: TransactionRepository['create']; + append?: AuditEventRepository['append']; + } = {}): { + creditLines: CreditLineRepository; + transactions: TransactionRepository; + audits: AuditEventRepository; + } { + const creditLines: CreditLineRepository = { + create: vi.fn(), + findById: vi.fn(async () => line), + lockById: vi.fn(async () => line), + findByWalletAddress: vi.fn(), + findAll: vi.fn(), + findAllWithCursor: vi.fn(), + update: overrides.update ?? vi.fn(async (_id, req) => ({ + ...line, + utilized: req.utilized ?? line.utilized, + })), + delete: vi.fn(), + exists: vi.fn(), + count: vi.fn(), + }; + const transactions: TransactionRepository = { + create: overrides.createTx ?? vi.fn(async (req) => ({ + id: 'tx-1', + creditLineId: req.creditLineId, + walletAddress: WALLET, + amount: req.amount, + type: req.type, + status: TransactionStatus.PENDING, + createdAt: new Date(), + })), + findById: vi.fn(), + findByCreditLineId: vi.fn(), + findByWalletAddress: vi.fn(), + updateStatus: vi.fn(), + findAll: vi.fn(), + count: vi.fn(), + findByStatus: vi.fn(), + }; + const audits: AuditEventRepository = { + append: overrides.append ?? vi.fn(async (req) => ({ + id: 'evt-1', + eventType: req.eventType, + aggregateType: req.aggregateType, + aggregateId: req.aggregateId, + payload: req.payload, + idempotencyKey: req.idempotencyKey, + createdAt: new Date(), + })), + findByIdempotencyKey: vi.fn(async () => null), + listByAggregate: vi.fn(async () => []), + }; + return { creditLines, transactions, audits }; + } + + it('issues BEGIN … COMMIT around a successful multi-write draw', async () => { + const statements: string[] = []; + const client: DbClient = { + async query(text: string) { + statements.push(text.trim().toUpperCase().split(/\s+/)[0] ?? text); + return { rows: [{ id: 'x' }] }; + }, + async end() { + /* no-op */ + }, + }; + const { creditLines, transactions, audits } = mockRepos(); + const service = new CreditLineService(creditLines, { + transactionRepository: transactions, + auditEventRepository: audits, + runInTransaction: createDbTransactionRunner(client), + }); + + await service.draw('cl-1', WALLET, '10'); + + expect(statements[0]).toBe('BEGIN'); + expect(statements.at(-1)).toBe('COMMIT'); + expect(statements).not.toContain('ROLLBACK'); + }); + + it('issues BEGIN … ROLLBACK when a mid-flow write throws', async () => { + const statements: string[] = []; + const client: DbClient = { + async query(text: string) { + statements.push(text.trim().toUpperCase().split(/\s+/)[0] ?? text); + return { rows: [] }; + }, + async end() { + /* no-op */ + }, + }; + const { creditLines, transactions, audits } = mockRepos({ + append: vi.fn(async () => { + throw new Error('injected mid-flow failure'); + }), + }); + const service = new CreditLineService(creditLines, { + transactionRepository: transactions, + auditEventRepository: audits, + runInTransaction: createDbTransactionRunner(client), + }); + + await expect(service.draw('cl-1', WALLET, '10')).rejects.toThrow('injected mid-flow failure'); + + expect(statements[0]).toBe('BEGIN'); + expect(statements.at(-1)).toBe('ROLLBACK'); + expect(statements).not.toContain('COMMIT'); + }); +}); diff --git a/src/services/__tests__/CreditLineService.test.ts b/src/services/__tests__/CreditLineService.test.ts index 8d4f9e2..acbc483 100644 --- a/src/services/__tests__/CreditLineService.test.ts +++ b/src/services/__tests__/CreditLineService.test.ts @@ -300,4 +300,84 @@ describe('CreditLineService', () => { expect(result.hasMore).toBe(false); }); }); + + describe('draw / repay (legacy mock repository)', () => { + it('should draw against an active line owned by the borrower', async () => { + const creditLine: CreditLine = { + id: 'cl-123', + walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + creditLimit: '1000.00', + availableCredit: '1000.00', + utilized: '0', + interestRateBps: 500, + status: CreditLineStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date() + }; + + vi.mocked(mockRepository.findById).mockResolvedValue(creditLine); + vi.mocked(mockRepository.update).mockResolvedValue({ + ...creditLine, + utilized: '200', + availableCredit: '800', + }); + + const result = await service.draw('cl-123', creditLine.walletAddress, '200'); + + expect(mockRepository.update).toHaveBeenCalledWith('cl-123', { utilized: '200' }); + expect(result.utilized).toBe('200'); + }); + + it('should throw when draw exceeds the remaining limit', async () => { + vi.mocked(mockRepository.findById).mockResolvedValue({ + id: 'cl-123', + walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + creditLimit: '100', + availableCredit: '0', + utilized: '100', + interestRateBps: 500, + status: CreditLineStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date() + }); + + await expect( + service.draw('cl-123', 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', '1'), + ).rejects.toThrow('Credit limit exceeded'); + }); + + it('should repay down to a floor of zero', async () => { + vi.mocked(mockRepository.findById).mockResolvedValue({ + id: 'cl-123', + walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + creditLimit: '1000', + availableCredit: '600', + utilized: '400', + interestRateBps: 500, + status: CreditLineStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date() + }); + vi.mocked(mockRepository.update).mockImplementation(async (_id, request) => ({ + id: 'cl-123', + walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + creditLimit: '1000', + availableCredit: '1000', + utilized: request.utilized ?? '0', + interestRateBps: 500, + status: CreditLineStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date() + })); + + const result = await service.repay( + 'cl-123', + 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1', + '1000', + ); + + expect(mockRepository.update).toHaveBeenCalledWith('cl-123', { utilized: '0' }); + expect(result.utilized).toBe('0'); + }); + }); }); diff --git a/src/services/creditService.ts b/src/services/creditService.ts index b86f363..b0e34d1 100644 --- a/src/services/creditService.ts +++ b/src/services/creditService.ts @@ -287,6 +287,11 @@ export async function submitDrawRequest( body: DrawBody, soroban: SorobanClient = noopSorobanClient, ): Promise { + // Persist utilized + ledger + audit atomically *before* the chain submit. + // Soroban RPC stays outside the DB transaction (no I/O inside BEGIN). + const { Container } = await import('../container/Container.js'); + await Container.getInstance().creditLineService.draw(id, body.walletAddress, body.amount); + const txHash = await soroban.submitDraw(body.walletAddress, id, body.amount); return { id, @@ -302,6 +307,9 @@ export async function submitRepayRequest( body: RepayBody, soroban: SorobanClient = noopSorobanClient, ): Promise { + const { Container } = await import('../container/Container.js'); + await Container.getInstance().creditLineService.repay(id, body.walletAddress, body.amount); + const txHash = await soroban.submitRepay(body.walletAddress, id, body.amount); return { id, diff --git a/tests/draw-credit.test.ts b/tests/draw-credit.test.ts index 68064bb..4f2e846 100644 --- a/tests/draw-credit.test.ts +++ b/tests/draw-credit.test.ts @@ -10,12 +10,17 @@ app.use('/api/credit', creditRouter); describe('POST /api/credit/lines/:id/draw', () => { it('should draw successfully with valid body', async () => { + const created = await request(app) + .post('/api/credit/lines') + .send({ walletAddress: VALID_ADDRESS, requestedLimit: '1000' }); + const lineId = created.body.data.id; + const res = await request(app) - .post('/api/credit/lines/line-1/draw') + .post(`/api/credit/lines/${lineId}/draw`) .send({ walletAddress: VALID_ADDRESS, amount: '200' }); expect(res.status).toBe(200); - expect(res.body.id).toBe('line-1'); + expect(res.body.id).toBe(lineId); expect(res.body.walletAddress).toBe(VALID_ADDRESS); expect(res.body.amount).toBe('200'); expect(res.body.txHash).toBeNull(); diff --git a/tests/routes/routes.test.ts b/tests/routes/routes.test.ts index 6dbb179..779ff69 100644 --- a/tests/routes/routes.test.ts +++ b/tests/routes/routes.test.ts @@ -87,14 +87,19 @@ describe('Credit routes', () => { describe('POST /api/credit/lines/:id/draw', () => { it('returns 200 with valid body', async () => { + const created = await request(app) + .post('/api/credit/lines') + .send({ walletAddress: VALID_ADDRESS, requestedLimit: '1000' }); + const lineId = created.body.data.id; + const res = await request(app) - .post('/api/credit/lines/line-1/draw') + .post(`/api/credit/lines/${lineId}/draw`) .send({ walletAddress: VALID_ADDRESS, amount: '100' }); expect(res.status).toBe(200); expect(res.body.walletAddress).toBe(VALID_ADDRESS); expect(res.body.amount).toBe('100'); - expect(res.body.id).toBe('line-1'); + expect(res.body.id).toBe(lineId); expect(res.body.txHash).toBeNull(); expect(res.body.status).toBe('pending'); }); @@ -138,14 +143,22 @@ describe('Credit routes', () => { describe('POST /api/credit/lines/:id/repay', () => { it('returns 200 with valid body', async () => { + const created = await request(app) + .post('/api/credit/lines') + .send({ walletAddress: VALID_ADDRESS, requestedLimit: '1000' }); + const lineId = created.body.data.id; + await request(app) + .post(`/api/credit/lines/${lineId}/draw`) + .send({ walletAddress: VALID_ADDRESS, amount: '100' }); + const res = await request(app) - .post('/api/credit/lines/line-1/repay') + .post(`/api/credit/lines/${lineId}/repay`) .send({ walletAddress: VALID_ADDRESS, amount: '50' }); expect(res.status).toBe(200); expect(res.body.walletAddress).toBe(VALID_ADDRESS); expect(res.body.amount).toBe('50'); - expect(res.body.id).toBe('line-1'); + expect(res.body.id).toBe(lineId); expect(res.body.txHash).toBeNull(); expect(res.body.status).toBe('pending'); }); diff --git a/tests/services/creditService.test.ts b/tests/services/creditService.test.ts index efb5962..6e8b984 100644 --- a/tests/services/creditService.test.ts +++ b/tests/services/creditService.test.ts @@ -5,55 +5,82 @@ import { noopSorobanClient, } from '../../src/services/creditService.js'; import type { SorobanClient } from '../../src/services/creditService.js'; +import { Container } from '../../src/container/Container.js'; const VALID_ADDRESS = 'G' + 'A'.repeat(55); +async function seedLine() { + return Container.getInstance().creditLineService.createCreditLine({ + walletAddress: VALID_ADDRESS, + creditLimit: '1000', + interestRateBps: 500, + }); +} + describe('submitDrawRequest', () => { it('returns pending status when soroban client returns null', async () => { - const result = await submitDrawRequest('line-1', { + const line = await seedLine(); + const result = await submitDrawRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '100', }); - expect(result.id).toBe('line-1'); + expect(result.id).toBe(line.id); expect(result.walletAddress).toBe(VALID_ADDRESS); expect(result.amount).toBe('100'); expect(result.txHash).toBeNull(); expect(result.status).toBe('pending'); + const stored = await Container.getInstance().creditLineService.getCreditLine(line.id); + expect(stored?.utilized).toBe('100'); }); it('returns submitted status when soroban client returns a tx hash', async () => { + const line = await seedLine(); const mockClient: SorobanClient = { submitDraw: vi.fn().mockResolvedValue('tx-hash-abc'), submitRepay: vi.fn().mockResolvedValue(null), }; const result = await submitDrawRequest( - 'line-1', + line.id, { walletAddress: VALID_ADDRESS, amount: '100' }, mockClient, ); expect(result.txHash).toBe('tx-hash-abc'); expect(result.status).toBe('submitted'); - expect(mockClient.submitDraw).toHaveBeenCalledWith(VALID_ADDRESS, 'line-1', '100'); + expect(mockClient.submitDraw).toHaveBeenCalledWith(VALID_ADDRESS, line.id, '100'); }); it('propagates errors thrown by the soroban client', async () => { + const line = await seedLine(); const mockClient: SorobanClient = { submitDraw: vi.fn().mockRejectedValue(new Error('network error')), submitRepay: vi.fn().mockResolvedValue(null), }; await expect( - submitDrawRequest('line-1', { walletAddress: VALID_ADDRESS, amount: '100' }, mockClient), + submitDrawRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '100' }, mockClient), ).rejects.toThrow('network error'); }); + + it('does not call soroban when the credit-line mutation is rejected', async () => { + const mockClient: SorobanClient = { + submitDraw: vi.fn().mockResolvedValue('tx-hash-abc'), + submitRepay: vi.fn().mockResolvedValue(null), + }; + await expect( + submitDrawRequest('missing', { walletAddress: VALID_ADDRESS, amount: '100' }, mockClient), + ).rejects.toThrow('Credit line not found'); + expect(mockClient.submitDraw).not.toHaveBeenCalled(); + }); }); describe('submitRepayRequest', () => { it('returns pending status when soroban client returns null', async () => { - const result = await submitRepayRequest('line-1', { + const line = await seedLine(); + await submitDrawRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '100' }); + const result = await submitRepayRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '50', }); - expect(result.id).toBe('line-1'); + expect(result.id).toBe(line.id); expect(result.walletAddress).toBe(VALID_ADDRESS); expect(result.amount).toBe('50'); expect(result.txHash).toBeNull(); @@ -61,27 +88,31 @@ describe('submitRepayRequest', () => { }); it('returns submitted status when soroban client returns a tx hash', async () => { + const line = await seedLine(); + await submitDrawRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '100' }); const mockClient: SorobanClient = { submitDraw: vi.fn().mockResolvedValue(null), submitRepay: vi.fn().mockResolvedValue('tx-hash-xyz'), }; const result = await submitRepayRequest( - 'line-1', + line.id, { walletAddress: VALID_ADDRESS, amount: '50' }, mockClient, ); expect(result.txHash).toBe('tx-hash-xyz'); expect(result.status).toBe('submitted'); - expect(mockClient.submitRepay).toHaveBeenCalledWith(VALID_ADDRESS, 'line-1', '50'); + expect(mockClient.submitRepay).toHaveBeenCalledWith(VALID_ADDRESS, line.id, '50'); }); it('propagates errors thrown by the soroban client', async () => { + const line = await seedLine(); + await submitDrawRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '100' }); const mockClient: SorobanClient = { submitDraw: vi.fn().mockResolvedValue(null), submitRepay: vi.fn().mockRejectedValue(new Error('soroban error')), }; await expect( - submitRepayRequest('line-1', { walletAddress: VALID_ADDRESS, amount: '50' }, mockClient), + submitRepayRequest(line.id, { walletAddress: VALID_ADDRESS, amount: '50' }, mockClient), ).rejects.toThrow('soroban error'); }); });