Skip to main content

H-061 — Modules constructing their own PrismaClient

Audit ref: Section 11.4 · Priority P1 (Phase 1) · Owners: Backend lead + Database lead Verification note: the audit said "at least ten"; the verified count on dev was nine non-test modules (plus the canonical client in backend/src/services/database.ts), one of which (deviceFingerprint) has no production references — eight live.

The core hazard was never the connection count. A financial helper holding its own client cannot join the caller's $transaction: its writes commit immediately and independently, so a caller rollback leaves half-applied fund movements. This is the mechanism behind several split-commit findings elsewhere in the audit.

Remediation table​

#Where this is happeningHow it is fixedImpact if not fixedImpact after the fix
1backend/src/accounting/ledgerService.ts — own client; recordDoubleEntry(input, tx?) fell back to it when tx was omitted, and the recordBetPlaced / recordBetSettled / recordAgentSettlement / recordPlatformCommission / recordMarginProfit wrappers never accepted tx at allImports the shared client from services/database.ts; tx is now required on recordDoubleEntry and threaded through all five wrappers — a double-entry write can no longer escape the caller's transactionLedger entries commit outside the caller's transaction: a rolled-back settlement/commission/reversal still leaves ledger rows behind, so the double-entry audit trail disagrees with balances (split commit on the money audit path)Every ledger write is provably inside the caller's transaction — enforced at compile time, not by convention
2backend/src/accounting/commissionService.ts — own client; saveCommissionRecord(..., tx?) optionalShared client; tx required (moved to 2nd parameter); sole caller (services/settlement.ts processMarketCommission) already ran a transaction and now passes it by force of the signatureA future caller omitting tx would commit the commission record + ledger entry outside the balance-decrement transaction — user charged with no record, or record with no chargeCommission record, ledger entry, balance decrement and take delta are one atomic commit, guaranteed by the type system
3backend/src/accounting/marginRevenueService.ts — own client; saveMarginRecord(input, tx?) optional, and its only caller (settlement.ts post-settlement side effects) passed no tx — so the marginRecord row and its ledger double-entry went through two different clients with no atomicity between themShared client; tx required; the call site in settlement.ts now wraps the write in its own prisma.$transaction (it runs post-settlement-commit by design)A crash between the two writes leaves a margin record with no ledger entry (or vice versa) — margin revenue reporting and the ledger silently divergeMargin record + ledger double-entry commit atomically
4backend/src/accounting/agentSettlementService.ts — own client; confirmSettlement performed ledger write, status flip and agent balance increment as three separate commitsShared client; confirmSettlement deleted — it belonged to the removed period-based settlement system (callers deleted in the Phase 5 cleanup, 2026-04-03) and had been unreachable since; review also flagged a TOCTOU race in it, which deletion resolvesLatently racy, split-commit money code stays exported and one wired-up route away from double-paying an agentThe dead path no longer exists; fluid settlement (v2) is untouched
5backend/src/accounting/reportService.ts — own client (read-only paths)Shared client importExtra idle connection pool per process; never disconnected on shutdownOne pool; clean shutdown via the existing disconnectDatabase()
6backend/src/services/notificationService.ts — own clientShared client importExtra pool; never disconnectedOne pool; clean shutdown
7backend/src/services/pushService.ts — own clientShared client importExtra pool; never disconnectedOne pool; clean shutdown
8backend/src/services/fraud/index.ts — worker constructed its own client on start and was the only module that disconnected it (stopFraudWorker)Worker now reuses the shared application client; stopFraudWorker no longer calls $disconnect (the shared client is owned by process shutdown, disconnectDatabase())Extra pool per process while the worker runs; risk that a future in-process consumer of the worker's client is severed when the worker stopsOne pool; worker stop cannot sever database access for the rest of the process
9backend/src/middleware/deviceFingerprint.ts — own client; dead code (no production references; covered only by platformIntegration.test.ts)Shared client import so the module cannot leak a pool if ever wired back in; module removal deferred to a separate chore (needs its own approval)Latent trap: re-enabling the middleware silently reintroduces a second poolNeutralised; removal tracked separately

Connection-pool arithmetic (why "if not fixed" is also a capacity problem)​

Each PrismaClient maintains its own pool of num_cpus × 2 + 1 connections (default). Nine clients on a 4-vCPU box ≈ 81 potential connections per process against Postgres's max_connections, of which 8 pools were mostly idle and 7 were never disconnected on shutdown. After the fix there is exactly one pool, sized and disconnected in one place (services/database.ts). If post-consolidation load saturates the single pool, raise connection_limit on the datasource URL — one knob instead of nine implicit ones.

Enforcement mechanism​

The fix is structural, not advisory: recordDoubleEntry, its five wrappers, saveCommissionRecord and saveMarginRecord now require Prisma.TransactionClient. Code that forgets the transaction does not compile. No runtime behavior changes on the happy path — the same rows are written; they now commit together instead of separately.

Out of scope / follow-ups​

  • Removing the dead deviceFingerprint middleware and the five unreferenced ledger wrappers (recordBetPlaced, recordBetSettled, recordAgentSettlement, recordPlatformCommission, recordMarginProfit — the third orphaned by confirmSettlement's deletion) — dead-code chore, needs explicit approval.
  • getAccountBalance keeps tx optional: it is a pure read used legitimately outside transactions; recordDoubleEntry always passes its own tx into it.
  • Tuning the shared pool's connection_limit after observing post-consolidation load on dev.