The problem
A finance factory was running its loan operation on spreadsheets and ad-hoc tools. The volume was already meaningful — hundreds of borrowers, thousands of contracts, monthly invoice cycles, manual mora and dunning reconciliation — and every manual step was a place where money slipped or compliance broke.
The system had to cover the full lifecycle: client onboarding, loan request approval workflow, automatic installment generation, late-fee + daily-mora accumulation, dunning via WhatsApp, renegotiation flows, and a CRM-grade audit trail per borrower.
Architecture
A monorepo with a NestJS 11 API and a Next.js 15 frontend, sharing a PostgreSQL database via Prisma 6. Background work runs on BullMQ + Redis; WhatsApp messages go out through the Evolution API.
┌─ Next.js 15 + React 19 ─────────────────────────┐
│ TanStack Query · Radix UI · Tailwind 4 │
│ JWT via HTTP-only cookies (withCredentials) │
└──────────────────┬──────────────────────────────┘
│ axios (typed via Zod)
┌─ NestJS 11 API ──┴──────────────────────────────┐
│ Auth · Clients · Requests · Invoices · Payments│
│ Renegotiations · Communications · Dashboard │
└──────┬───────────────────────────────┬──────────┘
│ │
PostgreSQL BullMQ + Redis ─→ Evolution API
(Prisma 6) (whatsapp-messages queue)
├─ random delay 5–15s
├─ 3 retries, exp. backoff
└─ CommunicationLog records
Daily cron jobs (Brazil timezone)
The scheduler runs three jobs in America/Sao_Paulo, in order:
- 05:00 — overdue processing: invoices past
dueDateget the configuredlateFee%applied, status flips toUNPAID, request totals are updated. - 05:01 — mora accumulation:
(lateFeeMora% / 30) × original_value × days_overdueis added daily. Renegotiations always compound against the original request’s value, not the renegotiated one — a non-obvious financial rule that matters for compliance. - 06:00 (weekdays only) — charge messages: groups today’s-and-overdue invoices by request, enqueues per-borrower WhatsApp reminders via Evolution API.
Each of the three has a manual recovery entrypoint for replays without re-triggering accidental side effects.
Domain modeling
The financial state machine maps to a typed Prisma enum: OPEN → APPROVED → ACTIVE → PAID / RENEGOTIATED / UNAPPROVED / LOST. The dashboard reads cached aggregates (cachedTotalValuePaid, cachedTotalValueToBreakeven, metricsLastUpdated) so portfolio-level views stay sub-100ms even with thousands of active contracts.
Access control is department-scoped — LOAN_MANAGEMENT, STREET_SUPERVISOR, RENEGOTIATION_ANALYST, plus a SUPER_ADMIN override. Roles are wired through AuthGuard + RolesGuard decorators on every controller.
Outcome
In production. The factory operates entirely inside the system — no spreadsheets — and the queues handle the daily invoice cycle without human intervention. The numbers above are live counts.