rmancinasandClaude Opus 5 4d5008b545
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s
feat(statements): OCR intake for scanned utility bills
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>
2026-08-01 00:42:35 -07:00
2026-07-30 16:38:06 -07:00

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.py truncates and reloads all downstream tables. Never run an individual transform_*.py in isolation — it orphans dependent tables. See migration/RECONCILIATION.md for details.


Production notes

  • Use pnpm --filter @jorgecuadros/database exec prisma migrate deploy if/when a committed migration history is adopted; today dev uses db push.
  • Set a strong SESSION_SECRET and a locked-down DATABASE_URL.
  • The API expects WEB_ORIGIN to match the browser origin for session cookies.
  • Documents are stored in MinIO in the deployed environment (see RESUME.md).
S
Description
No description provided
Readme
2.2 MiB
Languages
TypeScript 82.3%
Python 10.1%
CSS 3.7%
JavaScript 3%
Shell 0.5%
Other 0.4%