BALANCE FORWARD rows are not movements. Access materialized one per customer per year, dated Jan 1, holding the closing balance of everything before it — that is what let the portal keep each year in its own table and still show a correct running balance from one year's rows. The platform imported those rows AND the real pre-cutover history they summarize, and every balance aggregate summed the lot. NUMid 501 read -10,469.29 on the receivables worklist against -14,065.29 on the customer's own statement and on the legacy portal; the gap was two cash receipts from 2009 and 2012 that the 2026 opening balance had already absorbed. The scale settles what it is: summed the old way the whole book came to +20,605,447.86 MXN — the office owing its customers 20.6 million pesos. Floored, it is -56,855.90. A receivables ledger cannot be 20M in credit. Adds BALANCE_FLOOR_JOIN + NOT_SUPERSEDED and applies them to balances() (page and count queries, which must agree), to stats()'s per-currency and per-domain figures, and to the owing/in-credit split. The four stats() aggregates moved from Prisma groupBy to raw SQL because groupBy cannot express a per-customer floor. statement() takes the same floor as a scalar, which is also what stops FEE ANUAL and fee15 leaking in. Those are not in STATEMENT_EXCLUDED_SOURCE_TABLES — that list reproduces legacy's DATOS2-only `datosfreak` — and they were putting 2,092 pre-cutover fee rows across 1,062 customers into the statement, skewing it by -5,129,764 against the number those customers have been quoted for years. Dating rather than source is the right test: a fee row *after* the opening balance is a real charge and still counts. movements() is deliberately left alone. It is a browser over captured rows — "how much water did we capture in April" — and staff need the historical rows visible, so it keeps totalling everything, the same asymmetry NOT_OUTSTANDING already has. stats() now separates the two questions it was mixing: movements, ledgerCustomers, crossLineCustomers and the date range stay unfloored inventory; everything under byCurrency/byDomain is a balance and is floored. BillingService had no tests. Adds 13 covering the floor's failure modes — it fails silently, so MIN-vs-MAX, `>` vs `>=`, the NULL branch for customers with no opening balance, and the join/predicate alias pairing are each pinned, plus the 501 arithmetic as a regression. Verified through the real service against the live ledger: balances() and statement() both return -14,065.29 for 501, matching the portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Jorge Cuadros & Asociados — Platform
Internal platform for a Baja California insurance brokerage and property-services
firm: a single expedient joining each client's properties/services,
insurance policies, account statement, and the firm's checkbook.
It replaces a legacy PHP/Access app (see RESUME.md and PLAN.md for the full
history and rebuild rationale, and docs/BACKLOG.md for
everything still outstanding).
The UI is Spanish-first; the codebase and this document are in English.
Stack
| Layer | Tech | Port |
|---|---|---|
| Web | Next.js 14 (App Router, React 18) | 3000 |
| API | NestJS 10 · Passport local + express-session · Argon2 |
3001 |
| Database | MySQL 8 via Prisma 5 (@jorgecuadros/database workspace pkg) |
3306 |
| Migration | Python 3 pipeline (legacy Access → staging → transforms) | — |
Monorepo managed with pnpm workspaces. Node >= 20.
Repository layout
apps/
web/ Next.js frontend (@jorgecuadros/web)
api/ NestJS backend (@jorgecuadros/api)
scripts/seed-user.mjs idempotent admin seeder
packages/
database/ Prisma schema + generated client (@jorgecuadros/database)
prisma/schema.prisma
migration/ One-off Python ETL from the legacy Access DB (run_all.py)
docker/ Dockerfiles for api + web
docker-compose.yml mysql + api + web
.env.example copy to .env
API feature modules: auth, users, customers, policies, properties,
billing, bank, reports, notifications, renewals, mail, statements,
policy-ocr, ocr, storage, settings, ops.
Web routes: /inicio, /clientes, /polizas (+ /polizas/captura, policy
PDF OCR capture), /servicios, /estado-cuenta, /banco (chequera),
/recibos (utility statement OCR capture), /notificaciones (mass email +
renewal avisos; /renovaciones is an alias onto its Pólizas tab), /reportes,
/catalogos, /operaciones (DB ingest/backup, ADMIN), /usuarios, /login.
Two OCR intakes share one OcrProvider seam (src/ocr/, Tesseract today):
utility statements → ledger rows (docs/STATEMENT_OCR.md)
and carrier policy PDFs → Policy rows (docs/POLICY_OCR.md).
Both need tesseract-ocr, tesseract-ocr-data-spa, poppler-utils and object
storage; each reports its own availability and disables only itself if either
is missing.
Prerequisites
- Node.js >= 20 and pnpm (
npm i -g pnpm) - Docker (for MySQL, or bring your own MySQL 8)
- Python 3 — only if you run the legacy data migration
Run it locally (development)
1. Install
pnpm install
pnpm blocks postinstall build scripts by default; the trusted ones
(argon2, prisma, @prisma/client, @prisma/engines, @nestjs/core) are
allowlisted in pnpm-workspace.yaml, so the native builds run automatically.
2. Configure environment
cp .env.example .env
Then edit .env. For the Docker MySQL below the defaults already line up;
just set a real SESSION_SECRET:
DATABASE_URL=mysql://jorgecuadros:jorgecuadros@localhost:3306/jorgecuadros
SESSION_SECRET=<any long random string>
WEB_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
The API loads DATABASE_URL, SESSION_SECRET, WEB_ORIGIN, and optional
PORT (default 3001). The web app only needs NEXT_PUBLIC_API_ORIGIN.
3. Start MySQL
docker compose up -d mysql
(Or point DATABASE_URL at an existing MySQL 8 instance.)
4. Create the schema + generate the Prisma client
The schema is managed with prisma db push (no migration history committed):
pnpm --filter @jorgecuadros/database exec prisma db push
pnpm --filter @jorgecuadros/database generate
5. Seed a sign-in user
node apps/api/scripts/seed-user.mjs
Idempotent (upsert by email). Defaults — override with SEED_EMAIL,
SEED_PASSWORD, SEED_NAME:
- email:
admin@jorgecuadros.local - password:
ChangeMe!2026 - role:
ADMIN
6. Run the apps (two terminals)
# API → http://localhost:3001
pnpm --filter @jorgecuadros/api start:dev
# Web → http://localhost:3000
pnpm --filter @jorgecuadros/web dev
Root shortcuts also exist: pnpm dev:api, pnpm dev:web.
7. Log in
Open http://localhost:3000, sign in with the seeded credentials. Sessions are cookie-based and last 8 hours.
Run it with Docker (full stack)
Builds MySQL + API + web from docker-compose.yml:
export SESSION_SECRET=$(openssl rand -hex 32)
docker compose up --build
Web on http://localhost:3000, API on http://localhost:3001. SESSION_SECRET
is required (compose fails without it). After first boot, push the schema and
seed a user against the container DB:
docker compose exec api node apps/api/scripts/seed-user.mjs
Common commands
| Task | Command |
|---|---|
| Install | pnpm install |
| Dev — API | pnpm dev:api |
| Dev — Web | pnpm dev:web |
| Build all | pnpm build |
| Generate Prisma client | pnpm prisma:generate |
| Push schema (dev) | pnpm --filter @jorgecuadros/database exec prisma db push |
| Prisma Studio | pnpm prisma:studio |
| API tests | pnpm --filter @jorgecuadros/api test |
| Lint (web / api) | pnpm --filter @jorgecuadros/web lint · ... /api lint |
| Seed admin user | node apps/api/scripts/seed-user.mjs |
Auth & roles
Session auth via Passport local strategy; passwords hashed with Argon2 (no
plaintext, unlike the legacy app). Roles gate the UI and API — e.g. managing
/catalogos and /usuarios requires the appropriate ability (ADMIN /
MANAGER). New users are created by an admin in /usuarios; the first admin
comes from the seed script above.
Legacy data migration (optional)
migration/ holds the one-off Python ETL that lifts data out of the old
Microsoft Access database into MySQL.
pip install -r migration/requirements.txt
python migration/run_all.py
⚠️
run_all.pytruncates and reloads all downstream tables. Never run an individualtransform_*.pyin isolation — it orphans dependent tables. Seemigration/RECONCILIATION.mdfor details.
Scheduled jobs
The API runs two automatic email sweeps. Neither cadence is in the source:
both are stored in app_settings and edited at /notificaciones →
"Programación de envíos" (ADMIN, setting:manage), taking effect immediately
without a restart. Shipped defaults:
| Job | Default | What it does |
|---|---|---|
| Pólizas | on, 06:00 daily (America/Tijuana) | Renewal avisos at 30/15 days before expiry and 7 days after. |
| Servicios | off | All four mass-email jobs in order, same as "Ejecutar todos". |
A scheduled run never uses the UI's send flags — in particular it ignores
debug, so a forgotten test toggle cannot silently stop customer mail. Full
detail in docs/MASS_EMAIL_NOTIFICATIONS.md.
Sending needs SES_* in the environment. Without it the API still boots and
logs mail to stdout in dev; in production every send fails loudly and is
recorded as FAILED rather than quietly going nowhere.
Production notes
- Use
pnpm --filter @jorgecuadros/database exec prisma migrate deployif/when a committed migration history is adopted; today dev usesdb push. - Set a strong
SESSION_SECRETand a locked-downDATABASE_URL. - The API expects
WEB_ORIGINto match the browser origin for session cookies. - Documents are stored in MinIO in the deployed environment (see
RESUME.md).