Staff key 300+ utility statements per company per month by hand. This adds the ingest -> split -> OCR -> match -> review pipeline that proposes customer and amount per page instead (RECEIPT_CAPTURE_SPEC §2), posting through the existing BillingService.createBatch seam with source=OCR and a per-document captureRef so machine and hand capture share one write path and audit trail. Everything was designed against 10 real scanned statements (46 pages of CFE, CESPT and Telnor bills) rather than from the sample-free spec. The scans have no text layer at all — they are camera images — so OCR is mandatory, and they arrive bundled one customer per page. Measured on those pages the parser identifies the provider 46/46 and reads an account reference 43/46; against the dev database that is 39/46 (85%) exact auto-match, 40/46 identified, with the rest genuine review cases. That closes the OCR-provider question in favour of self-hosted Tesseract: it clears the bar for a queue where a human confirms every row, and OcrProvider keeps a managed API a one-line swap. The samples corrected three things the spec had wrong or unknown: - Clave catastral is NOT predial. DATMEX.clave (934 rows) is what CESPT and predial bills print; DATMEX.predial, which PROPERTY_TAX.accountNumber holds, has 663 distinct values across 1135 rows and appears on no statement. The clave now lives on Property.cadastralKey as the matcher's secondary key; predial is left untouched. This had been blocking predial matching. - Gas was recoverable: 160 of 334 DATMEX.gas values are real account numbers (the rest are ESTACIONARIO/CILINDRO descriptors), now in GAS.meterNumber. - Phone is one billed line per property (534/18/1 across phone1/2/3), so the new TELEPHONE ServiceKind backfills from phone1 only, not three rows. Matching is scoped to one column per service kind and never reads the customer name — a CESPT receipt prints ARNAIZ ROSAS ELSA AURORA for an account this office holds under CATT, RANDY, because the printed name is the registrant, not the current owner. Where a provider prints a payment barcode it beats the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two cross-check, with disagreement forcing review. Confirming a document whose service had no reference writes it back, so gas and any other cold start is a one-time cost rather than a permanent queue. Verified end to end against the live dev API and MinIO: real scans uploaded over HTTP, matched, confirmed against a check, and the resulting rows checked in MySQL (negative amounts, captureSource=OCR, concept derived from the batch kind, captureRef linking back to each page). Re-confirming a posted batch is refused. Test data was removed afterwards. 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).
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. Web routes: /clientes, /polizas, /servicios,
/estado-cuenta, /banco (chequera), /catalogos, /usuarios, /login.
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.
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).