Read-only. Reports what a corte would touch without touching it. The platform inherited legacy's BALANCE FORWARD rows (1,170, all dated 2026-01-01) but not the yearly process that produces them, and BillingService floors balances per customer on those rows. Two populations fall outside that floor, and they are unrelated defects that happen to surface as the same symptom — a customer whose balance reads as a credit the office does not owe. A. 102 customers have no BALANCE FORWARD row, so their balance is a raw lifetime sum. Only the current-year charge ledger (datos2) was migrated; the per-year charge tables stayed in DreamHost. What survives before the cutover is the EFECTIVO cash journal, and it shows: 199 of their 201 pre-cutover rows are credits. Flooring them at 2026-01-01 moves the book by -82,297.78 MXN and -52,020.20 USD. 91 of the 102 are left with no rows at all, so their balance becomes zero — an assertion, not a figure recovered from anywhere, which is why this script proposes and does not apply. B. 276 cash receipts booked twice in 2026 — once in EFECTIVO under folio N, once in datos2 as reference CN — across 120 customers, 986,240.00 MXN and 88,252.00 USD on the EFECTIVO side. Both rows sit after the floor so both count. The statement already hides them via STATEMENT_EXCLUDED_SOURCE_TABLES; the balances worklist, the movement browser and the /clientes/:id card do not. The folio alone does not establish a pair — folios are reused. Every pair is corroborated on money too: equal amounts when both legs share a currency, or an implied USD->MXN rate inside the band exchange_rates observed that year. Two of 278 folio matches fail that test and are reported apart rather than counted, both same-day partial applications that need a human. Matches the balance-forward row in both shapes, as numid.service.ts does. Databases imported before the type was minted carry those rows with typeId NULL, and name-only matching reports every customer as floorless on such a copy — including the dev database, which the API itself currently reads as +20,653,109.15 MXN against a floored -9,194.61. 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). WEB_ORIGIN is comma-separated — list every origin the
app is reached under, or credentialed fetches from the missing ones fail CORS.
The web app needs no API URL of its own: the browser derives it from the page it
loaded (same host on port 3001 over plain HTTP, or the same-origin /api path
behind a TLS proxy). Set NEXT_PUBLIC_API_ORIGIN (dev) or API_ORIGIN (deploy,
read at request time) only to override that — for instance when running the API
on a non-default port.
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).