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 happening | How it is fixed | Impact if not fixed | Impact after the fix |
|---|---|---|---|---|
| 1 | backend/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 all | Imports 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 transaction | Ledger 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 |
| 2 | backend/src/accounting/commissionService.ts — own client; saveCommissionRecord(..., tx?) optional | Shared 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 signature | A 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 charge | Commission record, ledger entry, balance decrement and take delta are one atomic commit, guaranteed by the type system |
| 3 | backend/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 them | Shared 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 diverge | Margin record + ledger double-entry commit atomically |
| 4 | backend/src/accounting/agentSettlementService.ts — own client; confirmSettlement performed ledger write, status flip and agent balance increment as three separate commits | Shared 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 resolves | Latently racy, split-commit money code stays exported and one wired-up route away from double-paying an agent | The dead path no longer exists; fluid settlement (v2) is untouched |
| 5 | backend/src/accounting/reportService.ts — own client (read-only paths) | Shared client import | Extra idle connection pool per process; never disconnected on shutdown | One pool; clean shutdown via the existing disconnectDatabase() |
| 6 | backend/src/services/notificationService.ts — own client | Shared client import | Extra pool; never disconnected | One pool; clean shutdown |
| 7 | backend/src/services/pushService.ts — own client | Shared client import | Extra pool; never disconnected | One pool; clean shutdown |
| 8 | backend/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 stops | One pool; worker stop cannot sever database access for the rest of the process |
| 9 | backend/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 pool | Neutralised; 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
deviceFingerprintmiddleware and the five unreferenced ledger wrappers (recordBetPlaced,recordBetSettled,recordAgentSettlement,recordPlatformCommission,recordMarginProfit— the third orphaned byconfirmSettlement's deletion) — dead-code chore, needs explicit approval. getAccountBalancekeepstxoptional: it is a pure read used legitimately outside transactions;recordDoubleEntryalways passes its owntxinto it.- Tuning the shared pool's
connection_limitafter observing post-consolidation load on dev.