rmancinasandClaude Opus 5 5bce0e4c94 feat(recibos): OCR capture for zona federal (ZOFEMAT Tijuana)
Adds the ZONA FEDERAL TIJUANA parser to the statement intake, measured
against 8 pages of real "Zona Federal Marítimo Terrestre" receipts — the
federal maritime-zone occupancy fee the municipality bills on beachfront
lots. Provider read on 8/8, amount on 8/8 (each verified against the
paper), concession clave on 6/8, period on 8/8, deadline on 2/8.

Four things the corpus forced:

- Tijuana bills predial and zona federal from the same treasury: same
  header, same Paseo del Centenario address, same ATB-541201 RFC. Every
  predial discriminator matches a zona federal page too, so whichever
  rule is asked first wins it. The only words exclusive to this layout
  are "Marítimo Terrestre", so its brand rule is asked ahead of all
  three predial ones — and its structural rule, anchored on the stub's
  "Derechos de ocupación", ahead of theirs.

- FEDERAL_ZONE.accountNumber is an amount, not a reference. It holds
  DATMEX.zfed, whose 77 values include 246.06, 2369.09, 22653.94 and a
  negative -1679, while the concession claves these receipts are keyed
  by appear nowhere in the database. Matching on that column could never
  hit — and because every row already has a value, the `[field]: null`
  guards on learnAccountRefs and on the review blank-service fill would
  never fire either, so every page would return to the queue every
  bimester forever. The clave moves to meterNumber, joining gas and
  Tijuana predial, and the first confirm teaches the match.

- The payable figure is not the printed subtotal. The municipality
  rounds to whole pesos and prints the difference on its own "Ajuste Ley
  Hacienda Mpal" line (-$0.05 against a 591.05 subtotal, $0.21 against
  2,872.79). The "Total a pagar" box carrying the rounded figure sits on
  a grey fill and OCR'd on 1 of 8 pages; the SubTotal row read on 8 of
  8. So the amount is the rounded subtotal, cross-checked against the
  printed box wherever it survives — where it did, it agreed.

- The clave is 2 digits, a letter and 3 digits (12-T -012), not the
  cadastral shape, and the letter is kept as printed: toDigits maps D to
  0, which turns a real 14-D -014 into 140014. It is printed twice,
  which rescued a page whose heading was struck through by the office's
  own highlighter — the failure mode behind both missing claves.

Deriving the deadline from the bimester is deliberately not attempted:
it is the 17th of the month after the bimester closes on a current bill,
but four of these eight are late (a $1,000 Multa) and print a
recalculated date, so a derived date would be wrong on exactly the pages
a human most wants to see.

Re-ran the earlier corpora (25 pages: predial Tijuana/Rosarito/Ensenada,
CFE, CESPT, Telnor) through detection to confirm the new rules steal
nothing — all 25 still read as their original provider, including the
five Tijuana predial pages that share the RFC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:07:21 -07:00
2026-08-01 09:22:56 +00:00
2026-08-01 09:22:56 +00: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%