Compare commits

..
19 Commits
Author SHA1 Message Date
gitea-actions a9b4aab7ec chore(release): v1.0.2
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m12s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.2.
2026-08-01 08:23:07 +00:00
rmancinasandClaude Opus 5 a8afd87c3f ci: add a "Cut release" dispatch workflow
Stamps every package.json, commits chore(release): vX.Y.Z, tags and pushes
both refs in one dispatch — patch/minor/major, or an explicit number. Cutting
a release from a laptop is how a manifest bump gets forgotten or a tag lands
on an unpushed commit; the only input here is the number.

Guards: refuses a version that already exists as a tag (releases are
immutable), a no-op bump, a leading `v`, and a malformed number. Checkout is
full-depth because the duplicate-tag check is meaningless against a shallow
clone.

Pushes with a RELEASE_TOKEN PAT rather than the built-in Actions token —
whether a push made with that token re-triggers build.yml depends on the Gitea
version, and a release that quietly publishes no images is worse than one that
fails outright.

Builds and deploys stay separate: the tag push triggers build.yml, and
deploying remains a deliberate dispatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:21:13 -07:00
rmancinasandClaude Opus 5 b59abda895 feat(captura): fold recibo OCR into Captura as an automatic mode
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
Scanning a stack of bills and keying them in are the same daily job, ending
in the same ledger path, so OCR intake becomes a mode of the capture screen
instead of a second menu entry:

- components/Captura.tsx holds the mode switch; the manual check form moves
  verbatim to components/ManualCheckCapture.tsx and the OCR intake to
  components/StatementIntake.tsx.
- /estado-cuenta/lote opens on manual, /recibos on automatic — both render
  Captura, so batch-review links and old bookmarks still land right.
- Nav drops "Recibos (OCR)"; "Captura" covers both, with a NavLink.aliases
  field so /recibos still highlights it.

Also fixes the "El almacenamiento de documentos no está configurado" failure
staff hit on upload. Uploading with no object storage configured used to
succeed, then die on the first put minutes later, leaving a FAILED batch
whose only explanation was that string. createBatch now refuses up front,
GET /statements/status reports storageAvailable alongside ocrAvailable, and
the intake tab explains the situation instead of offering an upload that
cannot work. S3_* documented in .env.example (deploy stacks already set it).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 01:04:09 -07:00
rmancinasandClaude Opus 5 4d5008b545 feat(statements): OCR intake for scanned utility bills
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s
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
rmancinasandClaude Opus 5 121952fdc1 chore(release): v1.0.1
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m6s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m22s
v1.0.0's images were built from db2bd54, which predates the full-hash
footer. Deploying 1.0.0 would ship the abbreviated footer, so the version
that actually goes to galactus is 1.0.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:38:06 -07:00
rmancinasandClaude Opus 5 15f533b984 fix(web): show the full commit hash in the build footer
The footer abbreviated to 7 characters, so the line read
"v master · 19f0319". That line exists to be pasted into `git show` or
compared against a registry tag, and an abbreviation makes both a manual
step — while the full 40-char value was already baked into the image
(build.yml passes `github.sha` whole, and /version returns it untouched).

`shortSha` had no other caller, so it goes with it.

The span gets `overflow-wrap: anywhere` and `min-width: 0`: hex offers no
break opportunity, and the api/web mismatch branch renders two of these
hashes side by side, which would otherwise push a phone into horizontal
scroll. Measured at a simulated 360px with both hashes present — the span
wraps, and documentElement.scrollWidth stays equal to clientWidth.

Verified in the browser against the dev database: footer renders
"v1.0.0 · db2bd54c0ffee1234567890abcdef0123456789a", hash length 40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:37:48 -07:00
rmancinasandClaude Opus 5 db2bd545a1 chore(release): v1.0.0
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m28s
Every manifest still read 0.1.0 while the deployed images were addressed by
the moving tag `latest`. That combination is what hid the stale-image bug:
a checkout could not be placed against a running container, and `latest`
silently kept serving two-commit-old web code through a green deploy.

Tagging v1.0.0 makes docker/metadata-action publish immutable `1.0.0` and
`1.0` image tags, so deploys can name a version instead of a moving target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:21:35 -07:00
rmancinasandClaude Opus 5 30dfc7dc3e fix(ops): run backups as an admin login, and stop recording failed dumps as good
The Operaciones panel (backup, restore, sync, re-import) shelled out to
mysqldump as the application user, parsed straight out of DATABASE_URL.
`--single-transaction` issues FLUSH TABLES, which needs the global RELOAD
privilege, and the app user is granted only ALL ON jorgecuadros.* plus
USAGE ON *.*. BACKUP failed outright; SYNC and REIMPORT failed with it,
since both take a safety backup first.

An admin credential is now supplied out of band via OPS_DB_ADMIN_USER /
OPS_DB_ADMIN_PASSWORD, mirroring what deploy/scripts/pre-migrate-backup.mjs
already does, rather than permanently elevating the user the API serves
requests as. Host, port and database still come from DATABASE_URL, so the
override can only change who logs in, never which server. Unset, it falls
back to the DATABASE_URL credentials and warns — local development is
unaffected.

Two defects in the dumps themselves, both shared with the deploy backup
before it was rewritten:

- No --set-gtid-purged=OFF. The production server is the replication source
  with GTID on, so every dump embedded SET @@GLOBAL.GTID_PURGED and was
  unrestorable onto the server it came from — the one thing the restore
  screen is for.

- The pipeline's exit status was gzip's, and gzip succeeded. A mysqldump
  that died on its first statement left a small, perfectly valid archive
  that the job recorded as SUCCESS and the restore screen listed as an
  ordinary restore point. Dumps now run under `set -o pipefail`, assert a
  CREATE TABLE count, and delete their own output on failure. Verified with
  a stubbed mysqldump: a failing dump exits 1, surfaces the real error,
  removes the partial file, and — critically — stops SYNC/REIMPORT before
  the ETL touches anything.

Restores gained pipefail too: a corrupt archive made gunzip fail while
mysql, fed a truncated stream, could still exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:21:00 -07:00
rmancinasandClaude Opus 5 d5ebb86cae fix(deploy): dump from a dedicated container as root, and prove the dump is real
The pre-migrate backup ran INSIDE the API container, which made it depend on
that image's toolchain — and deadlocked: the running image shipped a MySQL
client that could not authenticate, so the backup failed, which blocked the
very deploy that would have replaced the broken image. A backup must not depend
on the thing being deployed.

The dump now runs in a throwaway container built from mysql:8.4 with the API's
backup volume mounted. The volume name is discovered from the API container's
mounts, so the file still lands where the Operaciones restore screen looks. As
a container rather than an exec, its logs can simply be read — no more failures
reported as a bare exit code. The image is pulled if the host lacks it, since a
scope:app deploy never touches the db stack.

Three further defects found while verifying, none of which would have surfaced
without dumping against the real database:

- The dump now runs as root. mysqldump --single-transaction issues FLUSH
  TABLES, needing the global RELOAD privilege; the MySQL image grants the
  application user only ALL ON `<db>`.*, and --skip-lock-tables does not avoid
  it. Elevating the app's own runtime user would have been the worse trade.

- --set-gtid-purged=OFF. galactus is the replication SOURCE with GTID on, so a
  default dump embeds SET @@GLOBAL.GTID_PURGED and is unrestorable onto the
  server it came from. Verified: 0 GTID_PURGED lines in the output.

- Verification was too weak to be worth having. `test -s` plus `gzip -t` passes
  on a 372-byte gzip containing no tables, which is exactly what a dump that
  died on its first statement produces. It now asserts a CREATE TABLE count and
  logs it. A failed attempt also deletes its own output, so a truncated file
  never appears in the restore list.

Verified against live prod, both paths: success writes a 31-table dump the API
container can see; a wrong password fails with mysqldump's own error quoted and
leaves the volume empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:03:40 -07:00
rmancinasandClaude Opus 5 19f03198d6 fix(docker): install the MySQL 8.4 auth plugin; report why a dump fails
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m56s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m3s
The pre-migrate backup failed with "mysqldump exited 2" and nothing else.
Reproduced on the host with stderr captured:

  ERROR 1045: Plugin caching_sha2_password could not be loaded:
    /usr/lib/mariadb/plugin/caching_sha2_password.so: No such file or directory

Alpine's `mysql-client` is MariaDB's client and ships an EMPTY plugin
directory, so it cannot perform caching_sha2_password — MySQL 8.4's default and
effectively only auth method. `mariadb-connector-c` provides the plugin.

This was never about the deploy backup alone. Every mysqldump/mysql call from
the API container was broken, which means the whole Operaciones panel — backup,
restore, sync, re-import — could not work in a container. It went unnoticed
because that feature had only ever been run with the API on a developer
machine, where the Oracle client is installed. Verified after the fix: dump
exits 0, gzip valid, 31 CREATE TABLEs.

Also fixed, both found while chasing the above:

- The backup script reported an exit code and nothing else, because a detached
  exec captures no output — which is precisely why this needed a manual
  reproduction. mysqldump's stderr is now redirected to a file and read back
  through a short attached exec on failure, so the deploy log states the cause.
  Verified against live prod: the log now carries the 1045 line itself.

- Listing ONLY 100.100.100.100 as the containers' resolver costs them public
  DNS, since MagicDNS does not forward upstream unless the tailnet defines
  global nameservers. Nothing at runtime needed it, but `apk` inside the
  container stopped resolving, and anything outbound would have too. A public
  fallback resolver is now listed after MagicDNS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:52:03 -07:00
rmancinasandClaude Opus 5 b2cdcbe2cd fix(api): session cookie never issued over HTTP; ship the seed script
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m5s
Prod came up with nobody able to log in, in two separate ways.

1. No sign-in account exists. `prisma migrate deploy` creates tables, never
   rows, and nothing in the deploy path seeds one — deliberately, since making
   an administrator should not be a side effect of shipping code. But
   apps/api/scripts was not in the runtime image either, so the only way to
   create the first account was to run the script from a developer machine
   against a production DATABASE_URL. Ship scripts/ in the image so it can be
   run on the host with docker exec. Still never run automatically.

2. Login could not establish a session at all. cookie.secure followed NODE_ENV,
   the image sets NODE_ENV=production, and the app is served over plain HTTP —
   express-session then silently emits NO Set-Cookie header. POST /auth/login
   still answered 200 with the full user object, no session was created, every
   later request 403'd, and the UI would have looped back to /login. It reads
   as an auth bug and is really a transport mismatch.

   The flag is now driven by SESSION_COOKIE_SECURE, still defaulting to
   NODE_ENV. An EMPTY value counts as unset rather than false, because compose
   turns an absent `${SESSION_COOKIE_SECURE:-}` into the empty string and the
   naive check would have quietly dropped Secure on any deployment that merely
   passed the variable through.

   galactus sets it to "false". That is acceptable ONLY because the host is
   reachable exclusively over Tailscale, so WireGuard already encrypts the
   wire. It must go back to "true" when the app is served over TLS or exposed
   off-tailnet; behind a TLS-terminating proxy, set trust proxy instead.

Verified against live prod: seeded an admin, POST /auth/login returns 200 with
full ADMIN abilities, a wrong password is rejected with 401, and no Set-Cookie
was present before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:41:55 -07:00
rmancinasandClaude Opus 5 7e3b530174 fix(deploy): pull images explicitly, and detect api/web drift by commit
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m46s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m58s
The first successful galactus deploy came up all-green while the web tier was
running a build from two commits earlier. The registry held web:latest from
3ff56e6; the host still had a web:latest cached from 4ee7ec7; the deploy
reported success and served the old one. The API was only current because it
had been pulled by hand during earlier debugging.

Two independent failures, both fixed here.

1. Images are not pulled. The deploy action's `pull: true` does not reliably
   refresh an already-cached moving tag on a standalone endpoint. Added a
   Pull images step (deploy/scripts/pull-images.mjs) that pulls each image
   through Portainer's Docker API with registry credentials and fails the
   deploy if a pull fails — note the endpoint answers 200 even when the pull
   errored, so the stream body has to be inspected, not just the status.

2. The drift check could not see it. Both the verify step and the web footer
   compared APP_VERSION, but on a branch build BOTH tiers report "master", so
   equality proved nothing. They now compare gitSha, which is the only field
   that differs between two builds of the same branch. api and web come from
   one matrix run, so a difference can only mean an image was not replaced.

   This needed a /version on the web tier too — previously its build identity
   was only readable by scraping window.__APP_BUILD__ out of the HTML.

pull-images.mjs builds the X-Registry-Auth header as URL-safe base64 WITH
padding: Node's "base64url" omits the padding and Portainer's Go decoder
rejects it with "Illegal base64 data at input byte N".

Verified against galactus: pulls both images, and exits non-zero on a
nonexistent tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:57:44 -07:00
rmancinasandClaude Opus 5 1cba9bfc32 fix(galactus): give containers Tailscale's resolver so MagicDNS names resolve
With the image fixed, the API got as far as connecting and then died with
Prisma P1001 "can't reach database server". The cause is DNS, not routing.

galactus runs systemd-resolved, whose 127.0.0.53 stub is unreachable from
inside a container, so Docker falls back to the upstream resolver in
/run/systemd/resolve/resolv.conf — the LAN router, which knows nothing about
the tailnet. Verified from a probe container on galactus: resolving
galactus.tail01aa2.ts.net fails outright, while `nc 100.103.77.46 3306` is
OPEN. Only the lookup was broken.

Pin the api and web services to Tailscale's own resolver (100.100.100.100,
the same anycast address on every tailnet) with this tailnet's search suffix.
Both are overridable via TAILSCALE_DNS / TAILNET_SUFFIX. db and minio need
nothing — they make no outbound calls.

Verified end to end: the published image, unmodified, with only these DNS
settings, boots on galactus against the real database and serves
  /health   {"status":"ok"}
  /version  {"service":"api","version":"master","gitSha":"3ff56e6b..."}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:47:19 -07:00
rmancinasandClaude Opus 5 3ff56e6b72 fix(docker): API image could never boot — missing workspace link and Prisma engine
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m44s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m57s
Two independent defects in docker/api.Dockerfile, both found by booting the
published image on galactus rather than by reading it. Neither had ever been
observed because no deploy had previously got far enough to start the API.

1. "Cannot find module '@jorgecuadros/database'".
   node-linker=hoisted flattens EXTERNAL dependencies into /repo/node_modules,
   but the workspace dependency stays linked per-package at
   apps/api/node_modules/@jorgecuadros/database -> ../../../../packages/database.
   The runtime stage copied only /repo/node_modules, so the link was dropped.
   Copy the @jorgecuadros scope dir as well — not the whole directory, whose
   only other contents are devDependencies.

2. "Prisma Client could not locate the Query Engine for runtime
   linux-musl-openssl-3.0.x ... generated for linux-musl".
   Prisma picks its engine by sniffing the build environment. The build stage
   had no openssl so it generated for plain "linux-musl", while the runtime
   stage demanded the openssl-3.0.x variant and refused to start. Fixed at both
   ends: binaryTargets now names the musl target explicitly in schema.prisma,
   so the shipped engine no longer depends on what happens to be installed at
   build time, and openssl is installed in the deps stage (generate) and the
   runtime stage (Prisma needs it regardless).

Verified by running the published image on galactus with each fix patched in
by hand, against the real database, until it got past both failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:39:25 -07:00
rmancinasandClaude Opus 5 27f04f1073 fix(deploy): preflight missing secrets instead of failing opaquely
The first deploy attempt (run 705) died on "Input required and not supplied:
token", which names the action's input rather than the secret that was unset —
the repo had only REGISTRY_USERNAME and REGISTRY_PASSWORD, so every deploy
secret was missing on both workflows. That is also why the endpoint_id /
pull_image input-name bug had gone unnoticed: neither workflow had ever got
far enough to use them.

Both workflows now check their required secrets up front and fail listing the
ones that are empty. The scope=full-only secrets are only required when the
dispatch is actually scope=full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:24:43 -07:00
rmancinasandClaude Opus 5 4ee7ec71f0 feat(deploy): prisma migration history, /version, galactus standalone deploy
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m2s
Closes the gap between "what tag did I deploy" and "what is actually running",
and gives the schema a history that can be reasoned about across releases.

Migrations
- Baseline the existing schema as 0000_init (migrate diff --from-empty). The
  schema had only ever been applied with `prisma db push`, so no history
  existed and schema state was disconnected from app version. Existing
  databases must be baselined once with `migrate resolve --applied 0000_init`;
  the workflows print this remedy on P3005.
- Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a
  CMD, N replicas would race each other applying the same migration.

Version reporting
- GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that
  build.yml already baked into both images but nothing ever read.
- The web footer shows the web build and flags an api/web mismatch. The two
  cannot drift at build time (one matrix run) but can at deploy time.
- Both deploy workflows now fail if the running API does not report the tag
  that was dispatched — a stack naming a tag is not proof of what is running.
- scripts/set-version.mjs stamps every package.json, which had all sat at
  0.1.0 while real releases shipped as v1.x.

Pre-migrate backup
- deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the
  still-running old API container over Portainer's Docker API, so the file
  lands in the volume the Operaciones restore screen reads. A dump taken on
  the CI runner would be unreachable by the only restore path we have.
  Verifies the artefact with `gzip -t` before letting the migration proceed.

galactus
- deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks.
  Plain compose silently ignores `deploy:`, so restart_policy becomes
  `restart: unless-stopped` — without it nothing returns after a host reboot.
- .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets.

Fixes
- deploy.yml passed `endpoint_id` and `pull_image` to
  cssnr/portainer-stack-deploy-action, which has no such inputs (they are
  `endpoint` and `pull`). The endpoint was silently never set.

docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema
changes: Prisma has no down-migrations, so a code rollback never rolls the
schema back, and restoring the replication master from a dump diverges every
replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:41:12 -07:00
rmancinasandClaude Opus 5 9ba5d2d09a feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s
The office keeps more than one operating account (Utilities banks in MXN,
Seguros in USD), but bank_transactions was a single implicit MXN register by
design. Adds Bank/BankAccount and makes every read and write in the module
scoped to exactly one account.

Schema:
- Bank / BankAccount. Currency is fixed per account and BankTransaction has
  no currency column of its own — a movement inherits its account's, the way
  a real bank account doesn't mix currencies.
- BankTransaction.bankAccountId, required. A movement with no known account
  isn't reconcilable against a statement.
- @@index([bankAccountId, transactionDate]): every read now filters by
  account and orders/groups by date.

Migration:
- backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)"
  and backfills all 22,669 existing rows onto it, then promotes the column to
  NOT NULL and attaches the FK. Standalone because prisma db push cannot add
  a required column to a populated table. Idempotent; re-running once a second
  account exists does not re-point rows.
- run_all.py runs it (both modes) before transform_bank.py, which now resolves
  the account by label and fails fast if it is missing.

API:
- ?bankAccountId= required on list/stats/facets/summary — not optional with an
  "all accounts" default, since summing an MXN and a USD register repeats the
  currency-collapsing mistake the billing module exists to prevent. Missing is
  400, unknown is 404.
- facets() had no account clause at all and summary() has two raw-SQL rollups;
  all three are now parameterised. Scoping only one of summary's queries would
  leave the year list and its drill-down describing different books.
- New bank/accounts + bank/banks sub-resource under a MANAGER
  bank:manage-accounts ability. currency is absent from the update DTO: booked
  movements are denominated in it, so editing would re-denominate history.
  Capture into a closed account is rejected.

Web:
- /banco gains an account picker (remembered per browser) and reads every
  figure in the selected account's currency; the "single currency (MXN)"
  doc-comment and the hardcoded MXN formatting are gone.
- New /banco/cuentas for banks and accounts. Accounts are closed, never
  deleted — the FK is required, so deleting one would destroy its register.
- /inicio's chequera card names the account it is reading instead of implying
  a single register.

Verified against dev + browser: a second USD account showed full read/write
isolation from the MXN register, whose totals were unchanged (22,669
movements, net 1,014,266.97).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:54:16 -07:00
rmancinasandClaude Opus 5 c100dfa224 feat(web,api): scale spacing with text size, persist preference per account
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m13s
Build and Push Images / Build jorgecuadros-api (push) Successful in 3m16s
Two follow-ups to the text-size control.

Spacing now scales with the text. All padding, margin, gap and min-height
declarations in globals.css move from px to rem (263 declarations, converted
mechanically), so --ui-scale drives the whole layout rather than just the
glyphs. Deliberately left in px: border widths, which must stay hairlines;
box-shadow offsets; border-radius, which reads as bloated when scaled on large
cards; --shell-max, a container cap that must not outgrow the viewport; and
media-query breakpoints, which are conditions rather than declarations. With
spacing following along, the presets gain a 1.5 "Máximo" step and MAX_UI_SCALE
rises from 1.4.

The preference now lives on the account instead of only in one browser.
User.uiScale (Float, default 1) is added to the schema and to the safe select,
so it rides along on /auth/login and /auth/me. PATCH /auth/preferences writes
it, guarded by AuthenticatedGuard only — every role including VIEWER may set
their own, and the target is always the session's user id, never a body
parameter, so this cannot be used to touch another account. The global
ValidationPipe's whitelist rejects any extra field, so role cannot ride in
alongside uiScale.

localStorage stays, demoted to a pre-paint cache for the layout.tsx script;
AppShell reconciles it against the account once /auth/me answers, with the
account winning. FontScaleControl becomes a controlled component since the
same value is now edited from the appbar and the drawer.

Verified against the dev API: PATCH persists and is reflected by a subsequent
/auth/me, out-of-range values are rejected 400, and an extra "role" field in
the body is rejected 400.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:30:06 -07:00
rmancinasandClaude Opus 5 0bf97e6d2c feat(web): group top nav, add mobile drawer and app-wide text size control
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m37s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s
The appbar had grown to 11 flat links with no responsive behaviour, and
overflowed below ~1100px.

Nav is now 7 top-level entries: Inicio, Clientes, Pólizas, Propiedades,
Reportes stay one click away, while the movement screens (Captura, Estado de
cuenta, Chequera) and the admin screens (Catálogos, Usuarios, Operaciones)
collapse into "Cobranza" and "Admin" dropdowns. Groups are ability-filtered
and disappear entirely when the user can see none of their items, so VIEWER
never renders an empty Admin menu. activeHref now scans the flattened link
list, and a group trigger highlights while one of its children is current.

Below 980px the nav collapses to a burger drawer that lists every group
expanded, closing on navigation and on Escape.

Text size is user-adjustable app-wide. Every font-size in globals.css is
converted from px to rem (mechanically, 133 declarations) and the root size
becomes calc(100% * var(--ui-scale)), so one variable on <html> rescales the
whole UI. The preference persists in localStorage and is applied by a
pre-hydration script in layout.tsx to avoid a flash at the default size; the
Aa control lives in the appbar and, as a segmented row, in the drawer.
Spacing stays in px by design, which is why 1.3 is the largest preset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 22:16:30 -07:00
70 changed files with 9148 additions and 1111 deletions
+17
View File
@@ -4,6 +4,23 @@ SESSION_SECRET=change-me-to-a-random-string
WEB_ORIGIN=http://localhost:3000 WEB_ORIGIN=http://localhost:3000
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001 NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
# Object storage (MinIO / S3) for document blobs and scanned receipt pages.
# Without S3_ENDPOINT + credentials the API still boots, but every document
# upload/download and the whole recibo OCR intake are disabled. Credentials fall
# back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD when the S3_* pair is unset.
S3_ENDPOINT=http://localhost:9000
S3_BUCKET=jorgecuadros-documents
S3_ACCESS_KEY=
S3_SECRET_KEY=
# Login the "Operaciones" screen runs mysqldump/mysql as. Optional locally: when
# unset it falls back to the DATABASE_URL credentials, which a dev MySQL usually
# grants enough for. Required in any deployment, where the application user has
# only ALL ON jorgecuadros.* and mysqldump --single-transaction needs the global
# RELOAD privilege. Host/port/database always come from DATABASE_URL.
OPS_DB_ADMIN_USER=
OPS_DB_ADMIN_PASSWORD=
# Company info — printed in the header of every report (PDF + browser # Company info — printed in the header of every report (PDF + browser
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is # print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
# optional; when unset the API falls back to apps/api/assets/company_logo.png. # optional; when unset the API falls back to apps/api/assets/company_logo.png.
+319
View File
@@ -0,0 +1,319 @@
# Manual PROD deploy to galactus — the office server, Portainer endpoint 3.
#
# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies
# the compose files under deploy/galactus/, NOT the Swarm files in deploy/.
# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept
# separate on purpose because plain compose silently ignores Swarm's `deploy:`
# keys rather than failing on them.
#
# This does NOT build. build.yml already built + pushed both images from one
# matrix run, so api and web at the same tag are always in step.
#
# Order of operations, and why:
# 1. db + minio (scope=full only) — the API depends on both.
# 2. pre-migrate backup dumped INSIDE the still-running OLD api container,
# so the file lands in the volume the Operaciones
# restore screen reads. Must precede the migration.
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
# the rule, the backup is the emergency lever.
# 4. app (api + web) the new images.
# 5. verify ask the running API what it actually is.
#
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
# schema stays forward. This is exactly why every schema change must be
# backward-compatible with the previous release.
#
# Prereqs (once):
# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex
# secrets keep working side by side):
# PORTAINER_URL_GALACTUS https://100.103.77.46:9443
# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus
# PORTAINER_ENDPOINT_ID_GALACTUS 3
# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app
# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db
# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio
# DATABASE_URL_GALACTUS mysql://jorgecuadros:<pass>@<galactus>:3306/jorgecuadros
# APP_API_ORIGIN_GALACTUS browser-facing API URL
# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS)
# APP_S3_ENDPOINT_GALACTUS server-side minio URL
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
# - The runner (which lives on cubex) must be able to reach BOTH
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
# If it cannot reach 3306, run the migration by hand from a host that can
# and dispatch with skip_migrate=true.
# - ONE-TIME, on a database that predates migration history (i.e. one built
# with `prisma db push`): baseline it before the first run, or step 3 fails
# with P3005 "database schema is not empty":
# npx prisma@5 migrate resolve --applied 0000_init \
# --schema packages/database/prisma/schema.prisma
name: Deploy to galactus
on:
workflow_dispatch:
inputs:
tag:
description: "Image tag to deploy (1.2.3 — no leading v — or sha-<short>, or latest)"
required: true
default: "latest"
scope:
description: "What to deploy"
type: choice
required: true
default: "app"
options:
- app
- full
bootstrap:
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
type: boolean
required: false
default: false
skip_migrate:
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
type: boolean
required: false
default: false
env:
REGISTRY: git.mancinas.io
jobs:
deploy:
name: Deploy ${{ github.event.inputs.tag }} (${{ github.event.inputs.scope }})
runs-on: docker
container:
image: node:20-alpine
steps:
- name: Install tools
# openssl: prisma's migration engine picks its musl/openssl build at
# runtime and cannot resolve one without it.
run: apk add --no-cache openssl ca-certificates git
- uses: actions/checkout@v4
# An unset secret arrives as an empty string, and the deploy action then
# fails with "Input required and not supplied: token" — which names the
# action's input, not the secret you forgot. Check them up front and say
# exactly which ones are missing.
- name: Preflight — required secrets
env:
PORTAINER_URL_GALACTUS: ${{ secrets.PORTAINER_URL_GALACTUS }}
PORTAINER_API_KEY_GALACTUS: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
PORTAINER_ENDPOINT_ID_GALACTUS: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
PORTAINER_APP_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
PORTAINER_DB_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
PORTAINER_MINIO_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
DATABASE_URL_GALACTUS: ${{ secrets.DATABASE_URL_GALACTUS }}
SESSION_SECRET_GALACTUS: ${{ secrets.SESSION_SECRET_GALACTUS }}
APP_API_ORIGIN_GALACTUS: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
APP_WEB_ORIGIN_GALACTUS: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
APP_S3_ENDPOINT_GALACTUS: ${{ secrets.APP_S3_ENDPOINT_GALACTUS }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
SCOPE: ${{ github.event.inputs.scope }}
run: |
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
PORTAINER_ENDPOINT_ID_GALACTUS PORTAINER_APP_STACK_NAME_GALACTUS
DATABASE_URL_GALACTUS SESSION_SECRET_GALACTUS
APP_API_ORIGIN_GALACTUS APP_WEB_ORIGIN_GALACTUS
APP_S3_ENDPOINT_GALACTUS MINIO_ROOT_USER MINIO_ROOT_PASSWORD
MYSQL_ROOT_PASSWORD"
if [ "$SCOPE" = "full" ]; then
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME_GALACTUS
PORTAINER_MINIO_STACK_NAME_GALACTUS MYSQL_PASSWORD"
fi
missing=""
for name in $REQUIRED; do
eval "value=\${$name}"
[ -z "$value" ] && missing="$missing $name"
done
if [ -n "$missing" ]; then
echo "::error::missing repo secrets:$missing"
echo "::error::set them under Settings > Actions > Secrets"
exit 1
fi
echo "all required secrets present for scope=$SCOPE"
# --- full only: database ---------------------------------------------
- name: Deploy database stack
if: ${{ github.event.inputs.scope == 'full' }}
uses: cssnr/portainer-stack-deploy-action@v1
with:
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
file: deploy/galactus/jorgecuadros-db.compose.yml
type: file
standalone: true
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
env_data: |
{
"MYSQL_SERVER_ID": "1",
"MYSQL_PORT": "3306",
"MYSQL_DATABASE": "jorgecuadros",
"MYSQL_USER": "jorgecuadros",
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
}
# --- full only: object storage ---------------------------------------
- name: Deploy minio stack
if: ${{ github.event.inputs.scope == 'full' }}
uses: cssnr/portainer-stack-deploy-action@v1
with:
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
file: deploy/galactus/jorgecuadros-minio.compose.yml
type: file
standalone: true
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
env_data: |
{
"MINIO_API_PORT": "9000",
"MINIO_CONSOLE_PORT": "9001",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
}
# --- restore point, taken while the OLD api container is still up ------
- name: Pre-migrate backup
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
# The dump runs as root: --single-transaction issues FLUSH TABLES,
# which needs the global RELOAD privilege the application user
# deliberately does not have.
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
BACKUP_TAG: ${{ github.event.inputs.tag }}
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
# Portainer serves a self-signed certificate. Scoped to this step
# only, which does nothing but talk to Portainer.
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/pre-migrate-backup.mjs
# --- schema, forward-only ---------------------------------------------
- name: Apply database migrations
if: ${{ github.event.inputs.skip_migrate != 'true' }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
run: |
set -e
SCHEMA=packages/database/prisma/schema.prisma
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
echo "::error::the database predates migration history — baseline it once with:"
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
exit 1
fi
# --- make sure the host actually has the images ------------------------
# The deploy action's `pull: true` does not reliably refresh an already
# cached moving tag. Pull explicitly, or a "successful" deploy can leave
# the host serving an older build of the same tag.
- name: Pull images
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
REGISTRY: ${{ env.REGISTRY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
TAG: ${{ github.event.inputs.tag }}
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/pull-images.mjs
# --- always: the app (web + api) -------------------------------------
- name: Deploy app stack
uses: cssnr/portainer-stack-deploy-action@v1
with:
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
file: deploy/galactus/jorgecuadros-app.compose.yml
type: file
standalone: true
pull: true
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
env_data: |
{
"APP_TAG": "${{ github.event.inputs.tag }}",
"API_PORT": "3001",
"WEB_PORT": "3000",
"S3_BUCKET": "jorgecuadros-documents",
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
"SESSION_COOKIE_SECURE": "false",
"OPS_DB_ADMIN_USER": "root",
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
}
# --- prove it ----------------------------------------------------------
- name: Verify running version
env:
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
WANT: ${{ github.event.inputs.tag }}
# A stack naming a tag is not proof the containers run it. Ask BOTH
# tiers what they are, and require them to be the same commit: api and
# web are built from one matrix run, so a difference can only mean one
# of them did not actually get replaced.
run: |
set -e
apk add --no-cache curl >/dev/null
fetch_version() {
for i in $(seq 1 30); do
if curl -fsS "$1/version" > "$2"; then return 0; fi
echo "waiting for $1 ($i/30)..."
sleep 5
done
echo "::error::$1/version never answered"
return 1
}
fetch_version "$API_ORIGIN" /tmp/api.json
fetch_version "$WEB_ORIGIN" /tmp/web.json
cat /tmp/api.json; echo; cat /tmp/web.json; echo
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
# Compare the COMMIT, not the version string: on a branch build both
# tiers report "master", so version equality proves nothing.
if [ "$API_SHA" != "$WEB_SHA" ]; then
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
echo "::error::one of the images was not replaced; check the Pull images step"
exit 1
fi
echo "api and web agree: $API_SHA"
# A semver dispatch is additionally comparable to the tag itself:
# metadata-action's {{version}} turns tag v1.2.3 into image 1.2.3,
# while `latest` and `sha-*` report the branch or short sha instead.
case "$WANT" in
[0-9]*.[0-9]*.[0-9]*)
if [ "$API_VER" != "$WANT" ]; then
echo "::error::deployed $WANT but the API reports $API_VER"
exit 1
fi
echo "verified: running $API_VER"
;;
*)
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
;;
esac
+192 -5
View File
@@ -8,6 +8,17 @@
# app = web + api only (the usual app release) [default] # app = web + api only (the usual app release) [default]
# full = db + minio + web + api (bring up / update the whole platform) # full = db + minio + web + api (bring up / update the whole platform)
# #
# The `tag` input carries NO leading `v`: metadata-action's {{version}} turns
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
#
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
# app -> verify the API reports the version you asked for. Rollback = dispatch
# an older tag; that rolls back CODE only, never the schema, which is why every
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
#
# galactus (the office server) is standalone Docker, not this Swarm — it has its
# own workflow, .gitea/workflows/deploy-galactus.yml.
#
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates # cssnr/portainer-stack-deploy-action creates each stack on first run and updates
# it on every run, so no manual stack pre-creation in the Portainer UI. On a # it on every run, so no manual stack pre-creation in the Portainer UI. On a
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends # `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
@@ -36,6 +47,14 @@
# # Database stack (full only) # # Database stack (full only)
# MYSQL_PASSWORD app-user password (matches DATABASE_URL) # MYSQL_PASSWORD app-user password (matches DATABASE_URL)
# MYSQL_ROOT_PASSWORD mysql root password # MYSQL_ROOT_PASSWORD mysql root password
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
# migration step connects to the database directly. If it cannot reach 3306,
# migrate by hand and dispatch with skip_migrate=true.
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
# that exists today): baseline it before the first run, or the migrate step
# fails with P3005 "database schema is not empty":
# npx prisma@5 migrate resolve --applied 0000_init \
# --schema packages/database/prisma/schema.prisma
name: Deploy to Portainer name: Deploy to Portainer
@@ -54,6 +73,16 @@ on:
options: options:
- app - app
- full - full
bootstrap:
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
type: boolean
required: false
default: false
skip_migrate:
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
type: boolean
required: false
default: false
env: env:
REGISTRY: git.mancinas.io REGISTRY: git.mancinas.io
@@ -63,10 +92,57 @@ jobs:
name: Deploy (${{ github.event.inputs.scope }}) name: Deploy (${{ github.event.inputs.scope }})
runs-on: docker runs-on: docker
container: container:
image: node:18-alpine image: node:20-alpine
steps: steps:
- name: Install tools
# openssl: prisma's migration engine picks its musl/openssl build at
# runtime and cannot resolve one without it.
run: apk add --no-cache openssl ca-certificates git
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# An unset secret arrives as an empty string, and the deploy action then
# fails with "Input required and not supplied: token" — which names the
# action's input, not the secret you forgot.
- name: Preflight — required secrets
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
PORTAINER_APP_STACK_NAME: ${{ secrets.PORTAINER_APP_STACK_NAME }}
PORTAINER_DB_STACK_NAME: ${{ secrets.PORTAINER_DB_STACK_NAME }}
PORTAINER_MINIO_STACK_NAME: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
APP_API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
APP_WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
APP_S3_ENDPOINT: ${{ secrets.APP_S3_ENDPOINT }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
SCOPE: ${{ github.event.inputs.scope }}
run: |
REQUIRED="PORTAINER_URL PORTAINER_API_KEY PORTAINER_ENDPOINT_ID
PORTAINER_APP_STACK_NAME DATABASE_URL SESSION_SECRET
APP_API_ORIGIN APP_WEB_ORIGIN APP_S3_ENDPOINT
MINIO_ROOT_USER MINIO_ROOT_PASSWORD MYSQL_ROOT_PASSWORD"
if [ "$SCOPE" = "full" ]; then
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME
MYSQL_PASSWORD"
fi
missing=""
for name in $REQUIRED; do
eval "value=\${$name}"
[ -z "$value" ] && missing="$missing $name"
done
if [ -n "$missing" ]; then
echo "::error::missing repo secrets:$missing"
echo "::error::set them under Settings > Actions > Secrets"
exit 1
fi
echo "all required secrets present for scope=$SCOPE"
# --- full only: database --------------------------------------------- # --- full only: database ---------------------------------------------
- name: Deploy database stack - name: Deploy database stack
if: ${{ github.event.inputs.scope == 'full' }} if: ${{ github.event.inputs.scope == 'full' }}
@@ -77,7 +153,7 @@ jobs:
name: ${{ secrets.PORTAINER_DB_STACK_NAME }} name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
file: deploy/jorgecuadros-db.stack.yml file: deploy/jorgecuadros-db.stack.yml
type: file type: file
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }} endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
env_data: | env_data: |
{ {
"MYSQL_SERVER_ID": "1", "MYSQL_SERVER_ID": "1",
@@ -98,7 +174,7 @@ jobs:
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }} name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
file: deploy/jorgecuadros-minio.stack.yml file: deploy/jorgecuadros-minio.stack.yml
type: file type: file
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }} endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
env_data: | env_data: |
{ {
"MINIO_API_PORT": "9000", "MINIO_API_PORT": "9000",
@@ -107,6 +183,65 @@ jobs:
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
} }
# --- restore point, taken while the OLD api container is still up ------
# Dumped INSIDE the running api container so the file lands in the volume
# the "Operaciones" restore screen reads — a dump on the runner would be
# unreachable by the only restore path this platform has.
- name: Pre-migrate backup
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# The dump runs as root: --single-transaction issues FLUSH TABLES,
# which needs the global RELOAD privilege the application user
# deliberately does not have.
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
BACKUP_TAG: ${{ github.event.inputs.tag }}
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
# Portainer serves a self-signed certificate. Scoped to this step
# only, which does nothing but talk to Portainer.
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/pre-migrate-backup.mjs
# --- schema, forward-only ---------------------------------------------
# Prisma has no down-migrations: a code rollback does NOT roll the schema
# back. See docs/DEPLOY_AND_MIGRATIONS.md — every change must be
# expand/contract so the previous release still runs against the new
# schema. Run as a deploy STEP, never as the container CMD: N replicas
# would race each other applying the same migration.
- name: Apply database migrations
if: ${{ github.event.inputs.skip_migrate != 'true' }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
set -e
SCHEMA=packages/database/prisma/schema.prisma
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
echo "::error::the database predates migration history — baseline it once with:"
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
exit 1
fi
# --- make sure the host actually has the images ------------------------
# The deploy action's `pull: true` does not reliably refresh an already
# cached moving tag; without this a "successful" deploy can leave the host
# serving an older build of the same tag.
- name: Pull images
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
REGISTRY: ${{ env.REGISTRY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
TAG: ${{ github.event.inputs.tag }}
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/pull-images.mjs
# --- always: the app (web + api) ------------------------------------- # --- always: the app (web + api) -------------------------------------
- name: Deploy app stack - name: Deploy app stack
uses: cssnr/portainer-stack-deploy-action@v1 uses: cssnr/portainer-stack-deploy-action@v1
@@ -116,8 +251,8 @@ jobs:
name: ${{ secrets.PORTAINER_APP_STACK_NAME }} name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
file: deploy/jorgecuadros-app.stack.yml file: deploy/jorgecuadros-app.stack.yml
type: file type: file
pull_image: true pull: true
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }} endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
env_data: | env_data: |
{ {
"APP_TAG": "${{ github.event.inputs.tag }}", "APP_TAG": "${{ github.event.inputs.tag }}",
@@ -129,6 +264,58 @@ jobs:
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}", "S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
"DATABASE_URL": "${{ secrets.DATABASE_URL }}", "DATABASE_URL": "${{ secrets.DATABASE_URL }}",
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}", "SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
"OPS_DB_ADMIN_USER": "root",
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}", "MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}" "MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
} }
# --- prove it ----------------------------------------------------------
# A stack naming a tag is not proof the container is running it — a
# skipped pull leaves the old code up. Ask the API what it actually is.
- name: Verify running version
env:
API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
WANT: ${{ github.event.inputs.tag }}
run: |
set -e
apk add --no-cache curl >/dev/null
fetch_version() {
for i in $(seq 1 30); do
if curl -fsS "$1/version" > "$2"; then return 0; fi
echo "waiting for $1 ($i/30)..."
sleep 5
done
echo "::error::$1/version never answered"
return 1
}
fetch_version "$API_ORIGIN" /tmp/api.json
fetch_version "$WEB_ORIGIN" /tmp/web.json
cat /tmp/api.json; echo; cat /tmp/web.json; echo
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
# Compare the COMMIT, not the version string: on a branch build both
# tiers report "master", so version equality proves nothing.
if [ "$API_SHA" != "$WEB_SHA" ]; then
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
echo "::error::one of the images was not replaced; check the Pull images step"
exit 1
fi
echo "api and web agree: $API_SHA"
case "$WANT" in
[0-9]*.[0-9]*.[0-9]*)
if [ "$API_VER" != "$WANT" ]; then
echo "::error::deployed $WANT but the API reports $API_VER"
exit 1
fi
echo "verified: running $API_VER"
;;
*)
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
;;
esac
+166
View File
@@ -0,0 +1,166 @@
# Cut a release: stamp the version across every package.json, commit, tag, push.
#
# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what
# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and `latest`
# image tags. Deploying stays a separate, deliberate act: once the build is
# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag
# carries the `v`, the image tag does not).
#
# Why a workflow instead of three local commands: the release commit is the one
# thing that must be identical every time, and cutting it from a laptop is how
# a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the
# only input is the number.
#
# Prereqs (once):
# - Repo secret RELEASE_TOKEN: a Gitea personal access token with
# write:repository on this repo. The built-in Actions token is deliberately
# NOT used — whether a push made with it re-triggers build.yml depends on the
# Gitea version, and a release that silently publishes no images is worse
# than one that fails. A PAT push is an ordinary push and always triggers.
# If build.yml somehow does not start, it has workflow_dispatch: run it
# against the new tag by hand.
name: Cut release
on:
workflow_dispatch:
inputs:
bump:
description: "Which part to bump (choose 'explicit' to type the number)"
type: choice
required: true
default: "minor"
options:
- patch
- minor
- major
- explicit
version:
description: "Exact version when bump=explicit (x.y.z, no leading v)"
required: false
default: ""
jobs:
release:
name: Release
runs-on: docker
container:
image: node:20-alpine
steps:
- name: Install tools
run: apk add --no-cache git
- name: Preflight — RELEASE_TOKEN
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
set -eu
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "::error::Secret RELEASE_TOKEN is not set. Create a Gitea PAT with"
echo "::error::write:repository and add it as a repo secret named RELEASE_TOKEN."
exit 1
fi
# Full history + tags: the duplicate-tag check below is meaningless
# against a shallow clone, which has none of them.
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: master
token: ${{ secrets.RELEASE_TOKEN }}
- name: Resolve the new version
id: ver
env:
BUMP: ${{ github.event.inputs.bump }}
EXPLICIT: ${{ github.event.inputs.version }}
run: |
set -eu
CURRENT=$(node -p "require('./package.json').version")
echo "current: $CURRENT"
if [ "$BUMP" = "explicit" ]; then
NEXT="$EXPLICIT"
if [ -z "$NEXT" ]; then
echo "::error::bump=explicit requires the version input."
exit 1
fi
else
NEXT=$(node -e '
const [cur, part] = process.argv.slice(1);
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(cur);
if (!m) { console.error(`unparseable current version: ${cur}`); process.exit(1); }
let [maj, min, pat] = m.slice(1).map(Number);
if (part === "major") { maj += 1; min = 0; pat = 0; }
else if (part === "minor") { min += 1; pat = 0; }
else { pat += 1; }
process.stdout.write(`${maj}.${min}.${pat}`);
' "$CURRENT" "$BUMP")
fi
# set-version.mjs validates the shape too, but failing here keeps the
# working tree clean when the input is a typo.
case "$NEXT" in
v*) echo "::error::Version must not carry a leading 'v' (got $NEXT)."; exit 1 ;;
esac
if ! printf '%s' "$NEXT" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "::error::Invalid version: $NEXT (expected x.y.z)."
exit 1
fi
if [ "$NEXT" = "$CURRENT" ]; then
echo "::error::$NEXT is already the current version."
exit 1
fi
if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then
echo "::error::Tag v$NEXT already exists. Releases are immutable — pick a new number."
exit 1
fi
echo "next: $NEXT"
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
- name: Stamp the version across every manifest
run: node scripts/set-version.mjs "${{ steps.ver.outputs.version }}"
# A release whose only content is the version bump means the dispatch was
# a mistake — set-version.mjs already refused a no-op above, so an empty
# diff here means the manifests were somehow already at this number.
- name: Commit, tag, push
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
VERSION: ${{ steps.ver.outputs.version }}
ACTOR: ${{ github.actor }}
run: |
set -eu
if git diff --quiet; then
echo "::error::No manifest changed. Nothing to release."
exit 1
fi
git config user.name "gitea-actions"
git config user.email "actions@git.mancinas.io"
git commit -a \
-m "chore(release): v${VERSION}" \
-m "Cut by ${ACTOR} via the \"Cut release\" workflow. Pushing the tag triggers build.yml; deploy separately with tag=${VERSION}."
git tag -a "v${VERSION}" -m "v${VERSION}"
# Re-point at an authenticated remote. The token is a secret, so Gitea
# masks it in the log; nothing here echoes the URL regardless.
git remote set-url origin \
"$(printf '%s' "${GITHUB_SERVER_URL}" | sed "s#://#://x-access-token:${RELEASE_TOKEN}@#")/${GITHUB_REPOSITORY}.git"
# One push for both refs: a commit that lands without its tag builds
# nothing and looks like a successful release.
git push origin "HEAD:master" "refs/tags/v${VERSION}"
- name: Summary
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -eu
echo "Released v${VERSION}."
echo ""
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
echo "When it is green, dispatch 'Deploy to galactus' with:"
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false"
+10 -6
View File
@@ -134,8 +134,11 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser. - **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows. **Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled. **OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
- **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all. - **PDF/OCR auto-capture — DONE** (2026-08-01). The ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff key in by hand, built in `apps/api/src/statements/` and posting through §1.2's `createBatch` seam with `source: "OCR"` and a per-document `captureRef`. Web: `/recibos` + `/recibos/:id`. Abilities `statement:ingest`/`statement:review` (STAFF — the review step is what makes machine capture safe at that tier). OCR is self-hosted **Tesseract** behind a swappable `OcrProvider` interface; `tesseract-ocr`, `tesseract-ocr-data-spa` and `poppler-utils` were added to the API image.
- **Multi-bank chequera** — `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register; today's `bank_transactions` is hardcoded single-account/MXN-only by design (see step 7 above) and needs a required `bankAccountId` plus scoping added to every read path in `bank.service.ts`, including two raw-SQL queries in `summary()`. **Every decision was driven by 10 real scans (46 pages).** Shipped-parser results on them: provider 46/46, account ref 43/46, amount 42/46, due date 44/46 — and against the dev database **39/46 (85%) exact auto-match, 40/46 (87%) identified**, the rest genuine review cases. The scans are pure images (no text layer), so OCR is mandatory, and they arrive **bundled one customer per page**.
**The three gaps are closed, and two of them were mis-stated in the spec.** (a) `TELEPHONE` now exists and is backfilled from `Property.phone1` only — coverage is 534/18/1 across phone1/2/3, so phone is one billed line per property, not three. (b) **Clave catastral ≠ predial**: `DATMEX.clave` (934 rows, `KA903009`) is what CESPT and predial bills actually print, while `predial` — what `PROPERTY_TAX.accountNumber` holds — has only 663 distinct values across 1135 rows and appears on no statement; the clave now lives on `Property.cadastralKey` as the matcher's secondary key and predial is left untouched. (c) Gas was **not** a dead end: 160 of the 334 `DATMEX.gas` values are real account numbers (the rest are `ESTACIONARIO`/`CILINDRO` descriptors), all recovered into `GAS.meterNumber`.
**Matching is scoped 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 name on a utility bill is the registrant, not the current owner. Normalisation is per provider: CFE strips leading zeros off `NO. DE SERVICIO`, Telnor strips the 664 LADA down to the stored local 7 digits. Where a provider prints a payment barcode it is preferred over the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two are cross-checked, 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.
- **Multi-bank chequera — DONE** (2026-07-27). `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register. `bank_transactions` gained a **required** `bankAccountId` (plus an `(bankAccountId, transactionDate)` index, since every read is now filtered by account and ordered by date), and all 22,669 existing rows were backfilled onto a seeded "Utilities — Scotiabank (MXN)" account by `migration/backfill_bank_accounts.py` — a standalone step because `prisma db push` cannot add a required column to a populated table. It is idempotent and now runs inside `run_all.py` (both normal and `--sync`) ahead of `transform_bank.py`, which fails fast if the account is missing. Every read path in `bank.service.ts` is account-scoped, including `facets()` (which had no filter at all) and *both* raw-SQL rollups in `summary()`. API: `?bankAccountId=` is required on `list`/`stats`/`facets`/`summary`**not** optional-with-an-all-accounts-default, since summing an MXN and a USD register repeats exactly the currency-collapsing mistake the billing module exists to prevent — plus a new `bank/accounts` + `bank/banks` sub-resource under a MANAGER `bank:manage-accounts` ability. Web: `/banco` gained an account picker (remembered per browser) and reads every figure in the selected account's currency, `/banco/cuentas` manages banks and accounts, and `/inicio`'s chequera card names the account it is showing instead of implying one register. An account's `currency` is immutable after creation by design — its booked movements are denominated in it. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged.
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s). - **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section. Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
@@ -157,7 +160,7 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB. **Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
**Step 11 spec written, not built.** `docs/RECEIPT_CAPTURE_SPEC.md` covers the receipt-capture ("Editor") completion plus the three net-new ops features (OCR auto-capture, multi-bank chequera, customer-number recycling) — see Build sequencing step 11 above for the summary. Written from the 2026-07-25/26 meeting notes and verified against the real migration scripts and current API code, not just designed from the meeting notes alone. **Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown.
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end. **Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
@@ -183,9 +186,10 @@ Unlike the ops items above, these block design decisions, not just infrastructur
**Step 11 — utilities/ops side:** **Step 11 — utilities/ops side:**
- OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider). - ~~OCR provider/budget~~ — **CLOSED**: self-hosted Tesseract, chosen on measured accuracy against real scans, so there is no per-page cost to approve.
- Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill. - ~~Whether `PROPERTY_TAX.accountNumber` (from `DATMEX.PREDIAL`) is the same number as "Clave Catastral" (`DATMEX.CLAVE`)~~**CLOSED**: they are different numbers. Answered from real CESPT bills plus the staged data; the clave is now migrated separately and predial was left alone.
- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. - Whether the CFE figure to charge is the rounded headline/barcode amount (`$268` — what is actually paid at the window) or the exact breakdown `Total` (`$268.88`). The parser takes the barcode amount; one confirmation from Jorge would settle it.
- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. (Multi-bank support itself is **built** — this is now only the missing content: staff can open the account in `/banco/cuentas` the moment the answer arrives, and it starts empty unless a historical register turns up.)
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility. - The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention. - Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
+107 -5
View File
@@ -381,9 +381,18 @@ for what's actually next.
insurance/servicios/fideicomiso split the migration comment implied. A insurance/servicios/fideicomiso split the migration comment implied. A
classifier would invent data, so `categoryId` stays null and the module does classifier would invent data, so `categoryId` stays null and the module does
not filter on it. Register is browsable by date/payee/amount/cheque instead. not filter on it. Register is browsable by date/payee/amount/cheque instead.
(c) **Single currency (MXN).** `bank_transactions` has no currency column and (c) ~~**Single currency (MXN).**~~ **SUPERSEDED 2026-07-27 by the multi-bank
every `amountInWords` is spelled out in PESOS — so, unlike the customer chequera** (step 11, `docs/RECEIPT_CAPTURE_SPEC.md` §3). The office keeps
ledger, everything here is one currency and not split per-currency. more than one register, so `bank_transactions` now carries a **required**
`bankAccountId` and every read in the module is scoped to exactly one
`BankAccount`, whose `currency` the movements inherit — there is still no
currency column on the movement itself, because a real bank account doesn't
mix currencies. All 22,669 migrated rows are the Utilities/Scotiabank MXN
account (backfilled by `migration/backfill_bank_accounts.py`, which
`run_all.py` runs before `transform_bank.py`), which is why every
`amountInWords` is still spelled out in PESOS. There is deliberately no
"all accounts" option: summing an MXN and a USD register would repeat the
currency-collapsing mistake the billing module warns against.
(d) **The "acumulado" is net movement since the register opened, not a bank (d) **The "acumulado" is net movement since the register opened, not a bank
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
bank's name), so the running total starts at 0 in 2013. Labelled as such in bank's name), so the running total starts at 0 in 2013. Labelled as such in
@@ -391,9 +400,18 @@ for what's actually next.
(e) Sign convention (from `transform_bank.py`): positive = ingreso, (e) Sign convention (from `transform_bank.py`): positive = ingreso,
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
CANCELADO/VOID) — voids are excluded from both the income and expense sides. CANCELADO/VOID) — voids are excluded from both the income and expense sides.
(f) **Multi-account since 2026-07-27.** `/banco` opens on an account picker
(the last account is remembered per browser) and reads every figure in that
account's currency; `/banco/cuentas` manages banks and accounts under a new
MANAGER `bank:manage-accounts` ability. Accounts are never deleted — the
`bankAccountId` FK is required, so a used account can only be *closed*
(`active: false`), which hides it from new captures but keeps its history
readable. An account's currency is immutable after creation, since its
booked movements are denominated in it.
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers → - Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
properties → policies → transactions → prune → bank → blobs in order (all idempotent); properties → policies → transactions → prune → bank accounts → bank → blobs in order
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev. (all idempotent); add `--stage` to re-extract from the Access files first. Verified
end-to-end against dev.
5. **Infra****DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack 5. **Infra****DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled `jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
@@ -424,3 +442,87 @@ for what's actually next.
verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals, verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals,
clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail
mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap. mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.
---
## Statement OCR intake (`/recibos`) — DONE 2026-08-01
Plan step 11 §2 (`docs/RECEIPT_CAPTURE_SPEC.md` §2). The last big utilities
feature: staff scan the month's utility bills and the machine proposes customer
+ amount per page, instead of keying 300+ statements per company by hand. Built
in `apps/api/src/statements/` and `apps/web/src/app/recibos/`, posting through
step 11 §1.2's `BillingService.createBatch` seam (`source: "OCR"`, per-document
`captureRef`) so machine and hand capture share one write path and one audit
trail. Abilities `statement:ingest` / `statement:review`, both STAFF.
**Verified end to end against the live dev API + MinIO**, not just built: real
CFE and Telnor scans uploaded over HTTP, OCR'd, matched, confirmed against a
check, and the resulting rows checked in MySQL — negative (charge) amounts,
`captureSource = OCR`, concept auto-derived from the batch's service kind,
`captureRef` linking each transaction back to its page. Re-confirming a posted
batch is refused. All test data was removed afterwards.
**Everything here was decided from 10 real scanned statements (46 pages), not
from the sample-free spec.** Shipped-parser results on them: provider 46/46,
account reference 43/46, amount 42/46, due date 44/46; matched against the dev
database, **39/46 (85%) exact auto-match, 40/46 (87%) identified**. The rest are
real review cases (one shared account number, three phones not on file, one
clave not in the book, one page too poor to read).
Findings that corrected the spec, each of which changed the build:
- **The scans have no text layer at all** — they are camera images of paper, so
OCR is mandatory rather than a convenience, and they arrive **bundled, one
customer per page**.
- **Clave catastral is not predial.** `DATMEX.clave` (934 rows, `KA903009`) is
what CESPT and predial bills print; `DATMEX.predial` — which
`PROPERTY_TAX.accountNumber` holds — has only 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 was left
untouched. This is the question that had been blocking predial matching.
- **Gas was recoverable after all.** The spec said no legacy gas number existed;
in fact 160 of 334 `DATMEX.gas` values are real account numbers (the rest are
`ESTACIONARIO`/`CILINDRO` descriptors). Recovered into `GAS.meterNumber`.
- **Phone is one billed line per property** (534 / 18 / 1 across phone1/2/3), so
`TELEPHONE` — a new `ServiceKind` — backfills from `phone1` only.
- **Never match on the printed name.** A CESPT receipt for account `5365218`
reads `ARNAIZ ROSAS ELSA AURORA`; the office's book, corroborated by the
clave, has `CATT, RANDY`. The name on a utility bill is the registrant, not
the current owner.
`migration/backfill_statement_match_fields.py` closes those three data gaps on
an existing database (idempotent, wired into `run_all.py` after
`transform_properties.py`, which now produces them directly on a full rebuild).
Applied to dev: 934 claves, 160 gas numbers, 534 TELEPHONE rows.
Implementation notes worth keeping:
- OCR is self-hosted **Tesseract** behind an `OcrProvider` interface — the
provider question is closed on measured accuracy, and a managed API stays a
one-line swap in `statements.module.ts`. `tesseract-ocr`,
`tesseract-ocr-data-spa` and `poppler-utils` were added to the API image; if
they are missing the module reports itself unavailable and only this feature
is disabled.
- **Payment barcodes beat printed labels.** One CFE label OCR'd a digit too
many while its barcode was correct, so the barcode is the source and the label
the cross-check; disagreement forces review.
- **Detect the provider by brand first, layout only as a fallback** — and never
interleave the two passes. A scanned CESPT header came back as `E BAJA ES
PAGO / EALIFORNIA`, which is why the layout fallback exists; a Telnor page
contains words a CFE layout rule would otherwise claim, which is why ordering
matters.
- **Parse amounts by separator position.** A real Telnor bill OCR'd as
`$ 649,00`; stripping commas as thousands separators turns that into $64,900.
- Two of the three layouts are line-oriented, but the CESPT "RECIBO" is a
**table** whose values sit under column headers — that one needs the word
boxes, which is why `OcrPage` carries geometry and not just text.
- Confirming a document whose matched service had no reference **writes the
reference back** (only into an empty field, and only when exactly one blank
service of that kind is a candidate), so gas and any other cold start is a
one-time cost rather than a permanent queue.
- Handwritten folder numbers on the bills (`9`, `405`) are **not** used for
matching — Tesseract read `405` as `205`.
**Open:** whether the CFE charge should be the rounded barcode/headline figure
(`$268`, what is paid at the window — what the parser uses today) or the exact
breakdown total (`$268.88`). One question for Jorge.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "0.1.0", "version": "1.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
+21
View File
@@ -6,4 +6,25 @@ export class AppController {
health() { health() {
return { status: "ok" }; return { status: "ok" };
} }
/**
* What is actually running. The three values are baked into the image at
* build time by .gitea/workflows/build.yml (see docker/api.Dockerfile) and
* are the only way to confirm a deploy — or a rollback — landed: the tag you
* dispatched and the code inside the container can disagree if a stack was
* applied without pulling, or if the app stack still names an older tag.
*
* Deliberately unauthenticated, same as /health: the deploy workflow has to
* read it with no session, and it exposes nothing an attacker could not
* already infer from the repo.
*/
@Get("version")
version() {
return {
service: "api",
version: process.env.APP_VERSION ?? "dev",
gitSha: process.env.GIT_SHA ?? "unknown",
buildDate: process.env.BUILD_DATE ?? "unknown",
};
}
} }
+2
View File
@@ -9,6 +9,7 @@ import { CustomersModule } from "./customers/customers.module";
import { PoliciesModule } from "./policies/policies.module"; import { PoliciesModule } from "./policies/policies.module";
import { PropertiesModule } from "./properties/properties.module"; import { PropertiesModule } from "./properties/properties.module";
import { BillingModule } from "./billing/billing.module"; import { BillingModule } from "./billing/billing.module";
import { StatementsModule } from "./statements/statements.module";
import { BankModule } from "./bank/bank.module"; import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module"; import { OpsModule } from "./ops/ops.module";
import { ReportsModule } from "./reports/reports.module"; import { ReportsModule } from "./reports/reports.module";
@@ -26,6 +27,7 @@ import { AppController } from "./app.controller";
PoliciesModule, PoliciesModule,
PropertiesModule, PropertiesModule,
BillingModule, BillingModule,
StatementsModule,
BankModule, BankModule,
OpsModule, OpsModule,
ReportsModule, ReportsModule,
+12
View File
@@ -31,6 +31,9 @@ export type Ability =
| "ledger:void" | "ledger:void"
| "bank:create" | "bank:create"
| "bank:void" | "bank:void"
| "bank:manage-accounts"
| "statement:ingest"
| "statement:review"
| "lookup:manage" | "lookup:manage"
| "user:manage" | "user:manage"
| "db:manage"; | "db:manage";
@@ -50,6 +53,15 @@ export const ABILITY_MIN: Record<Ability, Role> = {
"ledger:void": "MANAGER", "ledger:void": "MANAGER",
"bank:create": "STAFF", "bank:create": "STAFF",
"bank:void": "MANAGER", "bank:void": "MANAGER",
// Opening or renaming a chequera is rarer and higher-stakes than posting a
// movement into one — a wrong account silently mixes two sets of books.
"bank:manage-accounts": "MANAGER",
// Uploading a stack of scans and reviewing what the OCR read are both
// "capturing a receipt" — the same trust tier as ledger:create, since
// confirming a statement *is* capturing it. The review step is what makes
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
"statement:ingest": "STAFF",
"statement:review": "STAFF",
"lookup:manage": "MANAGER", "lookup:manage": "MANAGER",
"user:manage": "ADMIN", "user:manage": "ADMIN",
"db:manage": "ADMIN", "db:manage": "ADMIN",
+28 -1
View File
@@ -1,9 +1,21 @@
import { Controller, Get, HttpCode, Post, Req, Res, UseGuards } from "@nestjs/common"; import {
Body,
Controller,
Get,
HttpCode,
Patch,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { LocalAuthGuard } from "./local-auth.guard"; import { LocalAuthGuard } from "./local-auth.guard";
import { AuthenticatedGuard } from "./authenticated.guard"; import { AuthenticatedGuard } from "./authenticated.guard";
import { LoginDto } from "./login.dto"; import { LoginDto } from "./login.dto";
import { UpdatePreferencesDto } from "./update-preferences.dto";
import { abilitiesFor, Role } from "./abilities"; import { abilitiesFor, Role } from "./abilities";
import { UsersService } from "../users/users.service";
/** Attach the resolved ability map so the web can gate its UI off one payload. */ /** Attach the resolved ability map so the web can gate its UI off one payload. */
function withAbilities(user: unknown) { function withAbilities(user: unknown) {
@@ -14,6 +26,8 @@ function withAbilities(user: unknown) {
@Controller("auth") @Controller("auth")
export class AuthController { export class AuthController {
constructor(private readonly users: UsersService) {}
// LoginDto is only used for request-shape documentation/validation here — // LoginDto is only used for request-shape documentation/validation here —
// the actual credential check happens inside LocalStrategy via Passport, // the actual credential check happens inside LocalStrategy via Passport,
// which populates req.user before this handler runs. // which populates req.user before this handler runs.
@@ -30,6 +44,19 @@ export class AuthController {
return withAbilities(req.user); return withAbilities(req.user);
} }
/**
* Update the caller's own UI preferences. Deliberately not on /users/:id —
* that controller is ADMIN-only, and this has to work for every role. The
* target is always the session's own user id, never a body parameter.
*/
@UseGuards(AuthenticatedGuard)
@Patch("preferences")
async updatePreferences(@Req() req: Request, @Body() dto: UpdatePreferencesDto) {
const id = (req.user as { id: string }).id;
const user = await this.users.updatePreferences(id, dto.uiScale);
return withAbilities(user);
}
@Post("logout") @Post("logout")
@HttpCode(200) @HttpCode(200)
logout(@Req() req: Request) { logout(@Req() req: Request) {
@@ -0,0 +1,17 @@
import { IsNumber, Max, Min } from "class-validator";
/**
* Self-service UI preferences — any authenticated user may set these on their
* own account, including VIEWER. No ability gate: it changes nothing but how
* the app looks to that one person.
*
* The bounds mirror MIN_UI_SCALE/MAX_UI_SCALE in apps/web/src/lib/ui-scale.ts;
* keep them in sync. The API clamps rather than trusting the client because
* this endpoint is reachable outside the UI.
*/
export class UpdatePreferencesDto {
@IsNumber()
@Min(0.9)
@Max(1.5)
uiScale!: number;
}
+47
View File
@@ -0,0 +1,47 @@
import {
IsBoolean,
IsIn,
IsOptional,
IsString,
MinLength,
} from "class-validator";
/** Mirrors the Prisma `Currency` enum; a chequera's is fixed at creation. */
export const BANK_CURRENCIES = ["MXN", "USD"] as const;
export type BankAccountCurrency = (typeof BANK_CURRENCIES)[number];
/** Mirrors `TransactionDomain`. A soft hint on the account, never enforced. */
export const BANK_BUSINESS_LINES = ["UTILITY", "INSURANCE", "TRUST"] as const;
export type BankBusinessLine = (typeof BANK_BUSINESS_LINES)[number];
export class CreateBankDto {
@IsString() @MinLength(1) name!: string;
/** "MX" | "US" — free text, informational only. */
@IsOptional() @IsString() country?: string;
}
export class UpdateBankDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() country?: string;
}
export class CreateBankAccountDto {
@IsString() @MinLength(1) bankId!: string;
@IsString() @MinLength(1) label!: string;
/**
* Immutable after creation (no field for it on the update DTO): every
* movement already booked into the account is denominated in it, so
* changing it would silently re-denominate history.
*/
@IsIn(BANK_CURRENCIES) currency!: BankAccountCurrency;
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
@IsOptional() @IsBoolean() active?: boolean;
}
export class UpdateBankAccountDto {
@IsOptional() @IsString() @MinLength(1) bankId?: string;
@IsOptional() @IsString() @MinLength(1) label?: string;
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
/** Closing an account hides it from the picker; its movements stay readable. */
@IsOptional() @IsBoolean() active?: boolean;
}
+5 -1
View File
@@ -2,10 +2,14 @@ import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-vali
/** /**
* A new bank-register movement. `amount` is signed: positive = ingreso, * A new bank-register movement. `amount` is signed: positive = ingreso,
* negative = egreso (the module's sign convention). Single currency (MXN). * negative = egreso (the module's sign convention). The currency is the
* account's, not the movement's — `bankAccountId` decides it.
* Booked rows are never edited — a mistake is corrected by voiding + re-capture. * Booked rows are never edited — a mistake is corrected by voiding + re-capture.
*/ */
export class CreateBankMovementDto { export class CreateBankMovementDto {
/** Which chequera this lands in. Required — see BankAccount in the schema. */
@IsString() @MinLength(1) bankAccountId!: string;
@IsNumber() amount!: number; @IsNumber() amount!: number;
@IsString() @MinLength(1) transactionDate!: string; @IsString() @MinLength(1) transactionDate!: string;
+96 -6
View File
@@ -3,6 +3,7 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
Patch,
Post, Post,
Query, Query,
Req, Req,
@@ -20,6 +21,12 @@ import {
BankSort, BankSort,
} from "./bank.service"; } from "./bank.service";
import { CreateBankMovementDto } from "./bank-movement.dto"; import { CreateBankMovementDto } from "./bank-movement.dto";
import {
CreateBankAccountDto,
CreateBankDto,
UpdateBankAccountDto,
UpdateBankDto,
} from "./bank-account.dto";
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"]; const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
const CLEARED: BankCleared[] = ["cleared", "pending"]; const CLEARED: BankCleared[] = ["cleared", "pending"];
@@ -54,28 +61,108 @@ export class BankController {
return (req.user as { id: string }).id; return (req.user as { id: string }).id;
} }
// --- accounts -------------------------------------------------------------
// Declared before the parameterised routes below so `/bank/accounts` can
// never be swallowed by a `:id`-shaped path.
/**
* The account picker. Readable by any authenticated user, VIEWER included —
* nothing else on this page can render until an account is chosen.
*/
@Get("accounts")
accounts() {
return this.bank.listAccounts();
}
@Get("banks")
banks() {
return this.bank.listBanks();
}
@Post("banks")
@RequireAbility("bank:manage-accounts")
async createBank(@Body() dto: CreateBankDto, @Req() req: Request) {
const row = await this.bank.createBank(dto);
void this.audit.log(this.actingId(req), "bank.bank.create", {
bankId: row.id,
name: row.name,
});
return row;
}
@Patch("banks/:id")
@RequireAbility("bank:manage-accounts")
async updateBank(
@Param("id") id: string,
@Body() dto: UpdateBankDto,
@Req() req: Request,
) {
const row = await this.bank.updateBank(id, dto);
void this.audit.log(this.actingId(req), "bank.bank.update", { bankId: id });
return row;
}
@Post("accounts")
@RequireAbility("bank:manage-accounts")
async createAccount(
@Body() dto: CreateBankAccountDto,
@Req() req: Request,
) {
const row = await this.bank.createAccount(dto);
void this.audit.log(this.actingId(req), "bank.account.create", {
bankAccountId: row.id,
label: row.label,
currency: row.currency,
});
return row;
}
@Patch("accounts/:id")
@RequireAbility("bank:manage-accounts")
async updateAccount(
@Param("id") id: string,
@Body() dto: UpdateBankAccountDto,
@Req() req: Request,
) {
const row = await this.bank.updateAccount(id, dto);
void this.audit.log(this.actingId(req), "bank.account.update", {
bankAccountId: id,
});
return row;
}
// --- register reads (all scoped to one account) ---------------------------
@Get("stats") @Get("stats")
stats() { async stats(@Query("bankAccountId") bankAccountId?: string) {
return this.bank.stats(); const account = await this.bank.requireAccount(bankAccountId);
return this.bank.stats(account.id);
} }
@Get("facets") @Get("facets")
facets() { async facets(@Query("bankAccountId") bankAccountId?: string) {
return this.bank.facets(); const account = await this.bank.requireAccount(bankAccountId);
return this.bank.facets(account.id);
} }
/** Year and month rollups with a running net-movement figure. */ /** Year and month rollups with a running net-movement figure. */
@Get("summary") @Get("summary")
summary(@Query("year") year?: string) { async summary(
@Query("bankAccountId") bankAccountId?: string,
@Query("year") year?: string,
) {
const account = await this.bank.requireAccount(bankAccountId);
const y = Number(year); const y = Number(year);
return this.bank.summary( return this.bank.summary(
account.id,
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined, Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
); );
} }
/** The register browser. */ /** The register browser. */
@Get() @Get()
list( async list(
@Query("bankAccountId") bankAccountId?: string,
@Query("query") query?: string, @Query("query") query?: string,
@Query("page") page?: string, @Query("page") page?: string,
@Query("pageSize") pageSize?: string, @Query("pageSize") pageSize?: string,
@@ -85,7 +172,9 @@ export class BankController {
@Query("to") to?: string, @Query("to") to?: string,
@Query("sort") sort?: string, @Query("sort") sort?: string,
) { ) {
const account = await this.bank.requireAccount(bankAccountId);
return this.bank.list({ return this.bank.list({
bankAccountId: account.id,
query, query,
page: Math.max(1, Number(page) || 1), page: Math.max(1, Number(page) || 1),
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)), pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
@@ -105,6 +194,7 @@ export class BankController {
const row = await this.bank.createMovement(dto); const row = await this.bank.createMovement(dto);
void this.audit.log(this.actingId(req), "bank.create", { void this.audit.log(this.actingId(req), "bank.create", {
bankTransactionId: row.id, bankTransactionId: row.id,
bankAccountId: row.bankAccountId,
amount: dto.amount, amount: dto.amount,
}); });
return row; return row;
+172 -18
View File
@@ -2,6 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreateBankMovementDto } from "./bank-movement.dto"; import { CreateBankMovementDto } from "./bank-movement.dto";
import {
CreateBankAccountDto,
CreateBankDto,
UpdateBankAccountDto,
UpdateBankDto,
} from "./bank-account.dto";
/** /**
* App-voided rows (voidedAt set) are reversed and must leave every * App-voided rows (voidedAt set) are reversed and must leave every
@@ -28,9 +34,18 @@ const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
* expense and are excluded from both sides, the way the ~193 zero rows are * expense and are excluded from both sides, the way the ~193 zero rows are
* in the customer ledger. * in the customer ledger.
* *
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here: * ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
* `bank_transactions` has none, and every `amountInWords` on the egreso side is * than one chequera (Utilities banks in MXN, Seguros in USD), so every read
* spelled out in PESOS. All figures in this module are MXN. * path here is scoped to exactly one `bankAccountId` — never "all accounts".
* There is deliberately no currency column on `bank_transactions`: a movement
* inherits its account's, the way a real bank account doesn't mix currencies.
* Callers must therefore pass an account id; an unscoped total would sum MXN
* and USD into a figure that never existed, the same mistake the billing
* module's per-currency rule exists to prevent.
*
* The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
* (backfilled by `migration/backfill_bank_accounts.py`), and their
* `amountInWords` on the egreso side is spelled out in PESOS accordingly.
* *
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354 * NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
* rows and this module does not filter or group by it, because the data cannot * rows and this module does not filter or group by it, because the data cannot
@@ -64,6 +79,8 @@ export type BankSort =
| "reference"; | "reference";
export interface BankListParams { export interface BankListParams {
/** Which chequera to read. Required — see the module header. */
bankAccountId: string;
query?: string; query?: string;
page: number; page: number;
pageSize: number; pageSize: number;
@@ -98,7 +115,9 @@ export class BankService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
private where(p: BankListParams): Prisma.BankTransactionWhereInput { private where(p: BankListParams): Prisma.BankTransactionWhereInput {
const and: Prisma.BankTransactionWhereInput[] = []; const and: Prisma.BankTransactionWhereInput[] = [
{ bankAccountId: p.bankAccountId },
];
if (p.query && p.query.trim()) { if (p.query && p.query.trim()) {
const q = p.query.trim(); const q = p.query.trim();
@@ -124,7 +143,9 @@ export class BankService {
}); });
} }
return and.length ? { AND: and } : {}; // Never empty: the account clause above is always present, so no read can
// accidentally span every chequera.
return { AND: and };
} }
private orderBy( private orderBy(
@@ -233,22 +254,25 @@ export class BankService {
}; };
} }
/** Top-line figures for the bank page header. */ /** Top-line figures for the bank page header, for one chequera. */
async stats() { async stats(bankAccountId: string) {
const account = { bankAccountId };
const [count, bounds, pending, transferred, totals] = await Promise.all([ const [count, bounds, pending, transferred, totals] = await Promise.all([
this.prisma.bankTransaction.count({ where: NOT_VOIDED }), this.prisma.bankTransaction.count({
where: { AND: [account, NOT_VOIDED] },
}),
this.prisma.bankTransaction.aggregate({ this.prisma.bankTransaction.aggregate({
where: NOT_VOIDED, where: { AND: [account, NOT_VOIDED] },
_min: { transactionDate: true }, _min: { transactionDate: true },
_max: { transactionDate: true }, _max: { transactionDate: true },
}), }),
this.prisma.bankTransaction.count({ this.prisma.bankTransaction.count({
where: { AND: [{ cleared: false }, NOT_VOIDED] }, where: { AND: [account, { cleared: false }, NOT_VOIDED] },
}), }),
this.prisma.bankTransaction.count({ this.prisma.bankTransaction.count({
where: { AND: [{ transferred: true }, NOT_VOIDED] }, where: { AND: [account, { transferred: true }, NOT_VOIDED] },
}), }),
this.totalsFor({}), this.totalsFor(account),
]); ]);
return { return {
@@ -261,14 +285,16 @@ export class BankService {
}; };
} }
/** Year list for the period filter, newest first. */ /** Year list for the period filter, newest first, for one chequera. */
async facets() { async facets(bankAccountId: string) {
// Tagged-template `$queryRaw`: the interpolation below is a bound
// parameter, not string concatenation.
const years = await this.prisma.$queryRaw< const years = await this.prisma.$queryRaw<
{ year: number; count: bigint | number | string }[] { year: number; count: bigint | number | string }[]
>` >`
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
FROM bank_transactions FROM bank_transactions
WHERE voidedAt IS NULL WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
GROUP BY year GROUP BY year
ORDER BY year DESC ORDER BY year DESC
`; `;
@@ -287,8 +313,12 @@ export class BankService {
* `BAN` table holds only the bank's name), so the register starts at zero on * `BAN` table holds only the bank's name), so the register starts at zero on
* its first row in 2013 and the running figure is the net movement since * its first row in 2013 and the running figure is the net movement since
* then. Labelled as such in the UI so it is never read as a statement balance. * then. Labelled as such in the UI so it is never read as a statement balance.
*
* Both rollups take the SAME `bankAccountId`. Scoping only one of them would
* leave the year list and its month drill-down describing different books —
* wrong in a way that still looks right.
*/ */
async summary(year?: number) { async summary(bankAccountId: string, year?: number) {
const years = await this.prisma.$queryRaw<PeriodRow[]>` const years = await this.prisma.$queryRaw<PeriodRow[]>`
SELECT SELECT
YEAR(transactionDate) AS period, YEAR(transactionDate) AS period,
@@ -297,7 +327,7 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense, SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net SUM(amount) AS net
FROM bank_transactions FROM bank_transactions
WHERE voidedAt IS NULL WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
GROUP BY period GROUP BY period
ORDER BY period ASC ORDER BY period ASC
`; `;
@@ -311,7 +341,9 @@ export class BankService {
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense, SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
SUM(amount) AS net SUM(amount) AS net
FROM bank_transactions FROM bank_transactions
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL WHERE YEAR(transactionDate) = ${year}
AND voidedAt IS NULL
AND bankAccountId = ${bankAccountId}
GROUP BY period GROUP BY period
ORDER BY period ASC ORDER BY period ASC
` `
@@ -365,13 +397,135 @@ export class BankService {
}; };
} }
// --- accounts -------------------------------------------------------------
/**
* Every chequera, closed ones included — a closed account still has to be
* selectable to read its history, it just isn't offered for new captures.
*/
async listAccounts() {
const rows = await this.prisma.bankAccount.findMany({
orderBy: [{ active: "desc" }, { label: "asc" }],
select: {
id: true,
label: true,
currency: true,
businessLine: true,
active: true,
bank: { select: { id: true, name: true, country: true } },
},
});
return rows.map((a) => ({
id: a.id,
label: a.label,
currency: a.currency,
businessLine: a.businessLine,
active: a.active,
bankId: a.bank.id,
bankName: a.bank.name,
bankCountry: a.bank.country,
}));
}
async listBanks() {
return this.prisma.bank.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, country: true },
});
}
/**
* Resolve an account id from a request, or reject. Every read route funnels
* through this so a bad/missing id is a 400 rather than a silently empty
* register that reads as "this account has no movements".
*/
async requireAccount(bankAccountId: string | undefined) {
if (!bankAccountId || !bankAccountId.trim())
throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
const account = await this.prisma.bankAccount.findUnique({
where: { id: bankAccountId },
select: { id: true, label: true, currency: true, active: true },
});
if (!account)
throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
return account;
}
async createBank(dto: CreateBankDto) {
return this.prisma.bank.create({
data: { name: dto.name.trim(), country: dto.country?.trim() || null },
});
}
async updateBank(id: string, dto: UpdateBankDto) {
await this.getBankOr404(id);
return this.prisma.bank.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.country !== undefined
? { country: dto.country.trim() || null }
: {}),
},
});
}
private async getBankOr404(id: string) {
const bank = await this.prisma.bank.findUnique({
where: { id },
select: { id: true },
});
if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
return bank;
}
async createAccount(dto: CreateBankAccountDto) {
await this.getBankOr404(dto.bankId);
return this.prisma.bankAccount.create({
data: {
bankId: dto.bankId,
label: dto.label.trim(),
currency: dto.currency,
businessLine: dto.businessLine ?? null,
active: dto.active ?? true,
},
});
}
/**
* `currency` is intentionally absent from the update DTO: the movements
* already booked in this account are denominated in it, so changing it would
* silently re-denominate history rather than convert it.
*/
async updateAccount(id: string, dto: UpdateBankAccountDto) {
await this.requireAccount(id);
if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
return this.prisma.bankAccount.update({
where: { id },
data: {
...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
...(dto.businessLine !== undefined
? { businessLine: dto.businessLine }
: {}),
...(dto.active !== undefined ? { active: dto.active } : {}),
},
});
}
// --- writes (append + void) ----------------------------------------------- // --- writes (append + void) -----------------------------------------------
async createMovement(dto: CreateBankMovementDto) { async createMovement(dto: CreateBankMovementDto) {
const date = new Date(dto.transactionDate); const date = new Date(dto.transactionDate);
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida"); if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
const account = await this.requireAccount(dto.bankAccountId);
if (!account.active)
throw new BadRequestException(
`La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
);
return this.prisma.bankTransaction.create({ return this.prisma.bankTransaction.create({
data: { data: {
bankAccountId: account.id,
amount: dto.amount, amount: dto.amount,
transactionDate: date, transactionDate: date,
concept: dto.concept, concept: dto.concept,
+3
View File
@@ -5,5 +5,8 @@ import { BillingService } from "./billing.service";
@Module({ @Module({
controllers: [BillingController], controllers: [BillingController],
providers: [BillingService], providers: [BillingService],
// The statements module posts confirmed OCR captures through
// BillingService.createBatch rather than writing Transaction rows itself.
exports: [BillingService],
}) })
export class BillingModule {} export class BillingModule {}
+21 -1
View File
@@ -25,6 +25,26 @@ async function bootstrap() {
throw new Error("SESSION_SECRET must be set (see .env.example)"); throw new Error("SESSION_SECRET must be set (see .env.example)");
} }
// Whether the session cookie carries the Secure flag. This CANNOT simply
// follow NODE_ENV: express-session silently declines to send a Secure cookie
// over a plain-HTTP connection, so a production image served over http://ial
// issues no cookie at all. Login then returns 200 with a user, no session is
// established, every later request 403s, and the UI loops back to /login —
// which is exactly what happened on the first galactus deploy.
//
// Leave it ON wherever the app is reached over TLS. Turn it OFF only for a
// deployment that is HTTP but reached over an already-encrypted transport
// (the galactus install is Tailscale-only, so WireGuard encrypts the wire).
// Behind a TLS-terminating proxy, set trust proxy instead of turning this off.
// An EMPTY value counts as unset, not as "false". Compose interpolation turns
// an absent `${SESSION_COOKIE_SECURE:-}` into the empty string, so testing
// `!== undefined` here would silently drop the Secure flag on any deployment
// that merely passes the variable through without setting it.
const cookieSecureRaw = process.env.SESSION_COOKIE_SECURE;
const cookieSecure = cookieSecureRaw
? cookieSecureRaw === "true"
: process.env.NODE_ENV === "production";
app.use( app.use(
session({ session({
secret: sessionSecret, secret: sessionSecret,
@@ -32,7 +52,7 @@ async function bootstrap() {
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: cookieSecure,
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
}, },
}) })
+89 -7
View File
@@ -31,6 +31,14 @@ export const INGEST_FILES = [
] as const; ] as const;
export type IngestName = (typeof INGEST_FILES)[number]; export type IngestName = (typeof INGEST_FILES)[number];
/**
* Prefix for every command containing a pipe. Without it the exit status of
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
* like a successful job. Both Alpine's busybox ash (the API image) and macOS
* `sh` (dev) support it; POSIX does not require it, so `sh -c` is the contract.
*/
const PIPEFAIL = "set -o pipefail; ";
interface MysqlConn { interface MysqlConn {
host: string; host: string;
port: string; port: string;
@@ -175,7 +183,7 @@ export class OpsService implements OnModuleInit {
); );
} }
const conn = this.parseDbUrl(); const conn = this.opsConn();
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn); const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
const job = await this.prisma.opsJob.create({ const job = await this.prisma.opsJob.create({
@@ -207,6 +215,37 @@ export class OpsService implements OnModuleInit {
}; };
} }
/**
* The credentials mysqldump/mysql run as — deliberately NOT the application
* user. `--single-transaction` issues FLUSH TABLES, which needs the global
* RELOAD privilege, and the app user is granted only `ALL ON jorgecuadros.*`
* plus `USAGE ON *.*`; `--skip-lock-tables` does not avoid it. A restore of a
* dump taken before --set-gtid-purged=OFF likewise needs SUPER to replay its
* SET @@GLOBAL.GTID_PURGED. So an admin credential is supplied out of band
* rather than elevating the runtime user for the sake of one admin screen —
* the same choice deploy/scripts/pre-migrate-backup.mjs makes.
*
* Host, port and database always come from DATABASE_URL: the ops user is a
* different login on the SAME server, never a way to point at another one.
*
* With the vars unset this falls back to the DATABASE_URL credentials, which
* is what local development wants — a dev MySQL grants the app user far more.
*/
private opsConn(): MysqlConn {
const conn = this.parseDbUrl();
const user = process.env.OPS_DB_ADMIN_USER;
const password = process.env.OPS_DB_ADMIN_PASSWORD;
if (!user || !password) {
this.logger.warn(
"OPS_DB_ADMIN_USER/OPS_DB_ADMIN_PASSWORD no configuradas; " +
`usando el usuario de la aplicación (${conn.user}) para mysqldump. ` +
"En producción esto falla por falta del privilegio RELOAD.",
);
return conn;
}
return { ...conn, user, password };
}
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in /** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
* the child env, never on the command line (which would leak via `ps`). */ * the child env, never on the command line (which would leak via `ps`). */
private connFlags(c: MysqlConn): string { private connFlags(c: MysqlConn): string {
@@ -217,6 +256,37 @@ export class OpsService implements OnModuleInit {
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19); return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
} }
/**
* One hardened mysqldump, shared by BACKUP and by the safety backups SYNC and
* REIMPORT take first. Kept byte-for-byte in spirit with the dump in
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
* and both are listed as restore points by this same screen.
*
* --set-gtid-purged=OFF: the production server is the replication SOURCE with
* GTID on, so without it every dump embeds SET @@GLOBAL.GTID_PURGED and is
* unrestorable onto the very server it came from.
*
* The table-count assertion is not belt-and-braces: `gzip -t` passes on the
* ~372-byte output of a mysqldump that died on its first statement, so a
* failed dump would otherwise be recorded as a successful backup. (`set -o
* pipefail` is set by the caller for the same reason — without it the exit
* status of the pipeline is gzip's, and gzip succeeded.)
*
* A failed attempt deletes its own output, so a truncated file never appears
* in the restore list looking like an ordinary restore point.
*/
private dumpCommand(flags: string, db: string, out: string): string {
return (
`( mysqldump ${flags} --single-transaction --routines --triggers ` +
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` +
`gzip -t ${out} && ` +
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
`echo "tablas capturadas: $TABLAS" && ` +
`[ "$TABLAS" -ge 1 ] ) || ` +
`{ rm -f ${out}; echo 'respaldo incompleto eliminado'; exit 1; }`
);
}
private async buildCommand( private async buildCommand(
kind: OpsJobKind, kind: OpsJobKind,
params: Record<string, unknown>, params: Record<string, unknown>,
@@ -229,7 +299,7 @@ export class OpsService implements OnModuleInit {
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`; const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
const out = shq(path.join(this.backupDir, file)); const out = shq(path.join(this.backupDir, file));
return { return {
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`, cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
resolvedParams: { file }, resolvedParams: { file },
}; };
} }
@@ -241,7 +311,10 @@ export class OpsService implements OnModuleInit {
throw new NotFoundException(`Respaldo no encontrado: ${name}`); throw new NotFoundException(`Respaldo no encontrado: ${name}`);
}); });
return { return {
cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`, // pipefail matters here too: a corrupt archive makes gunzip fail while
// mysql, fed a truncated stream, can still exit 0 — a restore that
// reported success having replayed only part of the dump.
cmd: `${PIPEFAIL}gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
resolvedParams: { file: name }, resolvedParams: { file: name },
}; };
} }
@@ -252,8 +325,8 @@ export class OpsService implements OnModuleInit {
const py = await this.pythonBin(); const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py")); const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd = const cmd =
`echo '== Respaldo de seguridad previo ==' && ` + `${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` + `${this.dumpCommand(flags, db, out)} && ` +
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` + `echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`; `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
return { cmd, resolvedParams: { safetyBackup: file } }; return { cmd, resolvedParams: { safetyBackup: file } };
@@ -266,8 +339,8 @@ export class OpsService implements OnModuleInit {
const py = await this.pythonBin(); const py = await this.pythonBin();
const runAll = shq(path.join(this.migrationDir, "run_all.py")); const runAll = shq(path.join(this.migrationDir, "run_all.py"));
const cmd = const cmd =
`echo '== Respaldo de seguridad previo ==' && ` + `${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` + `${this.dumpCommand(flags, db, out)} && ` +
`echo '== Reimportación desde carpeta de ingesta ==' && ` + `echo '== Reimportación desde carpeta de ingesta ==' && ` +
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`; `${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
return { cmd, resolvedParams: { safetyBackup: file } }; return { cmd, resolvedParams: { safetyBackup: file } };
@@ -287,6 +360,15 @@ export class OpsService implements OnModuleInit {
} }
} }
/**
* `password` is the ops credential from opsConn(), exported as MYSQL_PWD so it
* never reaches argv (which `ps` exposes to every process on the host).
*
* It does not leak into the Python ETL that SYNC and REIMPORT go on to run:
* migration/dbenv.py connects with pymysql using the credentials inside
* DATABASE_URL and never consults MYSQL_PWD. The ETL keeps running as the
* application user, which is what it should be doing.
*/
private run(jobId: string, cmd: string, password: string): void { private run(jobId: string, cmd: string, password: string): void {
const child = spawn("sh", ["-c", cmd], { const child = spawn("sh", ["-c", cmd], {
cwd: this.migrationDir, cwd: this.migrationDir,
@@ -0,0 +1,53 @@
/**
* The OCR seam. Everything above this interface works in terms of page text and
* word boxes, so the concrete engine is swappable without touching the parsers,
* the matcher, or the schema.
*
* The shipped implementation is self-hosted Tesseract (see tesseract.provider).
* That choice is evidence-based rather than assumed: run against 46 pages of
* real scanned CFE, CESPT and Telnor statements, it identified the provider on
* 46/46 and extracted a usable account reference on 43/46, which is well past
* the bar for a queue whose whole point is that a human confirms every row. A
* managed document-extraction API (Textract, Document Intelligence, Document
* AI) fits behind this same interface if per-page accuracy ever proves
* insufficient, with no schema change — but at 300+ pages/month/company it
* would carry a real recurring cost for accuracy that is not currently the
* bottleneck.
*/
/** One OCR'd word, with where it sits on the page. */
export interface OcrWord {
text: string;
/** Pixel box in the rendered page image. */
left: number;
top: number;
width: number;
height: number;
/** Engine confidence for this word, 0..1. */
confidence: number;
}
export interface OcrPage {
/** Full page text, reading order, newline-separated. */
text: string;
/**
* Word boxes. Needed because two of the three real layouts are *tables* —
* the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with the
* value in the row beneath it, which line-oriented text cannot associate.
* Parsers fall back to geometry for exactly those fields.
*/
words: OcrWord[];
/** Mean word confidence across the page, 0..1. */
confidence: number;
}
export interface OcrProvider {
/** True when the engine is actually usable in this deployment. */
available(): Promise<boolean>;
/** Split a PDF into one rendered page image per page. */
renderPages(pdf: Buffer): Promise<Buffer[]>;
/** OCR a single rendered page image. */
recognize(pageImage: Buffer): Promise<OcrPage>;
}
export const OCR_PROVIDER = Symbol("OCR_PROVIDER");
@@ -0,0 +1,195 @@
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { OcrPage, OcrProvider, OcrWord } from "./ocr.provider";
const run = promisify(execFile);
/**
* Self-hosted OCR: `pdftoppm` (poppler) to rasterise, `tesseract` to read.
*
* Both are external binaries rather than a native npm addon, which keeps the
* pnpm workspace free of a compiled dependency and makes the alpine runtime
* image a two-package change (see docker/api.Dockerfile). Like StorageService,
* a missing binary degrades rather than crashes the API: the module reports
* itself unavailable and statement ingest returns 503, while every other
* feature keeps working.
*
* The settings below are not arbitrary — they were measured against the real
* scanned samples:
* - 300 DPI grayscale. The source scans are phone photos of paper at ~5MB a
* page; below 300 the small print (RMU, clave catastral) stops resolving,
* above it costs time for no additional fields.
* - `--psm 6` ("assume a single uniform block of text"). The default page
* segmentation splits these dense forms into columns and interleaves them,
* which destroys the label-then-value adjacency every parser depends on.
* - Spanish traineddata, with a graceful fall back to English if the language
* pack is absent — an accented label reads worse but the digits, which are
* what actually gets matched, are unaffected.
*/
@Injectable()
export class TesseractOcrProvider implements OcrProvider {
private readonly logger = new Logger(TesseractOcrProvider.name);
private readonly dpi: number;
private readonly lang: string;
private probe: Promise<boolean> | null = null;
constructor(config: ConfigService) {
this.dpi = Number(config.get("OCR_DPI") ?? 300);
this.lang = config.get<string>("OCR_LANG") ?? "spa";
}
/** Cached — the binaries do not appear or vanish while the process runs. */
available(): Promise<boolean> {
if (!this.probe) {
this.probe = (async () => {
try {
await Promise.all([
run("tesseract", ["--version"]),
run("pdftoppm", ["-v"]),
]);
return true;
} catch {
this.logger.warn(
"OCR unavailable: `tesseract` and/or `pdftoppm` not found on PATH. " +
"Statement ingest is disabled; every other feature is unaffected.",
);
return false;
}
})();
}
return this.probe;
}
private async require(): Promise<void> {
if (!(await this.available())) {
throw new ServiceUnavailableException(
"El servicio de OCR no está disponible en este servidor.",
);
}
}
private async scratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(join(tmpdir(), "stmt-ocr-"));
try {
return await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async renderPages(pdf: Buffer): Promise<Buffer[]> {
await this.require();
return this.scratch(async (dir) => {
const src = join(dir, "in.pdf");
await writeFile(src, pdf);
// -gray: these are grayscale scans already; colour triples the bytes
// handed to tesseract for no gain in character recognition.
await run("pdftoppm", [
"-r",
String(this.dpi),
"-gray",
"-png",
src,
join(dir, "page"),
]);
const files = (await readdir(dir))
.filter((f) => f.startsWith("page") && f.endsWith(".png"))
// pdftoppm zero-pads its page numbers, so lexical order is page order.
.sort();
return Promise.all(files.map((f) => readFile(join(dir, f))));
});
}
async recognize(pageImage: Buffer): Promise<OcrPage> {
await this.require();
return this.scratch(async (dir) => {
const img = join(dir, "page.png");
await writeFile(img, pageImage);
// One tesseract invocation produces both outputs; TSV carries the word
// boxes and per-word confidence, and its text can be reassembled into
// reading order, so there is no need to run the engine twice.
const out = join(dir, "out");
try {
await run("tesseract", [img, out, "-l", this.lang, "--psm", "6", "tsv"]);
} catch (err) {
if (this.lang !== "eng") {
this.logger.warn(
`Tesseract failed with lang "${this.lang}", retrying with "eng": ${
(err as Error).message
}`,
);
await run("tesseract", [img, out, "-l", "eng", "--psm", "6", "tsv"]);
} else {
throw err;
}
}
const tsv = await readFile(`${out}.tsv`, "utf8");
return parseTsv(tsv);
});
}
}
/**
* Turn tesseract's TSV into words plus reassembled text.
*
* Columns are: level, page_num, block_num, par_num, line_num, word_num, left,
* top, width, height, conf, text. Rows with level < 5 are structural (page,
* block, paragraph, line) and carry no text; only level 5 is a word. A conf of
* -1 marks a structural row, so those are dropped rather than averaged in —
* including them would drag every page's confidence toward zero.
*/
export function parseTsv(tsv: string): OcrPage {
const lines = tsv.split("\n");
const header = lines[0]?.split("\t") ?? [];
const col = (name: string) => header.indexOf(name);
const iLeft = col("left");
const iTop = col("top");
const iWidth = col("width");
const iHeight = col("height");
const iConf = col("conf");
const iText = col("text");
const iLine = col("line_num");
const iBlock = col("block_num");
const words: OcrWord[] = [];
// Keyed by block+line so the reassembled text preserves the engine's own
// reading order instead of sorting words by raw y, which interleaves columns.
const byLine = new Map<string, string[]>();
for (let i = 1; i < lines.length; i++) {
const f = lines[i].split("\t");
if (f.length <= iText) continue;
const text = f[iText]?.trim();
if (!text) continue;
const confidence = Number(f[iConf]);
if (!Number.isFinite(confidence) || confidence < 0) continue;
words.push({
text,
left: Number(f[iLeft]) || 0,
top: Number(f[iTop]) || 0,
width: Number(f[iWidth]) || 0,
height: Number(f[iHeight]) || 0,
confidence: confidence / 100,
});
const key = `${f[iBlock]}:${f[iLine]}`;
const bucket = byLine.get(key);
if (bucket) bucket.push(text);
else byLine.set(key, [text]);
}
const text = [...byLine.values()].map((w) => w.join(" ")).join("\n");
const confidence = words.length
? words.reduce((sum, w) => sum + w.confidence, 0) / words.length
: 0;
return { text, words, confidence };
}
@@ -0,0 +1,390 @@
import type { ServiceKind } from "@jorgecuadros/database";
import type { OcrPage, OcrWord } from "../ocr/ocr.provider";
/**
* What one parsed statement page yields. `accountRef` is already normalised to
* the form the migrated `PropertyService` columns hold, so the matcher compares
* like with like and never has to know about provider-specific formatting.
*/
export interface ParsedStatement {
/** "CFE" | "CESPT" | "TELNOR", or null when no parser claimed the page. */
provider: string | null;
serviceKind: ServiceKind | null;
accountRef: string | null;
/** Clave catastral, when printed — a second key to match on. */
cadastralKey: string | null;
amount: number | null;
dueDate: Date | null;
period: string | null;
/**
* Independent corroboration of `accountRef`. CFE and Telnor both print a
* payment barcode that repeats the account number (and the amount), so when
* the barcode and the label agree the extraction is near-certainly right;
* when they disagree, or only one is present, the page is worth a human
* glance. Null when the layout has no second source.
*/
crossChecked: boolean | null;
/** Human-readable trail of what was read, surfaced in the review queue. */
notes: string[];
}
// --- shared helpers ---------------------------------------------------------
/**
* Tesseract confuses these glyphs inside numeric runs with some regularity —
* a real clave catastral `KB078025` came back as `KBO78025`. Applied ONLY to
* fields known to be digits, never to free text, where it would corrupt words.
*/
const DIGIT_CONFUSIONS: Record<string, string> = {
O: "0",
o: "0",
D: "0",
I: "1",
l: "1",
"|": "1",
S: "5",
B: "8",
};
export function toDigits(s: string | null | undefined): string {
if (!s) return "";
return s
.split("")
.map((c) => DIGIT_CONFUSIONS[c] ?? c)
.join("")
.replace(/\D/g, "");
}
/**
* Parse a printed amount, treating `,` and `.` by position rather than by
* assumption. A real Telnor bill OCR'd as "$ 649,00" — blindly stripping commas
* as thousands separators turned $649.00 into $64,900, a hundredfold error that
* would post silently. Two trailing digits after a single separator are always
* cents here; a separator followed by three digits is a thousands group.
*/
function money(s: string | null | undefined): number | null {
if (!s) return null;
const cleaned = s.replace(/[\s$]/g, "");
// 1.234,56 or 1,234.56 — grouped thousands plus optional cents.
let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
if (m) {
const whole = m[1].replace(/[.,]/g, "");
const cents = m[2] ? m[2].slice(1) : "";
return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
}
// 649,00 / 649.00 — a single separator with exactly two digits after it.
m = cleaned.match(/^(\d+)[.,](\d{2})$/);
if (m) return Number(`${m[1]}.${m[2]}`);
const n = Number(cleaned.replace(/[,.]/g, ""));
return Number.isFinite(n) ? n : null;
}
function firstMatch(text: string, patterns: RegExp[]): string | null {
for (const p of patterns) {
const m = text.match(p);
if (m?.[1]) return m[1].trim();
}
return null;
}
const MONTHS: Record<string, number> = {
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
};
/** Parses the three date shapes these statements actually print. */
export function parseDate(raw: string | null | undefined): Date | null {
if (!raw) return null;
const s = raw.trim().toUpperCase();
// 16/07/2026
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
// 22-JUL-2026 / 22 JUN 26
m = s.match(/^(\d{1,2})[-\s]([A-Z]{3})[A-Z]*[-\s](\d{2,4})$/);
if (m && MONTHS[m[2]] !== undefined) {
const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
return utc(y, MONTHS[m[2]], +m[1]);
}
// 2026-07-22 (already normalised, e.g. decoded from a barcode)
m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
return null;
}
function utc(y: number, mo: number, d: number): Date | null {
const dt = new Date(Date.UTC(y, mo, d));
return Number.isNaN(dt.getTime()) ? null : dt;
}
/**
* Read the value printed *underneath* a column header.
*
* The CESPT "RECIBO" is a table: `No. DE CUENTA` is a header cell and its value
* sits in the row below it, so no amount of label-adjacent regex on line text
* can associate the two. This walks the word boxes instead — find the header
* word, then take the nearest word below it whose horizontal centre falls
* within the column.
*/
export function valueUnder(
page: OcrPage,
header: RegExp,
opts: { maxDy?: number; tolerance?: number; match?: RegExp } = {},
): string | null {
const { maxDy = 300, tolerance = 200, match } = opts;
const centre = (w: OcrWord) => ({
x: w.left + w.width / 2,
y: w.top + w.height / 2,
});
for (const h of page.words.filter((w) => header.test(w.text))) {
const hc = centre(h);
const below = page.words
.filter((w) => {
const c = centre(w);
return c.y > hc.y && c.y <= hc.y + maxDy && Math.abs(c.x - hc.x) <= tolerance;
})
.sort((a, b) => centre(a).y - centre(b).y);
for (const w of below) {
if (!match || match.test(w.text)) return w.text;
}
}
return null;
}
// --- provider detection -----------------------------------------------------
/**
* Brand wordmarks first, page structure only as a fallback — and the two passes
* must not be interleaved. Scanned logos OCR badly (one CESPT header came back
* as "E BAJA ES PAGO / EALIFORNIA", with neither "CESPT" nor "COMISIÓN ESTATAL"
* readable), so the structural pass is what rescues those pages. But a Telnor
* bill contains the words "Pagar antes de", which a CFE structural rule
* evaluated first will happily claim — running all brand checks before any
* structural check is what keeps that from happening.
*/
const BRAND: [string, RegExp][] = [
["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i],
];
const LAYOUT: [string, RegExp][] = [
["CFE", /NO\.?\s*DE\s*SERVICIO|L[IÍ]MITE\s*DE\s*PAGO|PERIODO\s*FACTURADO/i],
["CESPT", /SALDO\s+CORRIENTE|CLAVE\s*CATASTRAL|No\.?\s*DE\s*CUENTA/i],
["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i],
];
export function detectProvider(text: string): string | null {
for (const group of [BRAND, LAYOUT]) {
for (const [name, pattern] of group) {
if (pattern.test(text)) return name;
}
}
return null;
}
// --- CFE (electric) ---------------------------------------------------------
function parseCfe(page: OcrPage): ParsedStatement {
const text = page.text;
const notes: string[] = [];
// The payment barcode line repeats the service number, the due date (YYMMDD)
// and the amount in one fixed-width run, and reads far more reliably than the
// label: on one sample the label came back as "0059603001917" (a digit too
// many) while its barcode gave the correct "005960300191". So the barcode
// wins, and the label becomes the cross-check rather than the source.
const barcode = text.match(/\b01\s+([0-9OIlSBD]{12})\s+([0-9OIlSBD]{6})\s+([0-9OIlSBD]{9})\b/);
const label = firstMatch(text, [/NO\.?\s*DE\s*SERVICIO\s*[:;.]?\s*([0-9OIlSBD]{10,14})/i]);
let accountRef: string | null = null;
let amount: number | null = null;
let dueDate: Date | null = null;
let crossChecked: boolean | null = null;
if (barcode) {
// Leading zeros are print padding: DATMEX.rpu holds the bare 10 digits.
accountRef = toDigits(barcode[1]).replace(/^0+/, "");
amount = Number(toDigits(barcode[3]));
const d = toDigits(barcode[2]);
dueDate = parseDate(`20${d.slice(0, 2)}-${d.slice(2, 4)}-${d.slice(4, 6)}`);
notes.push("importe y vencimiento leídos del código de barras");
if (label) {
crossChecked = toDigits(label).replace(/^0+/, "") === accountRef;
if (!crossChecked) {
notes.push(
`el número impreso (${toDigits(label).replace(/^0+/, "")}) no coincide con el código de barras`,
);
}
}
} else if (label) {
accountRef = toDigits(label).replace(/^0+/, "");
notes.push("sin código de barras legible; número tomado de la etiqueta");
}
if (amount == null) {
amount = money(firstMatch(text, [/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d*)/i]));
}
if (!dueDate) {
dueDate = parseDate(
firstMatch(text, [/L[IÍ]MITE\s*DE\s*PAGO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2,4})/i]),
);
}
return {
provider: "CFE",
serviceKind: "ELECTRIC",
accountRef: accountRef || null,
cadastralKey: null,
amount,
dueDate,
period: firstMatch(text, [
/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2}\s*-\s*\d{1,2}\s+\w{3}\s+\d{2})/i,
]),
crossChecked,
notes,
};
}
// --- CESPT (water) ----------------------------------------------------------
/**
* Two different layouts arrive under the same brand:
* - the line-oriented "COMPROBANTE DE PAGO" (`Cuenta : 7604192`), and
* - the tabular "RECIBO", where `No. DE CUENTA` is a column header.
* Line patterns are tried first; anything they miss falls through to the
* geometric read, which is what the tabular layout needs.
*/
function parseCespt(page: OcrPage): ParsedStatement {
const text = page.text;
const notes: string[] = [];
let account = firstMatch(text, [/Cuenta\s*[:;.]?\s*([0-9OIlSBD]{5,9})/i]);
if (!account) {
account = valueUnder(page, /^CUENTA$/i, { match: /^[0-9OIlSBD]{5,9}$/ });
if (account) notes.push("número de cuenta leído de la columna del recibo");
}
let clave = firstMatch(text, [/Cve\.?\s*Cat\.?\s*[:;.]?\s*([A-Z]{2}\s?[0-9OIlSBD]{6})/i]);
if (!clave) {
clave = valueUnder(page, /^CATASTRAL$/i, { match: /^[A-Z]{2}[0-9OIlSBD]{6}$/i });
if (clave) notes.push("clave catastral leída de la columna del recibo");
}
let due = firstMatch(text, [/Fecha\s*Venc\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]);
if (!due) due = valueUnder(page, /^VENCIMIENTO$/i, { match: /^\d{2}\/\d{2}\/\d{4}$/ });
const amount = money(
firstMatch(text, [
/TOTAL\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i,
/SALDO\s+CORRIENTE[^\n]*?([\d,]+\.\d{2})/i,
]),
);
// Leading zeros are print padding here too: the RECIBO prints `0457341` for
// what DATMEX.agua holds as `457341`.
const accountRef = account ? toDigits(account).replace(/^0+/, "") : null;
const cadastralKey = clave
? clave.replace(/\s/g, "").slice(0, 2).toUpperCase() +
toDigits(clave.replace(/\s/g, "").slice(2))
: null;
return {
provider: "CESPT",
serviceKind: "WATER",
accountRef: accountRef || null,
cadastralKey: cadastralKey || null,
amount,
dueDate: parseDate(due),
period: null,
crossChecked: null,
notes,
};
}
// --- TELNOR (telephone) -----------------------------------------------------
function parseTelnor(page: OcrPage): ParsedStatement {
const text = page.text;
const notes: string[] = [];
const label = firstMatch(text, [
/Tel[eé]fono\s*[:;.]?\s*([0-9OIlSBD]{3}\s?[0-9OIlSBD]{3}\s?[0-9OIlSBD]{4})/i,
]);
// The payment stub prints phone (10 digits) + amount in cents (9) + a check
// digit: `6646093444 000099900 7` for a $999.00 bill. Reading the amount as
// 10 digits swallows the check digit and inflates the figure 100-fold.
const barcode = text.match(/\b(\d{10})(\d{9})\d\b/);
let accountRef: string | null = null;
let crossChecked: boolean | null = null;
// The bill prints the number with its 664 Tijuana LADA; DATMEX stores the
// bare local 7 digits, so the LADA is dropped rather than the stored value
// being padded — padding would guess at an area code for the 500+ existing
// rows that never recorded one.
if (label) accountRef = toDigits(label).slice(-7);
if (barcode) {
const fromBarcode = barcode[1].slice(-7);
if (accountRef) {
crossChecked = fromBarcode === accountRef;
if (!crossChecked) notes.push("el teléfono impreso no coincide con el código de barras");
} else {
accountRef = fromBarcode;
notes.push("teléfono leído del código de barras");
}
}
let amount = money(firstMatch(text, [/Total\s*a\s*Pagar\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]));
if (amount == null && barcode) {
amount = Number(barcode[2]) / 100;
notes.push("importe leído del código de barras");
}
return {
provider: "TELNOR",
serviceKind: "TELEPHONE",
accountRef: accountRef || null,
cadastralKey: null,
amount,
dueDate: parseDate(
firstMatch(text, [/Pagar\s*antes\s*de\s*[:;.]?\s*(\d{2}-\w{3}-\d{4})/i]),
),
period: firstMatch(text, [/Mes\s*de\s*Facturaci[oó]n\s*[:;.]?\s*(\w+)/i]),
crossChecked,
notes,
};
}
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
CFE: parseCfe,
CESPT: parseCespt,
TELNOR: parseTelnor,
};
const EMPTY: ParsedStatement = {
provider: null,
serviceKind: null,
accountRef: null,
cadastralKey: null,
amount: null,
dueDate: null,
period: null,
crossChecked: null,
notes: [],
};
/** Detect the provider and run its parser. */
export function parseStatement(page: OcrPage): ParsedStatement {
const provider = detectProvider(page.text);
if (!provider) return { ...EMPTY, notes: ["no se reconoció el proveedor"] };
return PARSERS[provider](page);
}
@@ -0,0 +1,199 @@
import { Injectable } from "@nestjs/common";
import type { ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import type { ParsedStatement } from "./parsers/statement-parser";
export interface MatchResult {
propertyServiceId: string | null;
customerId: string | null;
/** Why it landed here — shown in the review queue verbatim. */
note: string;
/** True only for an unambiguous hit on the scoped field. */
confident: boolean;
/** Populated when more than one service claims the same number. */
candidates: { propertyServiceId: string; customerId: string; customerName: string }[];
}
/**
* Resolves a parsed statement to the customer who should be billed for it.
*
* Two rules govern everything here.
*
* **Match on one scoped field, never fuzzily across all identifiers.** Each
* service kind has exactly one column its statements print, and only that
* column is consulted. A blanket search over accountNumber/meterNumber/route
* would let a water account number collide with an unrelated phone number, and
* the resulting mis-post would look perfectly ordinary in the ledger.
*
* **Never match on the customer name.** The name on a utility bill is the
* account's registrant, which drifts from the current owner and is often years
* stale — one sample CESPT receipt is printed to "ARNAIZ ROSAS ELSA AURORA"
* for an account this office holds under "CATT, RANDY", who is not the same
* person. Names are displayed for the reviewer to sanity-check, and are never
* an input to matching.
*/
@Injectable()
export class StatementMatcherService {
constructor(private readonly prisma: PrismaService) {}
/** Which PropertyService column a given kind's statements actually print. */
private fieldFor(kind: ServiceKind): "accountNumber" | "meterNumber" | null {
switch (kind) {
case "ELECTRIC": // CFE "NO. DE SERVICIO" -> DATMEX.rpu
case "WATER": // CESPT "Cuenta" / "No. DE CUENTA" -> DATMEX.agua
case "TELEPHONE": // Telnor "Teléfono" (LADA stripped) -> DATMEX.telefono
case "FEDERAL_ZONE":
case "CABLE":
return "accountNumber";
case "GAS": // no account column in DATMEX; the number lived in notes
return "meterNumber";
// PROPERTY_TAX deliberately has no scoped column: what its
// accountNumber holds is DATMEX.predial, which is neither unique nor
// printed on any statement. Predial bills match on the clave catastral
// alone — see matchByCadastralKey.
default:
return null;
}
}
async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise<MatchResult> {
const kind = parsed.serviceKind ?? expectedKind;
// The uploader labels a batch with one service kind. If the parser reads a
// page as a different provider, that is a mis-sorted page, not a match —
// posting it would book a phone bill as a water charge.
if (parsed.serviceKind && parsed.serviceKind !== expectedKind) {
return this.unmatched(
`la página parece de ${parsed.provider} (${parsed.serviceKind}) pero el lote es de ${expectedKind}`,
);
}
const field = this.fieldFor(kind);
if (field && parsed.accountRef) {
const hit = await this.byServiceField(kind, field, parsed.accountRef);
if (hit) return hit;
}
// Secondary key. The clave catastral is printed on CESPT bills as well as
// predial ones, so it rescues a page whose account number did not OCR —
// which happened on real samples, where the clave read cleanly and the
// account number did not.
if (parsed.cadastralKey) {
const hit = await this.byCadastralKey(kind, parsed.cadastralKey);
if (hit) return hit;
}
if (!field && !parsed.cadastralKey) {
return this.unmatched(
kind === "PROPERTY_TAX"
? "el predial sólo se puede identificar por clave catastral y no se leyó ninguna"
: `no hay campo de búsqueda definido para ${kind}`,
);
}
return this.unmatched(
parsed.accountRef
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
: "no se pudo leer la referencia de la cuenta",
);
}
private async byServiceField(
kind: ServiceKind,
field: "accountNumber" | "meterNumber",
ref: string,
): Promise<MatchResult | null> {
const rows = await this.prisma.propertyService.findMany({
where: { kind, [field]: ref },
select: {
id: true,
property: {
select: { customerId: true, customer: { select: { name: true } } },
},
},
});
if (rows.length === 0) return null;
const candidates = rows.map((r) => ({
propertyServiceId: r.id,
customerId: r.property.customerId,
customerName: r.property.customer.name,
}));
// Duplicate account numbers do occur in the legacy data (the office's own
// DUPLICADOS report existed for a reason), so every candidate is surfaced
// for the reviewer to choose rather than one being picked arbitrarily.
if (rows.length > 1) {
return {
propertyServiceId: null,
customerId: null,
note: `${rows.length} servicios comparten la referencia ${ref}`,
confident: false,
candidates,
};
}
return {
propertyServiceId: candidates[0].propertyServiceId,
customerId: candidates[0].customerId,
note: `coincidencia exacta por ${field === "accountNumber" ? "número de cuenta" : "medidor"} ${ref}`,
confident: true,
candidates,
};
}
private async byCadastralKey(
kind: ServiceKind,
key: string,
): Promise<MatchResult | null> {
const props = await this.prisma.property.findMany({
where: { cadastralKey: key },
select: {
customerId: true,
customer: { select: { name: true } },
services: { where: { kind }, select: { id: true } },
},
});
if (props.length === 0) return null;
const candidates = props.flatMap((p) =>
(p.services.length ? p.services.map((s) => s.id) : [null]).map((sid) => ({
propertyServiceId: sid as string,
customerId: p.customerId,
customerName: p.customer.name,
})),
);
if (candidates.length > 1) {
return {
propertyServiceId: null,
customerId: null,
note: `${candidates.length} propiedades comparten la clave catastral ${key}`,
confident: false,
candidates,
};
}
// The clave identifies the property with certainty, but it is a *secondary*
// key: it was not the number the statement was issued against. Left for
// review so the confirm also teaches the matcher the account number, rather
// than the same page needing the fallback again next month.
return {
propertyServiceId: candidates[0].propertyServiceId ?? null,
customerId: candidates[0].customerId,
note: `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`,
confident: false,
candidates,
};
}
private unmatched(note: string): MatchResult {
return {
propertyServiceId: null,
customerId: null,
note,
confident: false,
candidates: [],
};
}
}
+52
View File
@@ -0,0 +1,52 @@
import {
IsBoolean,
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
MinLength,
} from "class-validator";
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
export class CreateStatementBatchDto {
@IsEnum(ServiceKind) serviceKind!: ServiceKind;
@IsOptional() @IsString() label?: string;
}
/** Staff correction of one document's extracted fields or its match. */
export class ReviewDocumentDto {
@IsOptional() @IsString() accountRef?: string;
@IsOptional() @IsNumber() amount?: number;
@IsOptional() @IsString() period?: string;
@IsOptional() @IsString() dueDate?: string;
@IsOptional() @IsString() matchedPropertyServiceId?: string;
@IsOptional() @IsString() matchedCustomerId?: string;
// Restricted to the review-reachable states: a client cannot declare a
// document POSTED, because only a successful ledger write may do that.
@IsOptional()
@IsEnum(StatementDocumentStatus)
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
}
/**
* Post a batch's confirmed documents. The check-level fields are shared by
* every line, exactly as on the manual batch-capture screen — an OCR batch is
* still "these receipts, paid by this check".
*/
export class ConfirmBatchDto {
@IsString() @MinLength(1) checkNumber!: string;
@IsString() @MinLength(1) transactionDate!: string;
@IsOptional() @IsEnum(Currency) currency?: Currency;
/** Overrides the concept derived from the batch's service kind. */
@IsOptional() @IsString() typeId?: string;
/** Post as outstanding (sin fondos) — captured but not yet funded. */
@IsOptional() @IsBoolean() outstanding?: boolean;
/** Also post documents a reviewer explicitly marked CONFIRMED. */
@IsOptional() @IsBoolean() includeReviewed?: boolean;
}
export class ListBatchesQuery {
@IsOptional() @IsInt() page?: number;
@IsOptional() @IsInt() pageSize?: number;
}
@@ -0,0 +1,162 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
Req,
Res,
StreamableFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FilesInterceptor } from "@nestjs/platform-express";
import type { ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
import type { Request, Response } from "express";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import type { UploadedFileLike } from "../storage/upload-file";
import { StatementsService } from "./statements.service";
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
/**
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
*
* Nothing here writes to the ledger directly — confirming a batch delegates to
* BillingService, so an OCR-captured charge is indistinguishable from a
* hand-keyed one except for its `captureSource`.
*/
@Controller("statements")
@UseGuards(AuthenticatedGuard, AbilityGuard)
export class StatementsController {
constructor(
private readonly statements: StatementsService,
private readonly audit: AuditService,
) {}
private actingId(req: Request): string {
return (req.user as { id: string } | undefined)?.id ?? "";
}
/**
* Whether this deployment can ingest scans at all — the UI hides automatic
* capture without it. Both halves are needed: OCR to read the page, object
* storage to keep it.
*/
@Get("status")
async status() {
return {
ocrAvailable: await this.statements.ocrAvailable(),
storageAvailable: this.statements.storageAvailable(),
};
}
@Get("batches")
listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
return this.statements.listBatches(
Math.max(1, Number(page) || 1),
Math.min(100, Math.max(1, Number(pageSize) || 25)),
);
}
@Get("batches/:id")
getBatch(@Param("id") id: string) {
return this.statements.getBatch(id);
}
@Get("batches/:id/documents")
listDocuments(@Param("id") id: string, @Query("status") status?: string) {
return this.statements.listDocuments(
id,
(status || undefined) as StatementDocumentStatus | undefined,
);
}
/** The rendered page, so a reviewer can compare it against what was read. */
@Get("documents/:id/page")
async pageImage(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
const { stream, contentType, contentLength } = await this.statements.pageImage(id);
res.set({
"Content-Type": contentType ?? "image/png",
...(contentLength ? { "Content-Length": String(contentLength) } : {}),
});
return new StreamableFile(stream);
}
// --- writes ---------------------------------------------------------------
@Post("batches")
@RequireAbility("statement:ingest")
@UseInterceptors(
// A month of one company's statements is a handful of multi-page scans;
// 25 files at 50MB covers that with room to spare.
FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }),
)
async createBatch(
@UploadedFiles() files: UploadedFileLike[] | undefined,
@Query("serviceKind") serviceKind: ServiceKind,
@Query("label") label: string | undefined,
@Req() req: Request,
) {
const batch = await this.statements.createBatch(
files ?? [],
serviceKind,
this.actingId(req),
label,
);
void this.audit.log(this.actingId(req), "statement.batch.create", {
batchId: batch.id,
serviceKind,
fileCount: batch.fileCount,
});
return batch;
}
@Patch("documents/:id")
@RequireAbility("statement:review")
async review(
@Param("id") id: string,
@Body() dto: ReviewDocumentDto,
@Req() req: Request,
) {
const doc = await this.statements.review(id, dto, this.actingId(req));
void this.audit.log(this.actingId(req), "statement.document.review", {
documentId: id,
status: doc.status,
});
return doc;
}
@Post("documents/:id/reject")
@RequireAbility("statement:review")
async reject(@Param("id") id: string, @Req() req: Request) {
const doc = await this.statements.reject(id, this.actingId(req));
void this.audit.log(this.actingId(req), "statement.document.reject", {
documentId: id,
});
return doc;
}
/** Post every matched document in the batch, against one check. */
@Post("batches/:id/confirm")
@RequireAbility("statement:review")
async confirm(
@Param("id") id: string,
@Body() dto: ConfirmBatchDto,
@Req() req: Request,
) {
const result = await this.statements.confirmBatch(id, dto, this.actingId(req));
void this.audit.log(this.actingId(req), "statement.batch.confirm", {
batchId: id,
posted: result.posted,
total: result.total,
checkNumber: dto.checkNumber,
});
return result;
}
}
@@ -0,0 +1,23 @@
import { Module } from "@nestjs/common";
import { BillingModule } from "../billing/billing.module";
import { StatementsController } from "./statements.controller";
import { StatementsService } from "./statements.service";
import { StatementMatcherService } from "./statement-matcher.service";
import { OCR_PROVIDER } from "./ocr/ocr.provider";
import { TesseractOcrProvider } from "./ocr/tesseract.provider";
/**
* The concrete OCR engine is bound here and nowhere else — everything
* downstream depends on the OcrProvider interface, so swapping Tesseract for a
* managed extraction API is a one-line change in this file.
*/
@Module({
imports: [BillingModule],
controllers: [StatementsController],
providers: [
StatementsService,
StatementMatcherService,
{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider },
],
})
export class StatementsModule {}
@@ -0,0 +1,470 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from "@nestjs/common";
import {
Prisma,
type ServiceKind,
type StatementDocumentStatus,
} from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { StorageService } from "../storage/storage.service";
import { BillingService } from "../billing/billing.service";
import type { UploadedFileLike } from "../storage/upload-file";
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
import { parseStatement } from "./parsers/statement-parser";
import { StatementMatcherService } from "./statement-matcher.service";
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
/**
* Default ledger concept per service kind. The names are the legacy
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
* per confirm rather than hard-coded as ids, which differ per environment.
*/
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
ELECTRIC: "ELECTRIC",
WATER: "WATER",
TELEPHONE: "TELEPHONE",
GAS: "GAS BUTANO",
PROPERTY_TAX: "PROPERTY TAXES",
FEDERAL_ZONE: "FEDERAL ZONE",
CABLE: "CABLE",
};
/** Statuses a document can still be worked on from. */
const OPEN: StatementDocumentStatus[] = ["NEEDS_REVIEW", "MATCHED", "CONFIRMED"];
@Injectable()
export class StatementsService {
private readonly logger = new Logger(StatementsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly billing: BillingService,
private readonly matcher: StatementMatcherService,
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
) {}
ocrAvailable(): Promise<boolean> {
return this.ocr.available();
}
/** Scans are stored as blobs, so no object storage means no intake. */
storageAvailable(): boolean {
return this.storage.available;
}
// --- ingest ---------------------------------------------------------------
/**
* Accept a batch of scanned PDFs and start processing.
*
* Processing is kicked off but deliberately not awaited: 300 pages of OCR is
* minutes of CPU, far past any sane HTTP timeout. The caller gets the batch
* id immediately and polls its status, which is also what lets the review
* queue show partial progress.
*/
async createBatch(
files: UploadedFileLike[],
serviceKind: ServiceKind,
uploadedById: string,
label?: string,
) {
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
if (!(await this.ocr.available())) {
throw new BadRequestException(
"El servidor no tiene OCR instalado; no se pueden procesar recibos.",
);
}
// Checked here rather than at the first `put`, which would only surface as
// a FAILED batch minutes later.
if (!this.storage.available) {
throw new BadRequestException(
"El almacenamiento de documentos no está configurado; no se pueden " +
"guardar los recibos escaneados.",
);
}
const batch = await this.prisma.statementBatch.create({
data: { serviceKind, uploadedById, label, fileCount: files.length },
});
// Buffers are held for the background pass; the request's own copies would
// otherwise be garbage once the response is sent.
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
await this.prisma.statementBatch.update({
where: { id: batch.id },
data: { status: "FAILED", error: (err as Error).message },
});
});
return batch;
}
/** Render → OCR → parse → match, one document row per page. */
private async process(
batchId: string,
files: { buffer: Buffer; name?: string }[],
serviceKind: ServiceKind,
) {
await this.prisma.statementBatch.update({
where: { id: batchId },
data: { status: "PROCESSING" },
});
let pageNumber = 0;
for (const file of files) {
// The source PDF is kept as well as the page images: it is the artifact
// the office actually received, and the only way to re-run a corrected
// parser over the original later.
const sourceKey = `statement/${batchId}/source-${pageNumber + 1}.pdf`;
await this.storage.put(sourceKey, file.buffer, "application/pdf");
const pages = await this.ocr.renderPages(file.buffer);
for (const image of pages) {
pageNumber += 1;
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
await this.storage.put(storageKey, image, "image/png");
try {
const ocr = await this.ocr.recognize(image);
const parsed = parseStatement(ocr);
const match = await this.matcher.match(parsed, serviceKind);
const notes = [...parsed.notes, match.note].filter(Boolean);
// A confident field match is only trusted when nothing contradicts
// it: a barcode that disagrees with the printed number means one of
// the two was misread, and which one is a judgement call.
const trusted = match.confident && parsed.crossChecked !== false;
await this.prisma.statementDocument.create({
data: {
batchId,
pageNumber,
storageKey,
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
ocrRawText: ocr.text,
ocrConfidence: new Prisma.Decimal(ocr.confidence.toFixed(3)),
provider: parsed.provider,
extractedAccountRef: parsed.accountRef,
extractedAmount:
parsed.amount != null ? new Prisma.Decimal(parsed.amount) : null,
extractedPeriod: parsed.period,
extractedDueDate: parsed.dueDate,
extractedCadastralKey: parsed.cadastralKey,
matchedPropertyServiceId: match.propertyServiceId,
matchedCustomerId: match.customerId,
matchNote: notes.join("; ").slice(0, 190),
},
});
} catch (err) {
// One unreadable page must not abandon the other 299.
await this.prisma.statementDocument.create({
data: {
batchId,
pageNumber,
storageKey,
status: "OCR_FAILED",
matchNote: (err as Error).message.slice(0, 190),
},
});
}
}
}
await this.prisma.statementBatch.update({
where: { id: batchId },
data: { status: "READY_FOR_REVIEW" },
});
}
// --- reads ----------------------------------------------------------------
async listBatches(page: number, pageSize: number) {
const [total, items] = await this.prisma.$transaction([
this.prisma.statementBatch.count(),
this.prisma.statementBatch.findMany({
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
uploadedBy: { select: { name: true } },
_count: { select: { documents: true } },
},
}),
]);
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
async getBatch(id: string) {
const batch = await this.prisma.statementBatch.findUnique({
where: { id },
include: { uploadedBy: { select: { name: true } } },
});
if (!batch) throw new NotFoundException("Lote no encontrado.");
const counts = await this.prisma.statementDocument.groupBy({
by: ["status"],
where: { batchId: id },
_count: { _all: true },
});
const totals = await this.prisma.statementDocument.aggregate({
where: { batchId: id, status: { in: OPEN } },
_sum: { extractedAmount: true },
});
return {
...batch,
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
};
}
async listDocuments(batchId: string, status?: StatementDocumentStatus) {
return this.prisma.statementDocument.findMany({
where: { batchId, ...(status ? { status } : {}) },
orderBy: { pageNumber: "asc" },
include: {
matchedCustomer: { select: { id: true, name: true } },
matchedPropertyService: {
select: {
id: true,
kind: true,
accountNumber: true,
meterNumber: true,
property: { select: { id: true, addressLine1: true } },
},
},
},
});
}
/** The rendered page image, so a reviewer can read what the parser read. */
async pageImage(documentId: string) {
const doc = await this.prisma.statementDocument.findUnique({
where: { id: documentId },
select: { storageKey: true },
});
if (!doc) throw new NotFoundException("Documento no encontrado.");
return this.storage.getStream(doc.storageKey);
}
// --- review ---------------------------------------------------------------
/** Staff correction of an extracted field or of the match itself. */
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue registrado.");
}
// Changing the service implies its owner; deriving the customer here rather
// than trusting a client-supplied pair is what stops a page being posted to
// one customer's ledger against another customer's service.
let matchedCustomerId = doc.matchedCustomerId;
let matchedPropertyServiceId = dto.matchedPropertyServiceId ?? undefined;
if (dto.matchedPropertyServiceId) {
const svc = await this.prisma.propertyService.findUnique({
where: { id: dto.matchedPropertyServiceId },
select: { property: { select: { customerId: true } } },
});
if (!svc) throw new BadRequestException("Servicio no encontrado.");
matchedCustomerId = svc.property.customerId;
} else if (dto.matchedCustomerId) {
matchedCustomerId = dto.matchedCustomerId;
// A reviewer picks a *customer*, not one of their service rows. Without
// a service the posting still works, but the confirmed reference has
// nowhere to be written back, so the same account would land in review
// again next month — which is exactly the behaviour that is supposed to
// make gas (whose numbers the migration never populated) a one-time cost.
// So: if the batch's service kind resolves to exactly one of that
// customer's services that has no reference yet, attach it. Exactly one
// — with two candidates there is no way to tell which meter or line the
// bill belongs to, and guessing would write a real number onto the wrong
// service.
const batch = await this.prisma.statementBatch.findUnique({
where: { id: doc.batchId },
select: { serviceKind: true },
});
if (batch) {
const field = batch.serviceKind === "GAS" ? "meterNumber" : "accountNumber";
const blank = await this.prisma.propertyService.findMany({
where: {
kind: batch.serviceKind,
[field]: null,
property: { customerId: matchedCustomerId },
},
select: { id: true },
take: 2,
});
if (blank.length === 1) matchedPropertyServiceId = blank[0].id;
}
}
return this.prisma.statementDocument.update({
where: { id },
data: {
extractedAccountRef: dto.accountRef ?? undefined,
extractedAmount:
dto.amount != null ? new Prisma.Decimal(dto.amount) : undefined,
extractedPeriod: dto.period ?? undefined,
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
matchedPropertyServiceId,
matchedCustomerId,
status: dto.status ?? "MATCHED",
reviewedById,
reviewedAt: new Date(),
},
});
}
async reject(id: string, reviewedById: string) {
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
if (!doc) throw new NotFoundException("Documento no encontrado.");
if (doc.status === "POSTED") {
throw new BadRequestException("Este documento ya fue registrado.");
}
return this.prisma.statementDocument.update({
where: { id },
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
});
}
// --- posting --------------------------------------------------------------
/**
* Post every confirmable document in a batch to the ledger.
*
* This goes through `BillingService.createBatch` — the same method the manual
* "Editor" screen uses — rather than writing `Transaction` rows directly, so
* OCR-sourced and hand-keyed receipts share one write path, one validation
* path and one audit trail. `source: "OCR"` and a per-line `captureRef` of
* the document id give the duplicate-post guard something to key on, so a
* batch confirmed twice cannot double-charge anyone.
*/
async confirmBatch(batchId: string, dto: ConfirmBatchDto, reviewedById: string) {
const batch = await this.prisma.statementBatch.findUnique({
where: { id: batchId },
});
if (!batch) throw new NotFoundException("Lote no encontrado.");
const docs = await this.prisma.statementDocument.findMany({
where: {
batchId,
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
matchedCustomerId: { not: null },
},
orderBy: { pageNumber: "asc" },
});
if (!docs.length) {
throw new BadRequestException("No hay documentos listos para registrar.");
}
const missing = docs.filter((d) => d.extractedAmount == null);
if (missing.length) {
throw new BadRequestException(
`Falta el importe en ${missing.length} documento(s): página(s) ` +
missing.map((d) => d.pageNumber).join(", "),
);
}
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
const result = await this.billing.createBatch(
{
domain: "UTILITY",
transactionDate: dto.transactionDate,
checkNumber: dto.checkNumber,
currency: dto.currency ?? "MXN",
typeId,
lines: docs.map((d) => ({
customerId: d.matchedCustomerId!,
// Charges are negative in this ledger: a negative amount is what the
// customer owes. The parser reads the printed (positive) figure, so
// the sign is applied here, at the single point where a statement
// becomes a ledger row.
amount: -Math.abs(Number(d.extractedAmount)),
reference: d.extractedAccountRef ?? undefined,
period: d.extractedPeriod ?? undefined,
outstanding: dto.outstanding ?? false,
})),
},
{ source: "OCR", refs: docs.map((d) => d.id) },
);
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
// the created rows zip straight back onto the documents that produced them.
await this.prisma.$transaction(
docs.map((d, i) =>
this.prisma.statementDocument.update({
where: { id: d.id },
data: {
status: "POSTED",
postedTransactionId: result.items[i].id,
reviewedById,
reviewedAt: new Date(),
},
}),
),
);
// Teach the matcher. When a document was matched by clave catastral or by
// hand because the scoped field was blank, writing the reference back means
// next month's statement for the same account matches on its own — this is
// what turns gas (whose numbers the migration never populated) from a
// permanent review queue into a one-time cost.
await this.learnAccountRefs(docs, batch.serviceKind);
await this.closeIfDone(batchId);
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
}
/** Write a confirmed reference onto a service that had none. */
private async learnAccountRefs(
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
kind: ServiceKind,
) {
const field = kind === "GAS" ? "meterNumber" : "accountNumber";
for (const d of docs) {
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
await this.prisma.propertyService.updateMany({
// Only fills a hole — never overwrites a number already on file, which
// would let one misread page rewrite good reference data.
where: { id: d.matchedPropertyServiceId, [field]: null },
data: { [field]: d.extractedAccountRef },
});
}
}
private async closeIfDone(batchId: string) {
const open = await this.prisma.statementDocument.count({
where: { batchId, status: { in: OPEN } },
});
if (open === 0) {
await this.prisma.statementBatch.update({
where: { id: batchId },
data: { status: "COMPLETED", completedAt: new Date() },
});
}
}
private async conceptFor(kind: ServiceKind): Promise<string | undefined> {
const name = CONCEPT_BY_KIND[kind];
if (!name) return undefined;
const row = await this.prisma.typeTransaction.findFirst({
where: { nameEn: name },
select: { id: true },
});
return row?.id;
}
}
+10
View File
@@ -73,6 +73,16 @@ export class StorageService implements OnModuleInit {
} }
} }
/**
* Whether the deployment has object storage at all. Callers use this to
* refuse work up front instead of failing halfway through — a recibo batch
* that dies on its first `put` leaves a FAILED batch and no explanation the
* office can act on.
*/
get available(): boolean {
return this.client !== null;
}
private require(): S3Client { private require(): S3Client {
if (!this.client) { if (!this.client) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
+13
View File
@@ -18,6 +18,7 @@ const safeSelect = {
email: true, email: true,
role: true, role: true,
active: true, active: true,
uiScale: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
} satisfies Prisma.UserSelect; } satisfies Prisma.UserSelect;
@@ -101,6 +102,18 @@ export class UsersService {
} }
} }
/**
* Self-service preference write — no ability check, because the only account
* it can touch is the caller's own (the controller passes the session id).
*/
updatePreferences(id: string, uiScale: number): Promise<SafeUserRow> {
return this.prisma.user.update({
where: { id },
data: { uiScale },
select: safeSelect,
});
}
async resetPassword(id: string, password: string): Promise<SafeUserRow> { async resetPassword(id: string, password: string): Promise<SafeUserRow> {
await this.ensureExists(id); await this.ensureExists(id);
const passwordHash = await argon2.hash(password); const passwordHash = await argon2.hash(password);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/web", "name": "@jorgecuadros/web",
"version": "0.1.0", "version": "1.0.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4500", "dev": "next dev -p 4500",
+525
View File
@@ -0,0 +1,525 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import {
createBankAccount,
createBankInstitution,
listBankAccounts,
listBankInstitutions,
updateBankAccount,
updateBankInstitution,
} from "@/lib/api";
import { domainLabel } from "@/lib/labels";
import type {
BankAccount,
BankInstitution,
Currency,
TransactionDomain,
} from "@/lib/types";
/**
* Chequera accounts admin — docs/RECEIPT_CAPTURE_SPEC.md §3.
*
* Two levels: the bank (institution) and the accounts held at it. Opening an
* account is rare and consequential — its currency is what every movement
* booked into it is denominated in, and it can't be changed afterwards without
* silently re-denominating history, so the edit form deliberately has no
* currency field.
*
* Accounts are never deleted: `bank_transactions.bankAccountId` is a required
* FK, so a used account can't be removed without destroying its register.
* Closing one (`active: false`) hides it from new captures while leaving the
* history readable, matching this app's never-hard-delete convention.
*/
const CURRENCIES: Currency[] = ["MXN", "USD"];
const BUSINESS_LINES: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
export default function CuentasChequeraPage() {
return (
<AppShell>
<Cuentas />
</AppShell>
);
}
function Cuentas() {
const canEdit = useCan("bank:manage-accounts");
const [banks, setBanks] = useState<BankInstitution[] | null>(null);
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
const [error, setError] = useState<string | null>(null);
function reload() {
Promise.all([listBankInstitutions(), listBankAccounts()])
.then(([b, a]) => {
setBanks(b);
setAccounts(a);
})
.catch((e) => setError(e?.message ?? "No se pudieron cargar las cuentas."));
}
useEffect(reload, []);
if (!canEdit) {
return (
<>
<div className="page-head">
<h1 className="page-title">Cuentas de chequera</h1>
</div>
<div className="state-box state-error">
No tiene permisos para administrar cuentas bancarias.
</div>
</>
);
}
return (
<>
<div className="page-head">
<p className="eyebrow">
<Link href="/banco">Chequera</Link>
</p>
<h1 className="page-title">Cuentas de chequera</h1>
<p className="section-note">
Cada cuenta es una chequera física y se lleva por separado. La moneda
se fija al darla de alta porque todos sus movimientos quedan
registrados en ella; para cambiarla hay que abrir otra cuenta. Las
cuentas no se eliminan: se cierran, y su historial sigue consultable.
</p>
</div>
{error && <div className="state-box state-error">{error}</div>}
{!banks || !accounts ? (
<div className="empty-inline">
<span className="spinner" aria-label="Cargando" />
</div>
) : (
<>
<BanksSection banks={banks} onChanged={reload} />
<AccountsSection
banks={banks}
accounts={accounts}
onChanged={reload}
/>
</>
)}
</>
);
}
/* ------------------------------------------------------------------ banks */
function BanksSection({
banks,
onChanged,
}: {
banks: BankInstitution[];
onChanged: () => void;
}) {
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState("");
const [country, setCountry] = useState("");
const [busy, setBusy] = useState(false);
function startAdd() {
setEditingId(null);
setAdding(true);
setName("");
setCountry("");
}
function startEdit(b: BankInstitution) {
setAdding(false);
setEditingId(b.id);
setName(b.name);
setCountry(b.country ?? "");
}
function cancel() {
setAdding(false);
setEditingId(null);
}
async function submit() {
if (!name.trim()) {
window.alert("El nombre del banco es obligatorio.");
return;
}
setBusy(true);
try {
const payload = { name: name.trim(), country: country.trim() };
if (editingId) await updateBankInstitution(editingId, payload);
else await createBankInstitution(payload);
cancel();
onChanged();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo guardar el banco.");
} finally {
setBusy(false);
}
}
const editor = (
<div className="child-editor">
<div className="form-grid">
<label className="field">
<span className="field-label">
Banco <span aria-hidden>*</span>
</span>
<input
className="input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Ej. Scotiabank"
/>
</label>
<label className="field">
<span className="field-label">País</span>
<input
className="input"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="MX / US"
/>
</label>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-primary"
onClick={submit}
disabled={busy}
>
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
</button>
<button type="button" className="btn btn-ghost" onClick={cancel}>
Cancelar
</button>
</div>
</div>
);
return (
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
<div className="child-head">
<h3 className="section-title" style={{ margin: 0 }}>
Bancos
<span className="section-count"> {banks.length}</span>
</h3>
{!adding && editingId === null && (
<button type="button" className="btn btn-outline" onClick={startAdd}>
+ Agregar
</button>
)}
</div>
{banks.length === 0 && !adding ? (
<div className="empty-inline">Sin bancos registrados.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Banco</th>
<th>País</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{adding && (
<tr>
<td colSpan={3}>{editor}</td>
</tr>
)}
{banks.map((b) =>
editingId === b.id ? (
<tr key={b.id}>
<td colSpan={3}>{editor}</td>
</tr>
) : (
<tr key={b.id}>
<td>{b.name}</td>
<td>{b.country || "—"}</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => startEdit(b)}
>
Editar
</button>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
/* --------------------------------------------------------------- accounts */
function AccountsSection({
banks,
accounts,
onChanged,
}: {
banks: BankInstitution[];
accounts: BankAccount[];
onChanged: () => void;
}) {
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [bankId, setBankId] = useState("");
const [label, setLabel] = useState("");
const [currency, setCurrency] = useState<Currency>("MXN");
const [businessLine, setBusinessLine] = useState<string>("");
const [active, setActive] = useState(true);
const [busy, setBusy] = useState(false);
function startAdd() {
setEditingId(null);
setAdding(true);
setBankId(banks[0]?.id ?? "");
setLabel("");
setCurrency("MXN");
setBusinessLine("");
setActive(true);
}
function startEdit(a: BankAccount) {
setAdding(false);
setEditingId(a.id);
setBankId(a.bankId);
setLabel(a.label);
setCurrency(a.currency);
setBusinessLine(a.businessLine ?? "");
setActive(a.active);
}
function cancel() {
setAdding(false);
setEditingId(null);
}
async function submit() {
if (!bankId) {
window.alert("Selecciona el banco de la cuenta.");
return;
}
if (!label.trim()) {
window.alert("El nombre de la cuenta es obligatorio.");
return;
}
setBusy(true);
try {
const line = businessLine
? (businessLine as TransactionDomain)
: undefined;
if (editingId) {
// No `currency`: see the file header.
await updateBankAccount(editingId, {
bankId,
label: label.trim(),
businessLine: line,
active,
});
} else {
await createBankAccount({
bankId,
label: label.trim(),
currency,
businessLine: line,
active,
});
}
cancel();
onChanged();
} catch (e) {
window.alert((e as Error)?.message ?? "No se pudo guardar la cuenta.");
} finally {
setBusy(false);
}
}
const editor = (
<div className="child-editor">
<div className="form-grid">
<label className="field">
<span className="field-label">
Banco <span aria-hidden>*</span>
</span>
<select
className="select"
value={bankId}
onChange={(e) => setBankId(e.target.value)}
>
<option value=""></option>
{banks.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">
Nombre de la cuenta <span aria-hidden>*</span>
</span>
<input
className="input"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Ej. Seguros — Bank of America (USD)"
/>
</label>
<label className="field">
<span className="field-label">
Moneda <span aria-hidden>*</span>
</span>
<select
className="select"
value={currency}
disabled={editingId !== null}
onChange={(e) => setCurrency(e.target.value as Currency)}
>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
{editingId !== null && (
<span className="section-note">
No se puede cambiar: los movimientos ya registrados están en esta
moneda.
</span>
)}
</label>
<label className="field">
<span className="field-label">Línea de negocio</span>
<select
className="select"
value={businessLine}
onChange={(e) => setBusinessLine(e.target.value)}
>
<option value="">Sin asignar</option>
{BUSINESS_LINES.map((d) => (
<option key={d} value={d}>
{domainLabel(d)}
</option>
))}
</select>
<span className="section-note">
Referencia nada más: una chequera puede pagar de varias líneas.
</span>
</label>
<label
className="field"
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
>
<input
type="checkbox"
checked={active}
onChange={(e) => setActive(e.target.checked)}
/>
<span className="field-label" style={{ margin: 0 }}>
Cuenta abierta
</span>
</label>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-primary"
onClick={submit}
disabled={busy}
>
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
</button>
<button type="button" className="btn btn-ghost" onClick={cancel}>
Cancelar
</button>
</div>
</div>
);
return (
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
<div className="child-head">
<h3 className="section-title" style={{ margin: 0 }}>
Cuentas
<span className="section-count"> {accounts.length}</span>
</h3>
{!adding && editingId === null && banks.length > 0 && (
<button type="button" className="btn btn-outline" onClick={startAdd}>
+ Agregar
</button>
)}
</div>
{banks.length === 0 ? (
<div className="empty-inline">
Registra primero el banco donde está la cuenta.
</div>
) : accounts.length === 0 && !adding ? (
<div className="empty-inline">Sin cuentas registradas.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cuenta</th>
<th>Banco</th>
<th>Moneda</th>
<th>Línea</th>
<th>Estatus</th>
<th className="num">Acciones</th>
</tr>
</thead>
<tbody>
{adding && (
<tr>
<td colSpan={6}>{editor}</td>
</tr>
)}
{accounts.map((a) =>
editingId === a.id ? (
<tr key={a.id}>
<td colSpan={6}>{editor}</td>
</tr>
) : (
<tr key={a.id}>
<td>{a.label}</td>
<td>{a.bankName}</td>
<td className="mono">{a.currency}</td>
<td>{a.businessLine ? domainLabel(a.businessLine) : "—"}</td>
<td>{a.active ? "Abierta" : "Cerrada"}</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-ghost"
onClick={() => startEdit(a)}
>
Editar
</button>
<Link href="/banco" className="btn btn-ghost">
Ver movimientos
</Link>
</div>
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+243 -47
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { ContextReports } from "@/components/ContextReports"; import { ContextReports } from "@/components/ContextReports";
@@ -8,6 +9,7 @@ import {
getBankFacets, getBankFacets,
getBankStats, getBankStats,
getBankSummary, getBankSummary,
listBankAccounts,
listBankMovements, listBankMovements,
voidBankMovement, voidBankMovement,
} from "@/lib/api"; } from "@/lib/api";
@@ -22,6 +24,7 @@ import {
monthName, monthName,
} from "@/lib/labels"; } from "@/lib/labels";
import type { import type {
BankAccount,
BankCleared, BankCleared,
BankDirection, BankDirection,
BankFacets, BankFacets,
@@ -32,23 +35,28 @@ import type {
BankSummary, BankSummary,
BankTotals, BankTotals,
CreateBankMovementInput, CreateBankMovementInput,
Currency,
} from "@/lib/types"; } from "@/lib/types";
/** /**
* Bank register (chequera) browser — plan step 7. * Bank register (chequera) browser — plan step 7, multi-account since the
* step-11 multi-bank work.
* *
* This is the office's OWN checking account, not customer money. It is a * This is the office's OWN checking accounts, not customer money. It is a
* separate page from /estado-cuenta on purpose: nothing here belongs in a * separate page from /estado-cuenta on purpose: nothing here belongs in a
* customer's statement and the two sets of figures are never combined. * customer's statement and the two sets of figures are never combined.
* *
* Two views: * Two views, both scoped to the ONE account picked at the top:
* - "Movimientos": the register itself — every deposit and payment, by date, * - "Movimientos": the register itself — every deposit and payment, by date,
* payee, cheque number or amount. * payee, cheque number or amount.
* - "Resumen": ingresos vs egresos per year, and per month inside a year, * - "Resumen": ingresos vs egresos per year, and per month inside a year,
* with the running net movement since the register opened in 2013. * with the running net movement since the register opened.
* *
* Single currency (MXN) — the source has no currency column. See the module * Every amount is read in the selected account's currency. There is no "all
* header in `bank.service.ts` for why there is no category/ramo filter. * accounts" option on purpose — Utilities banks in MXN and Seguros in USD, so
* one combined figure would be a number that never existed, exactly what
* /estado-cuenta's per-currency rule avoids. See the module header in
* `bank.service.ts` for why there is no category/ramo filter.
*/ */
type View = "movimientos" | "resumen"; type View = "movimientos" | "resumen";
@@ -81,9 +89,18 @@ export default function BancoPage() {
); );
} }
/** Remembers the last chequera a person looked at, per browser. */
const ACCOUNT_KEY = "banco.bankAccountId";
function BankBrowser() { function BankBrowser() {
const canCapture = useCan("bank:create"); const canCapture = useCan("bank:create");
const canVoid = useCan("bank:void"); const canVoid = useCan("bank:void");
const canManageAccounts = useCan("bank:manage-accounts");
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
const [accountId, setAccountId] = useState<string | null>(null);
const [accountsError, setAccountsError] = useState<string | null>(null);
const [stats, setStats] = useState<BankStats | null>(null); const [stats, setStats] = useState<BankStats | null>(null);
const [facets, setFacets] = useState<BankFacets | null>(null); const [facets, setFacets] = useState<BankFacets | null>(null);
const [view, setView] = useState<View>("movimientos"); const [view, setView] = useState<View>("movimientos");
@@ -104,16 +121,66 @@ function BankBrowser() {
const debounceRef = useRef<ReturnType<typeof setTimeout>>(); const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const account = accounts?.find((a) => a.id === accountId) ?? null;
const currency = account?.currency ?? "MXN";
// Accounts load first: nothing else on this page can be requested until one
// is selected, because every read is scoped to exactly one chequera.
useEffect(() => { useEffect(() => {
getBankStats().then(setStats).catch(() => setStats(null)); listBankAccounts()
getBankFacets().then(setFacets).catch(() => setFacets(null)); .then((rows) => {
setAccounts(rows);
const remembered =
typeof window !== "undefined"
? window.localStorage.getItem(ACCOUNT_KEY)
: null;
const pick =
rows.find((a) => a.id === remembered) ??
rows.find((a) => a.active) ??
rows[0];
setAccountId(pick?.id ?? null);
if (rows.length === 0) setLoading(false);
})
.catch((e) => {
setAccountsError(e?.message ?? "No se pudieron cargar las cuentas.");
setLoading(false);
});
}, []); }, []);
function pickAccount(id: string) {
setAccountId(id);
if (typeof window !== "undefined")
window.localStorage.setItem(ACCOUNT_KEY, id);
// The previous account's figures must not linger while the new ones load.
setStats(null);
setFacets(null);
setMovements(null);
setSummary(null);
setSummaryYear(null);
}
const refreshStats = useCallback(() => {
if (!accountId) return;
getBankStats(accountId)
.then(setStats)
.catch(() => setStats(null));
}, [accountId]);
useEffect(() => {
if (!accountId) return;
refreshStats();
getBankFacets(accountId)
.then(setFacets)
.catch(() => setFacets(null));
}, [accountId, refreshStats]);
const runSearch = useCallback( const runSearch = useCallback(
(p: number) => { (p: number) => {
if (!accountId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
listBankMovements({ listBankMovements({
bankAccountId: accountId,
query: query || undefined, query: query || undefined,
direction: direction || undefined, direction: direction || undefined,
cleared: cleared || undefined, cleared: cleared || undefined,
@@ -132,23 +199,23 @@ function BankBrowser() {
setLoading(false); setLoading(false);
}); });
}, },
[query, direction, cleared, from, to, sort], [accountId, query, direction, cleared, from, to, sort],
); );
useEffect(() => { useEffect(() => {
if (view !== "movimientos") return; if (view !== "movimientos" || !accountId) return;
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(1), 280); debounceRef.current = setTimeout(() => runSearch(1), 280);
return () => { return () => {
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
}; };
}, [runSearch, view]); }, [runSearch, view, accountId]);
useEffect(() => { useEffect(() => {
if (view !== "resumen") return; if (view !== "resumen" || !accountId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
getBankSummary(summaryYear ?? undefined) getBankSummary(accountId, summaryYear ?? undefined)
.then((res) => { .then((res) => {
setSummary(res); setSummary(res);
setLoading(false); setLoading(false);
@@ -157,7 +224,7 @@ function BankBrowser() {
setError(e?.message ?? "No se pudo cargar el resumen."); setError(e?.message ?? "No se pudo cargar el resumen.");
setLoading(false); setLoading(false);
}); });
}, [view, summaryYear]); }, [view, summaryYear, accountId]);
function goToPage(p: number) { function goToPage(p: number) {
runSearch(p); runSearch(p);
@@ -194,20 +261,71 @@ function BankBrowser() {
setSort("date_desc"); setSort("date_desc");
} }
if (accountsError) {
return (
<>
<div className="page-head">
<h1 className="page-title">Chequera</h1>
</div>
<div className="state-error" role="alert">
{accountsError}
</div>
</>
);
}
// No chequera on file: the register has nothing it could be scoped to.
if (accounts && accounts.length === 0) {
return (
<>
<div className="page-head">
<p className="eyebrow">Cuentas propias de la oficina</p>
<h1 className="page-title">Chequera</h1>
</div>
<div className="state-box">
<div className="state-glyph" aria-hidden>
</div>
<h3>Sin cuentas registradas</h3>
<p>
{canManageAccounts ? (
<>
Registra una cuenta bancaria en{" "}
<Link href="/banco/cuentas">Cuentas de chequera</Link> para
empezar a capturar movimientos.
</>
) : (
"Pide a un administrador que registre una cuenta bancaria."
)}
</p>
</div>
</>
);
}
return ( return (
<> <>
<div className="page-head rise"> <div className="page-head rise">
<p className="eyebrow">Cuenta propia de la oficina</p> <p className="eyebrow">Cuenta propia de la oficina</p>
<h1 className="page-title">Chequera</h1> <h1 className="page-title">Chequera</h1>
<AccountPicker
accounts={accounts}
accountId={accountId}
onPick={pickAccount}
canManageAccounts={canManageAccounts}
/>
<BankStatStrip <BankStatStrip
stats={stats} stats={stats}
currency={currency}
direction={view === "movimientos" ? direction : ""} direction={view === "movimientos" ? direction : ""}
onPickDirection={pickDirection} onPickDirection={pickDirection}
/> />
<p className="section-note"> <p className="section-note">
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma Movimientos de{" "}
parte del estado de cuenta de los clientes y sus cifras no se suman <strong>{account ? account.label : "la cuenta seleccionada"}</strong>,
con las de ellos. en {currency}. Cada cuenta se lee por separado: las cifras de dos
chequeras nunca se suman, igual que los saldos por moneda del estado
de cuenta. Tampoco forman parte del estado de cuenta de los clientes.
</p> </p>
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<ContextReports <ContextReports
@@ -252,7 +370,7 @@ function BankBrowser() {
</button> </button>
))} ))}
</div> </div>
{view === "movimientos" && canCapture && ( {view === "movimientos" && canCapture && account?.active && (
<button <button
type="button" type="button"
className="btn btn-primary" className="btn btn-primary"
@@ -263,12 +381,20 @@ function BankBrowser() {
)} )}
</div> </div>
{view === "movimientos" && captureOpen && ( {account && !account.active && (
<div className="section-note">
Esta cuenta está cerrada: su historial se consulta, pero no admite
movimientos nuevos.
</div>
)}
{view === "movimientos" && captureOpen && account && (
<BankCaptureForm <BankCaptureForm
account={account}
onSaved={() => { onSaved={() => {
setCaptureOpen(false); setCaptureOpen(false);
runSearch(movements?.page ?? 1); runSearch(movements?.page ?? 1);
getBankStats().then(setStats).catch(() => setStats(null)); refreshStats();
}} }}
onCancel={() => setCaptureOpen(false)} onCancel={() => setCaptureOpen(false)}
/> />
@@ -389,7 +515,7 @@ function BankBrowser() {
)} )}
{view === "movimientos" && movements && !loading && ( {view === "movimientos" && movements && !loading && (
<FilteredTotals totals={movements.totals} /> <FilteredTotals totals={movements.totals} currency={currency} />
)} )}
{error ? ( {error ? (
@@ -402,6 +528,7 @@ function BankBrowser() {
<SummaryView <SummaryView
summary={summary} summary={summary}
year={summaryYear} year={summaryYear}
currency={currency}
onPickYear={pickYear} onPickYear={pickYear}
/> />
) : movements && movements.total === 0 ? ( ) : movements && movements.total === 0 ? (
@@ -430,12 +557,11 @@ function BankBrowser() {
<BankRow <BankRow
key={m.id} key={m.id}
m={m} m={m}
currency={currency}
canVoid={canVoid} canVoid={canVoid}
onVoided={() => { onVoided={() => {
runSearch(movements?.page ?? 1); runSearch(movements?.page ?? 1);
getBankStats() refreshStats();
.then(setStats)
.catch(() => setStats(null));
}} }}
/> />
))} ))}
@@ -456,13 +582,66 @@ function BankBrowser() {
); );
} }
/**
* Which chequera the whole page is reading. There is no "todas las cuentas"
* option and there must not be one — see the file header.
*/
function AccountPicker({
accounts,
accountId,
onPick,
canManageAccounts,
}: {
accounts: BankAccount[] | null;
accountId: string | null;
onPick: (id: string) => void;
canManageAccounts: boolean;
}) {
if (!accounts) {
return (
<div className="skeleton" style={{ height: 34, width: 260, marginTop: 12 }} />
);
}
return (
<div
className="filter-row"
style={{ marginTop: 12, alignItems: "flex-end" }}
>
<label className="filter-field">
<span className="filter-label">Cuenta</span>
<select
className="input select"
value={accountId ?? ""}
onChange={(e) => onPick(e.target.value)}
aria-label="Cuenta de chequera"
>
{accounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label} · {a.currency}
{a.active ? "" : " (cerrada)"}
</option>
))}
</select>
</label>
{canManageAccounts && (
<Link href="/banco/cuentas" className="btn btn-ghost">
Administrar cuentas
</Link>
)}
</div>
);
}
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */ /** Headline figures; the ingreso/egreso cells double as register shortcuts. */
function BankStatStrip({ function BankStatStrip({
stats, stats,
currency,
direction, direction,
onPickDirection, onPickDirection,
}: { }: {
stats: BankStats | null; stats: BankStats | null;
currency: Currency;
direction: BankDirection | ""; direction: BankDirection | "";
onPickDirection: (d: BankDirection) => void; onPickDirection: (d: BankDirection) => void;
}) { }) {
@@ -493,7 +672,7 @@ function BankStatStrip({
aria-pressed={direction === "income"} aria-pressed={direction === "income"}
> >
<div className="stat-value tx-amount pos"> <div className="stat-value tx-amount pos">
{formatMoney(stats.income, "MXN")} {formatMoney(stats.income, currency)}
</div> </div>
<div className="stat-label"> <div className="stat-label">
En ingresos · {formatNumber(stats.incomeCount)} movimientos En ingresos · {formatNumber(stats.incomeCount)} movimientos
@@ -508,14 +687,14 @@ function BankStatStrip({
aria-pressed={direction === "expense"} aria-pressed={direction === "expense"}
> >
<div className="stat-value tx-amount neg"> <div className="stat-value tx-amount neg">
{formatMoney(stats.expense, "MXN")} {formatMoney(stats.expense, currency)}
</div> </div>
<div className="stat-label"> <div className="stat-label">
En egresos · {formatNumber(stats.expenseCount)} movimientos En egresos · {formatNumber(stats.expenseCount)} movimientos
</div> </div>
</button> </button>
<div className="stat-cell"> <div className="stat-cell">
<div className="stat-value">{formatMoney(stats.net, "MXN")}</div> <div className="stat-value">{formatMoney(stats.net, currency)}</div>
{/* Not the bank balance: the register carries no opening balance. */} {/* Not the bank balance: the register carries no opening balance. */}
<div className="stat-label">Movimiento neto acumulado</div> <div className="stat-label">Movimiento neto acumulado</div>
</div> </div>
@@ -544,27 +723,33 @@ function BankStatStrip({
} }
/** Totals for everything the current filter matched, not just the page. */ /** Totals for everything the current filter matched, not just the page. */
function FilteredTotals({ totals }: { totals: BankTotals }) { function FilteredTotals({
totals,
currency,
}: {
totals: BankTotals;
currency: Currency;
}) {
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0) if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
return null; return null;
return ( return (
<div className="filtered-totals"> <div className="filtered-totals">
<div className="filtered-total"> <div className="filtered-total">
<span className="filtered-total-cur">MXN</span> <span className="filtered-total-cur">{currency}</span>
<span> <span>
<strong className="tx-amount pos"> <strong className="tx-amount pos">
{formatMoney(totals.income, "MXN")} {formatMoney(totals.income, currency)}
</strong>{" "} </strong>{" "}
en ingresos · {formatNumber(totals.incomeCount)} en ingresos · {formatNumber(totals.incomeCount)}
</span> </span>
<span> <span>
<strong className="tx-amount neg"> <strong className="tx-amount neg">
{formatMoney(totals.expense, "MXN")} {formatMoney(totals.expense, currency)}
</strong>{" "} </strong>{" "}
en egresos · {formatNumber(totals.expenseCount)} en egresos · {formatNumber(totals.expenseCount)}
</span> </span>
<span className="filtered-total-net"> <span className="filtered-total-net">
Neto <strong>{formatMoney(totals.net, "MXN")}</strong> Neto <strong>{formatMoney(totals.net, currency)}</strong>
</span> </span>
{totals.voidCount > 0 && ( {totals.voidCount > 0 && (
<span>{formatNumber(totals.voidCount)} cancelados</span> <span>{formatNumber(totals.voidCount)} cancelados</span>
@@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
function BankRow({ function BankRow({
m, m,
currency,
canVoid, canVoid,
onVoided, onVoided,
}: { }: {
m: BankListItem; m: BankListItem;
currency: Currency;
canVoid: boolean; canVoid: boolean;
onVoided: () => void; onVoided: () => void;
}) { }) {
@@ -620,7 +807,7 @@ function BankRow({
<td>{bankSourceLabel(m.source)}</td> <td>{bankSourceLabel(m.source)}</td>
<td className="num"> <td className="num">
<span className={`tx-amount ${bankTone(m.direction)}`}> <span className={`tx-amount ${bankTone(m.direction)}`}>
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")} {m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
</span> </span>
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div> <div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
</td> </td>
@@ -650,10 +837,12 @@ function BankRow({
function SummaryView({ function SummaryView({
summary, summary,
year, year,
currency,
onPickYear, onPickYear,
}: { }: {
summary: BankSummary | null; summary: BankSummary | null;
year: number | null; year: number | null;
currency: Currency;
onPickYear: (y: number) => void; onPickYear: (y: number) => void;
}) { }) {
if (!summary) return null; if (!summary) return null;
@@ -687,12 +876,12 @@ function SummaryView({
<td className="num">{formatNumber(r.count)}</td> <td className="num">{formatNumber(r.count)}</td>
<td className="num"> <td className="num">
<span className="tx-amount pos"> <span className="tx-amount pos">
{formatMoney(r.income, "MXN")} {formatMoney(r.income, currency)}
</span> </span>
</td> </td>
<td className="num"> <td className="num">
<span className="tx-amount neg"> <span className="tx-amount neg">
{formatMoney(r.expense, "MXN")} {formatMoney(r.expense, currency)}
</span> </span>
</td> </td>
<td className="num"> <td className="num">
@@ -701,10 +890,10 @@ function SummaryView({
Number(r.net) < 0 ? "neg" : "pos" Number(r.net) < 0 ? "neg" : "pos"
}`} }`}
> >
{formatMoney(r.net, "MXN")} {formatMoney(r.net, currency)}
</span> </span>
</td> </td>
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td> <td className="num mono">{formatMoney(r.cumulative, currency)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -724,7 +913,7 @@ function SummaryView({
<span className="section-rule cuenta" /> <span className="section-rule cuenta" />
<h2 className="section-title">Meses de {year}</h2> <h2 className="section-title">Meses de {year}</h2>
<span className="section-count"> <span className="section-count">
abre en {formatMoney(summary.opening, "MXN")} abre en {formatMoney(summary.opening, currency)}
</span> </span>
</div> </div>
<div className="card"> <div className="card">
@@ -747,12 +936,12 @@ function SummaryView({
<td className="num">{formatNumber(r.count)}</td> <td className="num">{formatNumber(r.count)}</td>
<td className="num"> <td className="num">
<span className="tx-amount pos"> <span className="tx-amount pos">
{formatMoney(r.income, "MXN")} {formatMoney(r.income, currency)}
</span> </span>
</td> </td>
<td className="num"> <td className="num">
<span className="tx-amount neg"> <span className="tx-amount neg">
{formatMoney(r.expense, "MXN")} {formatMoney(r.expense, currency)}
</span> </span>
</td> </td>
<td className="num"> <td className="num">
@@ -761,11 +950,11 @@ function SummaryView({
Number(r.net) < 0 ? "neg" : "pos" Number(r.net) < 0 ? "neg" : "pos"
}`} }`}
> >
{formatMoney(r.net, "MXN")} {formatMoney(r.net, currency)}
</span> </span>
</td> </td>
<td className="num mono"> <td className="num mono">
{formatMoney(r.cumulative, "MXN")} {formatMoney(r.cumulative, currency)}
</td> </td>
</tr> </tr>
))} ))}
@@ -779,13 +968,16 @@ function SummaryView({
); );
} }
/** Inline capture form for a single chequera movement. Single currency (MXN); /** Inline capture form for a single chequera movement. The amount is in the
* sign convention: positive = ingreso, negative = egreso. Booked rows are * selected account's currency; sign convention: positive = ingreso, negative
* never edited — fix mistakes with voidBankMovement + a fresh capture. */ * = egreso. Booked rows are never edited — fix mistakes with voidBankMovement
* + a fresh capture. */
function BankCaptureForm({ function BankCaptureForm({
account,
onSaved, onSaved,
onCancel, onCancel,
}: { }: {
account: BankAccount;
onSaved: () => void; onSaved: () => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
@@ -818,6 +1010,7 @@ function BankCaptureForm({
} }
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs); const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
const payload: CreateBankMovementInput = { const payload: CreateBankMovementInput = {
bankAccountId: account.id,
amount: signed, amount: signed,
transactionDate, transactionDate,
concept: s(concept), concept: s(concept),
@@ -843,9 +1036,12 @@ function BankCaptureForm({
<form onSubmit={submit}> <form onSubmit={submit}>
{error && <div className="state-box state-error">{error}</div>} {error && <div className="state-box state-error">{error}</div>}
<div className="card" style={{ padding: 20, marginBottom: 16 }}> <div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}> <h2 className="section-title" style={{ marginBottom: 4 }}>
Capturar movimiento de chequera Capturar movimiento de chequera
</h2> </h2>
<p className="section-note" style={{ marginBottom: 14 }}>
Se registra en <strong>{account.label}</strong>, en {account.currency}.
</p>
<div className="form-grid"> <div className="form-grid">
<label className="field"> <label className="field">
<span className="field-label"> <span className="field-label">
@@ -874,7 +1070,7 @@ function BankCaptureForm({
</label> </label>
<label className="field"> <label className="field">
<span className="field-label"> <span className="field-label">
Monto (MXN) <span aria-hidden>*</span> Monto ({account.currency}) <span aria-hidden>*</span>
</span> </span>
<input <input
className="input" className="input"
+3 -549
View File
@@ -1,557 +1,11 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker"; import { Captura } from "@/components/Captura";
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
import type {
BatchCreateInput,
BillingFacets,
ByCheckResponse,
Currency,
LedgerCurrency,
TransactionDomain,
} from "@/lib/types";
/**
* Batch capture by check — the "Editor" screen from the legacy system
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2).
*
* Staff key many customers' receipts against ONE physical check before cutting
* it, then check that the captured total matches the check's amount. That
* reconciliation is the whole point, so the running total is the most prominent
* thing on the page and an optional "importe del cheque" field turns it into a
* live difference.
*
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
* by it answers every by-check question (see the "Reporte por cheque" report).
*/
const DOMAINS: { key: TransactionDomain; label: string }[] = [
{ key: "UTILITY", label: "Servicios" },
{ key: "INSURANCE", label: "Seguros" },
{ key: "TRUST", label: "Fideicomiso" },
];
interface Line {
/** Local row key — lines have no server identity until the batch posts. */
key: number;
customerId: string;
customerName: string;
amount: string;
reference: string;
period: string;
outstanding: boolean;
}
function blankLine(key: number): Line {
return {
key,
customerId: "",
customerName: "",
amount: "",
reference: "",
period: "",
outstanding: false,
};
}
/** Daily capture, opened on the manual (key-by-hand) mode. */
export default function BatchCapturePage() { export default function BatchCapturePage() {
return ( return (
<AppShell> <AppShell>
<BatchCapture /> <Captura initialMode="manual" />
</AppShell> </AppShell>
); );
} }
function BatchCapture() {
const canCapture = useCan("ledger:create");
const [facets, setFacets] = useState<BillingFacets | null>(null);
// Check-level fields — shared by every line.
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
const [typeId, setTypeId] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
/** The physical check's amount, for reconciliation only — never submitted. */
const [checkAmount, setCheckAmount] = useState("");
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
const [nextKey, setNextKey] = useState(4);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
useEffect(() => {
getBillingFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const filled = lines.filter(
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
);
// Charges are captured as positive numbers and signed on submit, matching
// MovementForm — staff type what's on the bill, not a negative.
const total = useMemo(
() =>
filled
.filter((l) => !l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const outstandingTotal = useMemo(
() =>
filled
.filter((l) => l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const checkAmt = Number(checkAmount);
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
const diff = hasCheckAmt ? checkAmt - total : 0;
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
function update(key: number, patch: Partial<Line>) {
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
}
function addLine() {
setLines((ls) => [...ls, blankLine(nextKey)]);
setNextKey((k) => k + 1);
}
function removeLine(key: number) {
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
if (filled.length === 0) {
setError("Captura al menos una línea con cliente y monto.");
return;
}
const dupes = filled
.map((l) => l.customerId)
.filter((id, i, arr) => arr.indexOf(id) !== i);
if (dupes.length) {
const names = filled
.filter((l) => dupes.includes(l.customerId))
.map((l) => l.customerName);
if (
!window.confirm(
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
", ",
)}). ¿Continuar?`,
)
)
return;
}
const payload: BatchCreateInput = {
domain,
transactionDate,
checkNumber: checkNumber.trim(),
currency: currency as Currency,
typeId: typeId || undefined,
lines: filled.map((l) => ({
customerId: l.customerId,
// Every line of a check batch is a charge the office paid out.
amount: -Math.abs(Number(l.amount)),
reference: l.reference.trim() || undefined,
period: l.period.trim() || undefined,
outstanding: l.outstanding || undefined,
})),
};
setSaving(true);
setError(null);
try {
await createMovementBatch(payload);
// Re-read through the by-check view so the confirmation shows what's
// actually stored (including anything captured against this check
// earlier), not just what this request sent.
setPosted(await getByCheck(payload.checkNumber));
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
} finally {
setSaving(false);
}
}
function reset() {
setPosted(null);
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
setNextKey((k) => k + 3);
setCheckNumber("");
setCheckAmount("");
}
if (!canCapture) {
return (
<div className="state-box state-error">
No tienes permiso para capturar movimientos.
</div>
);
}
if (posted) {
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Lote capturado</h1>
<p className="eyebrow">
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
{posted.count === 1 ? "movimiento" : "movimientos"}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button type="button" className="btn btn-primary" onClick={reset}>
Capturar otro cheque
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Volver a estado de cuenta
</Link>
</div>
</div>
<div className="filtered-totals" style={{ marginBottom: 16 }}>
{posted.totals.map((t) => (
<div className="filtered-total" key={t.currency}>
<span className="filtered-total-cur">{t.currency}</span>
<span className="filtered-total-net">
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
</span>
<span>{formatNumber(t.count)} movimientos</span>
</div>
))}
{posted.outstandingCount > 0 && (
<div className="filtered-total">
<span>
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
total)
</span>
</div>
)}
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Referencia</th>
<th>Periodo</th>
<th>Estado</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{posted.items.map((i) => (
<tr key={i.id}>
<td>
<Link
href={`/estado-cuenta/${i.customerId}`}
className="inline-link"
>
{i.customerName}
</Link>
</td>
<td>{i.reference || "—"}</td>
<td>{i.period || "—"}</td>
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(i.amount, i.currency)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted" style={{ marginTop: 14 }}>
Para imprimir la conciliación, usa el reporte{" "}
<Link
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
posted.checkNumber,
)}`}
className="inline-link"
>
Reporte por cheque
</Link>
.
</p>
</>
);
}
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Captura por cheque</h1>
<p className="eyebrow">
Captura los recibos de varios clientes contra un mismo cheque y
concilia el total antes de guardar.
</p>
</div>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Datos del cheque
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
required
/>
</label>
<label className="field">
<span className="field-label">Fecha *</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Línea de negocio *</span>
<select
className="select"
value={domain}
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Moneda *</span>
<select
className="select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</option>
</select>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<select
className="select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">(sin concepto)</option>
{facets?.types.map((t) => (
<option key={t.id} value={t.id}>
{txTypeLabel({ nameEn: t.name })}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Importe del cheque</span>
<input
className="input"
type="number"
step="0.01"
min="0"
value={checkAmount}
onChange={(e) => setCheckAmount(e.target.value)}
placeholder="Para conciliar"
/>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 14,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>
Recibos ({formatNumber(filled.length)})
</h2>
<button type="button" className="btn btn-outline" onClick={addLine}>
Agregar línea
</button>
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th style={{ minWidth: 240 }}>Cliente *</th>
<th style={{ minWidth: 120 }}>Referencia</th>
<th style={{ minWidth: 100 }}>Periodo</th>
<th style={{ minWidth: 110 }} className="num">
Monto *
</th>
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
<th style={{ width: 1 }} />
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
<td>
<CustomerPicker
value={l.customerId}
valueName={l.customerId ? l.customerName : undefined}
onPick={(id, name) =>
update(l.key, { customerId: id, customerName: name })
}
/>
</td>
<td>
<input
className="input"
value={l.reference}
onChange={(e) =>
update(l.key, { reference: e.target.value })
}
/>
</td>
<td>
<input
className="input"
value={l.period}
onChange={(e) => update(l.key, { period: e.target.value })}
placeholder="2026-07"
/>
</td>
<td>
<input
className="input num"
type="number"
step="0.01"
min="0"
value={l.amount}
onChange={(e) => update(l.key, { amount: e.target.value })}
placeholder="0.00"
/>
</td>
<td style={{ textAlign: "center" }}>
<input
type="checkbox"
checked={l.outstanding}
onChange={(e) =>
update(l.key, { outstanding: e.target.checked })
}
aria-label="Sin fondos"
/>
</td>
<td>
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => removeLine(l.key)}
disabled={lines.length === 1}
>
Quitar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Conciliación
</h2>
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">{currency}</span>
<span className="filtered-total-net">
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
</span>
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
</div>
{outstandingTotal > 0 && (
<div className="filtered-total">
<span>
Sin fondos{" "}
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
(no suma al cheque)
</span>
</div>
)}
{hasCheckAmt && (
<div className="filtered-total">
<span className="filtered-total-net">
{reconciled ? (
<strong className="tx-amount pos">Cuadra con el cheque</strong>
) : (
<>
Diferencia{" "}
<strong className="tx-amount neg">
{formatMoney(String(diff), currency)}
</strong>
</>
)}
</span>
</div>
)}
</div>
</div>
<div className="form-actions">
<button
type="submit"
className="btn btn-primary"
disabled={saving || filled.length === 0}
>
{saving
? "Guardando…"
: `Capturar ${formatNumber(filled.length)} ${
filled.length === 1 ? "recibo" : "recibos"
}`}
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
</form>
</>
);
}
File diff suppressed because it is too large Load Diff
+53 -7
View File
@@ -10,6 +10,7 @@ import {
getPolicyStats, getPolicyStats,
getPropertyStats, getPropertyStats,
getStats, getStats,
listBankAccounts,
} from "@/lib/api"; } from "@/lib/api";
import { useAuth } from "@/lib/abilities"; import { useAuth } from "@/lib/abilities";
import { import {
@@ -21,6 +22,7 @@ import {
trustStatusLabel, trustStatusLabel,
} from "@/lib/labels"; } from "@/lib/labels";
import type { import type {
BankAccount,
BankStats, BankStats,
BillingStats, BillingStats,
CustomerStats, CustomerStats,
@@ -41,7 +43,29 @@ interface DashboardData {
policies: PolicyStats | null; policies: PolicyStats | null;
properties: PropertyStats | null; properties: PropertyStats | null;
billing: BillingStats | null; billing: BillingStats | null;
/** Figures for ONE chequera — see `bankAccount` for which. */
bank: BankStats | null; bank: BankStats | null;
/**
* The chequera the card above is reading. The office keeps more than one, in
* different currencies, so this card shows the default account rather than a
* cross-account total, which would be a figure that never existed.
*/
bankAccount: BankAccount | null;
bankAccountCount: number;
}
/** Same default as /banco, so the two screens agree on which chequera opens. */
function defaultAccount(accounts: BankAccount[]): BankAccount | null {
const remembered =
typeof window !== "undefined"
? window.localStorage.getItem("banco.bankAccountId")
: null;
return (
accounts.find((a) => a.id === remembered) ??
accounts.find((a) => a.active) ??
accounts[0] ??
null
);
} }
function HomeDashboard() { function HomeDashboard() {
@@ -52,25 +76,42 @@ function HomeDashboard() {
properties: null, properties: null,
billing: null, billing: null,
bank: null, bank: null,
bankAccount: null,
bankAccountCount: 0,
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
// The chequera figures need an account id, so that read is a two-step:
// list the accounts, then ask the default one for its stats.
const bank = listBankAccounts().then(async (accounts) => {
const account = defaultAccount(accounts);
if (!account) return { account: null, stats: null, count: 0 };
return {
account,
stats: await getBankStats(account.id),
count: accounts.length,
};
});
Promise.allSettled([ Promise.allSettled([
getStats(), getStats(),
getPolicyStats(), getPolicyStats(),
getPropertyStats(), getPropertyStats(),
getBillingStats(), getBillingStats(),
getBankStats(), bank,
]).then((results) => { ]).then((results) => {
if (!alive) return; if (!alive) return;
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
setData({ setData({
customers: results[0].status === "fulfilled" ? results[0].value : null, customers: results[0].status === "fulfilled" ? results[0].value : null,
policies: results[1].status === "fulfilled" ? results[1].value : null, policies: results[1].status === "fulfilled" ? results[1].value : null,
properties: results[2].status === "fulfilled" ? results[2].value : null, properties: results[2].status === "fulfilled" ? results[2].value : null,
billing: results[3].status === "fulfilled" ? results[3].value : null, billing: results[3].status === "fulfilled" ? results[3].value : null,
bank: results[4].status === "fulfilled" ? results[4].value : null, bank: bankResult?.stats ?? null,
bankAccount: bankResult?.account ?? null,
bankAccountCount: bankResult?.count ?? 0,
}); });
setLoading(false); setLoading(false);
}); });
@@ -260,22 +301,27 @@ function HomeDashboard() {
loading={loading} loading={loading}
title="Chequera del despacho" title="Chequera del despacho"
primary={ primary={
data.bank ? ( data.bank && data.bankAccount ? (
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}> <span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
{formatMoney(data.bank.net, "MXN")} {formatMoney(data.bank.net, data.bankAccount.currency)}
</span> </span>
) : ( ) : (
"—" "—"
) )
} }
// Names the account, because this is one chequera's figure and the
// office has more than one — they are never added together.
sub={ sub={
data.bank data.bank && data.bankAccount
? balancePhrase(data.bank.net) ? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
: undefined : undefined
} }
meta={ meta={
data.bank data.bank
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` ? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
(data.bankAccountCount > 1
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
: "")
: undefined : undefined
} }
/> />
+18 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "./globals.css"; import "./globals.css";
import { readBuildInfoFromEnv } from "@/lib/build-info";
export const metadata = { export const metadata = {
title: "Jorge Cuadros & Asociados — Plataforma", title: "Jorge Cuadros & Asociados — Plataforma",
@@ -20,6 +21,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
process.env.API_ORIGIN ?? process.env.API_ORIGIN ??
process.env.NEXT_PUBLIC_API_ORIGIN ?? process.env.NEXT_PUBLIC_API_ORIGIN ??
"http://localhost:3001"; "http://localhost:3001";
// Same reason as the API origin: read on the server per request so the built
// image is not pinned to one build identity in its client bundle.
const build = readBuildInfoFromEnv();
return ( return (
<html lang="es"> <html lang="es">
@@ -27,7 +31,20 @@ export default function RootLayout({ children }: { children: ReactNode }) {
{/* Must run before the app bundle so lib/api.ts sees it at import. */} {/* Must run before the app bundle so lib/api.ts sees it at import. */}
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`, __html:
`window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};` +
`window.__APP_BUILD__=${JSON.stringify(build)};`,
}}
/>
{/* Text-size preference, applied before first paint so the page never
flashes at the default size. Mirrors lib/ui-scale.ts — keep the key
and the clamp in sync with it. */}
<script
dangerouslySetInnerHTML={{
__html:
`try{var s=parseFloat(localStorage.getItem("jc.ui-scale"));` +
`if(isFinite(s))document.documentElement.style.setProperty(` +
`"--ui-scale",String(Math.min(1.5,Math.max(0.9,s))));}catch(e){}`,
}} }}
/> />
{/* Google Fonts via <link> so an offline build still runs with the {/* Google Fonts via <link> so an offline build still runs with the
+478
View File
@@ -0,0 +1,478 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { AppShell } from "@/components/AppShell";
import { CustomerPicker } from "@/components/CustomerPicker";
import {
confirmStatementBatch,
getStatementBatch,
listStatementDocuments,
rejectStatementDocument,
reviewStatementDocument,
statementPageUrl,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
import type {
ConfirmBatchInput,
StatementBatchDetail,
StatementDocument,
StatementDocumentStatus,
} from "@/lib/types";
/**
* Review queue for one batch of scanned bills.
*
* The reviewer's job is to answer one question per page — "is this the right
* customer for this amount?" — so the page image sits next to the extracted
* fields and every row can be corrected in place. Rows the matcher is sure
* about are pre-approved and can be posted in bulk; everything else is listed
* first, because that is the work.
*/
const STATUS_LABEL: Record<StatementDocumentStatus, string> = {
PENDING_OCR: "En proceso",
OCR_FAILED: "No se pudo leer",
NEEDS_REVIEW: "Requiere revisión",
MATCHED: "Identificado",
CONFIRMED: "Confirmado",
POSTED: "Registrado",
REJECTED: "Descartado",
};
/** Rows still needing a decision, listed before the settled ones. */
const OPEN_FIRST: StatementDocumentStatus[] = [
"NEEDS_REVIEW",
"OCR_FAILED",
"MATCHED",
"CONFIRMED",
"POSTED",
"REJECTED",
"PENDING_OCR",
];
export default function RecibosBatchPage({ params }: { params: { id: string } }) {
return (
<AppShell>
<BatchReview id={params.id} />
</AppShell>
);
}
function BatchReview({ id }: { id: string }) {
const canReview = useCan("statement:review");
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
const [docs, setDocs] = useState<StatementDocument[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [b, d] = await Promise.all([
getStatementBatch(id),
listStatementDocuments(id),
]);
setBatch(b);
setDocs(d);
setError(null);
} catch (e) {
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
void load();
}, [load]);
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
useEffect(() => {
if (!processing) return;
const t = setInterval(() => void load(), 4000);
return () => clearInterval(t);
}, [processing, load]);
const sorted = useMemo(
() =>
[...docs].sort(
(a, b) =>
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
a.pageNumber - b.pageNumber,
),
[docs],
);
const readyCount = docs.filter(
(d) => d.status === "MATCHED" && d.matchedCustomer,
).length;
if (loading) return <div className="state-box">Cargando</div>;
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
return (
<div className="stack">
<header className="page-head">
<div>
<h1 className="page-title">
Recibos {serviceKindLabel(batch.serviceKind)}
{batch.label ? ` · ${batch.label}` : ""}
</h1>
<p className="page-sub">
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
{STATUS_LABEL_BATCH[batch.status] ?? batch.status}
</p>
</div>
<Link className="btn btn-ghost" href="/recibos">
Volver a captura
</Link>
</header>
{error && <div className="state-box state-error">{error}</div>}
{processing && (
<div className="state-box">
Leyendo los recibos esta pantalla se actualiza sola.
</div>
)}
<SummaryCard batch={batch} readyCount={readyCount} />
{canReview && readyCount > 0 && (
<ConfirmCard
batchId={id}
readyCount={readyCount}
onDone={load}
setError={setError}
/>
)}
<section className="stack">
{sorted.map((doc) => (
<DocumentRow
key={doc.id}
doc={doc}
canReview={canReview}
onChange={load}
/>
))}
</section>
</div>
);
}
const STATUS_LABEL_BATCH: Record<string, string> = {
UPLOADED: "Recibido",
PROCESSING: "Procesando",
READY_FOR_REVIEW: "Listo para revisar",
COMPLETED: "Registrado",
FAILED: "Falló",
};
function SummaryCard({
batch,
readyCount,
}: {
batch: StatementBatchDetail;
readyCount: number;
}) {
const entries = Object.entries(batch.byStatus) as [StatementDocumentStatus, number][];
return (
<section className="card" style={{ padding: 16 }}>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 20 }}>
{entries.map(([status, count]) => (
<div key={status}>
<div className="page-sub">{STATUS_LABEL[status] ?? status}</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{count}</div>
</div>
))}
<div>
<div className="page-sub">Importe pendiente</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>
{formatMoney(batch.pendingTotal, "MXN")}
</div>
</div>
<div>
<div className="page-sub">Listos para registrar</div>
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyCount}</div>
</div>
</div>
</section>
);
}
/**
* Posting is by check, exactly as on the manual capture screen — an OCR batch
* is still "these bills, paid with this check", so the same fields are asked
* for and the same ledger path is used.
*/
function ConfirmCard({
batchId,
readyCount,
onDone,
setError,
}: {
batchId: string;
readyCount: number;
onDone: () => void;
setError: (m: string | null) => void;
}) {
const [checkNumber, setCheckNumber] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
const [outstanding, setOutstanding] = useState(false);
const [includeReviewed, setIncludeReviewed] = useState(true);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<string | null>(null);
async function submit() {
if (!checkNumber.trim()) return;
setBusy(true);
setError(null);
try {
const input: ConfirmBatchInput = {
checkNumber: checkNumber.trim(),
transactionDate,
outstanding,
includeReviewed,
};
const r = await confirmStatementBatch(batchId, input);
setResult(
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
);
setCheckNumber("");
onDone();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
} finally {
setBusy(false);
}
}
return (
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Registrar {readyCount} recibo(s)
</h2>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Cheque</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
placeholder="Número de cheque"
/>
</label>
<label>
<span className="page-sub">Fecha</span>
<input
type="date"
className="input"
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="check">
<input
type="checkbox"
checked={outstanding}
onChange={(e) => setOutstanding(e.target.checked)}
/>{" "}
Sin fondos (queda pendiente)
</label>
<label className="check">
<input
type="checkbox"
checked={includeReviewed}
onChange={(e) => setIncludeReviewed(e.target.checked)}
/>{" "}
Incluir los confirmados a mano
</label>
<button
type="button"
className="btn btn-primary"
disabled={!checkNumber.trim() || busy}
onClick={submit}
>
{busy ? "Registrando…" : "Registrar"}
</button>
</div>
{result && (
<div className="state-box" style={{ marginTop: 12 }}>
{result}
</div>
)}
<p className="page-sub" style={{ marginTop: 12 }}>
Se registran como cargos del cliente, por la misma vía que la captura
manual. Un lote registrado dos veces no duplica cobros.
</p>
</section>
);
}
function DocumentRow({
doc,
canReview,
onChange,
}: {
doc: StatementDocument;
canReview: boolean;
onChange: () => void;
}) {
const [open, setOpen] = useState(
doc.status === "NEEDS_REVIEW" || doc.status === "OCR_FAILED",
);
const [amount, setAmount] = useState(doc.extractedAmount ?? "");
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const settled = doc.status === "POSTED" || doc.status === "REJECTED";
async function save(status: "MATCHED" | "CONFIRMED") {
setBusy(true);
setErr(null);
try {
await reviewStatementDocument(doc.id, {
accountRef: accountRef.trim() || undefined,
amount: amount ? Number(amount) : undefined,
matchedCustomerId: customerId || undefined,
status,
});
onChange();
} catch (e) {
setErr((e as Error)?.message ?? "No se pudo guardar.");
} finally {
setBusy(false);
}
}
async function reject() {
setBusy(true);
setErr(null);
try {
await rejectStatementDocument(doc.id);
onChange();
} catch (e) {
setErr((e as Error)?.message ?? "No se pudo descartar.");
} finally {
setBusy(false);
}
}
return (
<div className="card" style={{ padding: 16 }}>
<div
className="inline-form"
style={{ justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}
>
<div>
<strong>Página {doc.pageNumber}</strong>{" "}
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
{doc.provider && <span className="page-sub">{doc.provider}</span>}
<div className="page-sub" style={{ marginTop: 4 }}>
{doc.matchedCustomer ? (
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
{doc.matchedCustomer.name}
</Link>
) : (
"Sin cliente asignado"
)}
{doc.extractedAccountRef && ` · cuenta ${doc.extractedAccountRef}`}
{doc.extractedCadastralKey && ` · clave ${doc.extractedCadastralKey}`}
</div>
{doc.matchNote && (
<div className="page-sub" style={{ marginTop: 4 }}>
{doc.matchNote}
</div>
)}
</div>
<div className="inline-form" style={{ gap: 8 }}>
<strong>
{doc.extractedAmount
? formatMoney(doc.extractedAmount, "MXN")
: "sin importe"}
</strong>
<button
type="button"
className="btn btn-ghost"
onClick={() => setOpen((v) => !v)}
>
{open ? "Ocultar" : "Ver recibo"}
</button>
</div>
</div>
{open && (
<div style={{ marginTop: 12, display: "grid", gap: 16 }}>
{/* The scan itself — the reviewer's source of truth, not the OCR. */}
<img
src={statementPageUrl(doc.id)}
alt={`Recibo página ${doc.pageNumber}`}
style={{
maxWidth: "100%",
border: "1px solid var(--border, #ddd)",
borderRadius: 6,
}}
/>
{canReview && !settled && (
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Cuenta</span>
<input
className="input"
value={accountRef}
onChange={(e) => setAccountRef(e.target.value)}
/>
</label>
<label>
<span className="page-sub">Importe</span>
<input
className="input"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</label>
<div style={{ minWidth: 260 }}>
<span className="page-sub">Cliente</span>
<CustomerPicker
value={customerId}
valueName={customerName}
onPick={(cid, name) => {
setCustomerId(cid);
setCustomerName(name);
}}
/>
</div>
<button
type="button"
className="btn btn-primary"
disabled={busy || !customerId}
onClick={() => save("MATCHED")}
>
Guardar
</button>
<button
type="button"
className="btn btn-ghost"
disabled={busy}
onClick={reject}
>
Descartar
</button>
</div>
)}
{err && <div className="state-box state-error">{err}</div>}
</div>
)}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { AppShell } from "@/components/AppShell";
import { Captura } from "@/components/Captura";
/**
* Same capture screen as `/estado-cuenta/lote`, opened on the automatic
* (scanned recibos + OCR) mode. Kept as its own route so links from a batch
* review page and older bookmarks land on the right tab.
*/
export default function RecibosPage() {
return (
<AppShell>
<Captura initialMode="auto" />
</AppShell>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { readBuildInfoFromEnv } from "@/lib/build-info";
// Read per request, never prerendered — the whole point is to report what THIS
// running container is, and a baked answer would defeat that.
export const dynamic = "force-dynamic";
/**
* The web tier's counterpart to the API's GET /version.
*
* Without this, the only way to see what the web container is running was to
* scrape window.__APP_BUILD__ out of the HTML. The deploy workflow compares the
* two tiers' gitSha to catch a half-applied release, so it needs a stable,
* parseable answer from both sides.
*/
export function GET() {
return NextResponse.json({ service: "web", ...readBuildInfoFromEnv() });
}
+330 -27
View File
@@ -1,11 +1,20 @@
"use client"; "use client";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { logout, me } from "@/lib/api"; import { getApiVersion, logout, me, updateUiScale, type ServiceVersion } from "@/lib/api";
import { webBuildInfo } from "@/lib/build-info";
import { AuthContext, can } from "@/lib/abilities"; import { AuthContext, can } from "@/lib/abilities";
import { ROLE_LABEL } from "@/lib/labels"; import { ROLE_LABEL } from "@/lib/labels";
import {
DEFAULT_UI_SCALE,
applyUiScale,
normalizeUiScale,
readUiScale,
saveUiScale,
} from "@/lib/ui-scale";
import { FontScaleControl } from "./FontScaleControl";
import type { AuthUser, Ability } from "@/lib/types"; import type { AuthUser, Ability } from "@/lib/types";
/** /**
@@ -14,23 +23,87 @@ import type { AuthUser, Ability } from "@/lib/types";
* content. Provides the AuthContext so any page can read the user's * content. Provides the AuthContext so any page can read the user's
* abilities. Used by every authenticated page. * abilities. Used by every authenticated page.
*/ */
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
{ href: "/inicio", label: "Inicio", exact: true }, type NavLink = {
{ href: "/clientes", label: "Clientes" }, href: string;
{ href: "/servicios", label: "Propiedades" }, label: string;
{ href: "/polizas", label: "Pólizas" }, ability?: Ability;
exact?: boolean;
/** Extra path prefixes that belong to this entry (e.g. a second route into
* the same screen), so they highlight it instead of nothing. */
aliases?: string[];
};
type NavEntry =
| ({ kind: "link" } & NavLink)
| { kind: "group"; label: string; items: NavLink[] };
/**
* Top nav. Daily screens stay one click away; the movement screens and the
* admin screens are grouped behind menus so the bar doesn't saturate as the
* app grows. A group disappears entirely when the user can't see any of its
* items (gating here is cosmetic — the API enforces every write).
*/
const NAV: NavEntry[] = [
{ kind: "link", href: "/inicio", label: "Inicio", exact: true },
{ kind: "link", href: "/clientes", label: "Clientes" },
{ kind: "link", href: "/polizas", label: "Pólizas" },
{ kind: "link", href: "/servicios", label: "Propiedades" },
{
kind: "group",
label: "Cobranza",
items: [
// Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who
// can't capture anyway — the page itself also refuses. Both capture modes
// live behind this one entry: keying receipts by hand, and scanning a
// stack of bills for OCR (the `/recibos` route opens the same screen on
// its automatic tab).
{
href: "/estado-cuenta/lote",
label: "Captura",
ability: "ledger:create",
aliases: ["/recibos"],
},
{ href: "/estado-cuenta", label: "Estado de cuenta" }, { href: "/estado-cuenta", label: "Estado de cuenta" },
// Daily data-entry screen (the legacy "Editor"), so it earns a top-level
// entry rather than living one click inside the Movimientos tab. Hidden from
// VIEWER, who can't capture anyway — the page itself also refuses.
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
{ href: "/banco", label: "Chequera" }, { href: "/banco", label: "Chequera" },
{ href: "/reportes", label: "Reportes" }, ],
},
{ kind: "link", href: "/reportes", label: "Reportes" },
{
kind: "group",
label: "Admin",
items: [
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" }, { href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
{
href: "/banco/cuentas",
label: "Cuentas de chequera",
ability: "bank:manage-accounts",
},
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" }, { href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" }, { href: "/operaciones", label: "Operaciones", ability: "db:manage" },
],
},
]; ];
/** Every nav destination, flattened out of the groups. */
const NAV_LINKS: NavLink[] = NAV.flatMap((entry) =>
entry.kind === "link" ? [entry] : entry.items,
);
/** The nav the given user may see, with empty groups dropped. */
function visibleNav(user: AuthUser | null): NavEntry[] {
const allowed = (item: NavLink) => !item.ability || can(user, item.ability);
const out: NavEntry[] = [];
for (const entry of NAV) {
if (entry.kind === "link") {
if (allowed(entry)) out.push(entry);
continue;
}
const items = entry.items.filter(allowed);
if (items.length > 0) out.push({ ...entry, items });
}
return out;
}
/** /**
* Which nav entry is highlighted for a path. Longest matching href wins, so a * Which nav entry is highlighted for a path. Longest matching href wins, so a
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also * nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
@@ -40,10 +113,12 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
function activeHref(pathname: string | null): string | null { function activeHref(pathname: string | null): string | null {
if (!pathname) return null; if (!pathname) return null;
let best: string | null = null; let best: string | null = null;
for (const item of NAV) { for (const item of NAV_LINKS) {
const under = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
const match = item.exact const match = item.exact
? pathname === item.href ? pathname === item.href
: pathname === item.href || pathname.startsWith(`${item.href}/`); : under(item.href) || (item.aliases?.some(under) ?? false);
if (match && (best === null || item.href.length > best.length)) { if (match && (best === null || item.href.length > best.length)) {
best = item.href; best = item.href;
} }
@@ -51,22 +126,161 @@ function activeHref(pathname: string | null): string | null {
return best; return best;
} }
/**
* One collapsible group in the desktop bar. Opens on click, closes on outside
* click, Escape, or navigation. The trigger stays highlighted while any of its
* children is the current page.
*/
function NavMenu({
label,
items,
current,
pathname,
}: {
label: string;
items: NavLink[];
current: string | null;
pathname: string | null;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const holdsCurrent = items.some((item) => item.href === current);
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div className="appbar-menu" ref={ref}>
<button
type="button"
className={`appbar-link appbar-menu-trigger${holdsCurrent ? " active" : ""}`}
aria-expanded={open}
aria-haspopup="true"
onClick={() => setOpen((v) => !v)}
>
{label}
<span className="appbar-caret" aria-hidden="true" />
</button>
{open && (
<div className="appbar-dropdown" role="menu">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
role="menuitem"
className={`appbar-dropdown-link${current === item.href ? " active" : ""}`}
aria-current={current === item.href ? "page" : undefined}
onClick={() => setOpen(false)}
>
{item.label}
</Link>
))}
</div>
)}
</div>
);
}
/**
* What is deployed, from both halves. build.yml builds api + web in one matrix
* run, so their versions cannot drift at build time — but they can at DEPLOY
* time, if a stack is applied with only one image's tag moved. Showing both and
* flagging a mismatch is the cheap check that catches a half-applied release.
*/
function BuildFooter() {
const web = webBuildInfo();
const [api, setApi] = useState<ServiceVersion | null>(null);
useEffect(() => {
let alive = true;
getApiVersion()
.then((v) => {
if (alive) setApi(v);
})
.catch(() => {
// The shell already redirects to /login when the API is unreachable;
// a missing version line is not worth a second error surface.
});
return () => {
alive = false;
};
}, []);
// Compare the COMMIT, not the version string. On a branch build both tiers
// report APP_VERSION "master", so comparing versions cannot see drift — which
// is exactly how a stale web image once sat next to a current API with this
// footer showing nothing wrong. The sha is the only field that actually
// differs between two builds of the same branch.
const mismatch = api !== null && api.gitSha !== web.gitSha;
return (
<footer className="shell-footer">
<span>Jorge Cuadros &amp; Asociados</span>
{/* The FULL 40-char commit, not an abbreviation: this line exists to be
pasted into `git show` or compared against a registry tag, and a
7-char prefix makes both a manual step. It is what GIT_SHA already
carries — build.yml bakes in `github.sha` whole. */}
<span
className="shell-footer-build"
title={`web ${web.version} (${web.gitSha}) — ${web.buildDate}`}
>
v{web.version} · {web.gitSha}
{mismatch && api ? ` · API ${api.gitSha}` : ""}
</span>
{mismatch && (
<span className="shell-footer-warn" role="status">
versiones desincronizadas
</span>
)}
</footer>
);
}
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [user, setUser] = useState<AuthUser | null>(null); const [user, setUser] = useState<AuthUser | null>(null);
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE);
const current = activeHref(pathname); const current = activeHref(pathname);
const nav = visibleNav(user);
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
me() me()
.then((u) => { .then((u) => {
if (alive) { if (!alive) return;
setUser(u); setUser(u);
setChecking(false); setChecking(false);
} // The account wins over the localStorage copy the pre-hydration script
// painted with: that copy is this browser's, while the account follows
// the person between machines. Re-save so the next cold paint here is
// already correct.
const accountScale = normalizeUiScale(u.uiScale ?? DEFAULT_UI_SCALE);
setUiScale(accountScale);
applyUiScale(accountScale);
saveUiScale(accountScale);
}) })
.catch(() => { .catch(() => {
router.replace("/login"); router.replace("/login");
@@ -76,6 +290,39 @@ export function AppShell({ children }: { children: ReactNode }) {
}; };
}, [router]); }, [router]);
// Before /auth/me answers, show whatever the pre-hydration script applied so
// the control isn't briefly out of step with the page.
useEffect(() => {
setUiScale(readUiScale());
}, []);
function changeUiScale(next: number) {
setUiScale(next);
applyUiScale(next);
saveUiScale(next);
setUser((prev) => (prev ? { ...prev, uiScale: next } : prev));
// Fire and forget: the change is already applied and cached locally, so a
// failed write only means it won't follow the user to another machine.
updateUiScale(next).catch(() => {
/* ignore */
});
}
// Navigating away closes the mobile drawer — the route change is the only
// "done" signal we get from a <Link>.
useEffect(() => {
setDrawerOpen(false);
}, [pathname]);
useEffect(() => {
if (!drawerOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setDrawerOpen(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [drawerOpen]);
async function handleLogout() { async function handleLogout() {
setLoggingOut(true); setLoggingOut(true);
try { try {
@@ -117,24 +364,30 @@ export function AppShell({ children }: { children: ReactNode }) {
</span> </span>
</Link> </Link>
<nav className="appbar-nav" aria-label="Principal"> <nav className="appbar-nav" aria-label="Principal">
{NAV.filter((item) => !item.ability || can(user, item.ability)).map( {nav.map((entry) =>
(item) => { entry.kind === "link" ? (
const active = current === item.href;
return (
<Link <Link
key={item.href} key={entry.href}
href={item.href} href={entry.href}
className={`appbar-link${active ? " active" : ""}`} className={`appbar-link${current === entry.href ? " active" : ""}`}
aria-current={active ? "page" : undefined} aria-current={current === entry.href ? "page" : undefined}
> >
{item.label} {entry.label}
</Link> </Link>
); ) : (
}, <NavMenu
key={entry.label}
label={entry.label}
items={entry.items}
current={current}
pathname={pathname}
/>
),
)} )}
</nav> </nav>
<span className="appbar-spacer" /> <span className="appbar-spacer" />
<div className="appbar-user"> <div className="appbar-user">
<FontScaleControl value={uiScale} onChange={changeUiScale} />
{user && ( {user && (
<span className="appbar-user-name"> <span className="appbar-user-name">
{user.name} {user.name}
@@ -150,9 +403,59 @@ export function AppShell({ children }: { children: ReactNode }) {
{loggingOut ? "Saliendo…" : "Cerrar sesión"} {loggingOut ? "Saliendo…" : "Cerrar sesión"}
</button> </button>
</div> </div>
<button
type="button"
className="appbar-burger"
aria-label={drawerOpen ? "Cerrar menú" : "Abrir menú"}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen((v) => !v)}
>
<span className={`burger-icon${drawerOpen ? " open" : ""}`} aria-hidden="true" />
</button>
</div> </div>
{drawerOpen && (
<nav className="appbar-drawer" aria-label="Principal (móvil)">
{nav.map((entry) =>
entry.kind === "link" ? (
<Link
key={entry.href}
href={entry.href}
className={`appbar-drawer-link${current === entry.href ? " active" : ""}`}
aria-current={current === entry.href ? "page" : undefined}
>
{entry.label}
</Link>
) : (
<div key={entry.label} className="appbar-drawer-group">
<span className="appbar-drawer-heading">{entry.label}</span>
{entry.items.map((item) => (
<Link
key={item.href}
href={item.href}
className={`appbar-drawer-link${current === item.href ? " active" : ""}`}
aria-current={current === item.href ? "page" : undefined}
>
{item.label}
</Link>
))}
</div>
),
)}
<FontScaleControl
value={uiScale}
onChange={changeUiScale}
variant="inline"
/>
{user && (
<div className="appbar-drawer-user">
{user.name} · {ROLE_LABEL[user.role]}
</div>
)}
</nav>
)}
</header> </header>
<main className="shell-main">{children}</main> <main className="shell-main">{children}</main>
<BuildFooter />
</AuthContext.Provider> </AuthContext.Provider>
); );
} }
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ManualCheckCapture } from "@/components/ManualCheckCapture";
import { StatementIntake } from "@/components/StatementIntake";
import { useCan } from "@/lib/abilities";
/**
* The daily capture screen (the legacy "Editor"), with two ways in:
*
* - **manual** — key each customer's receipt against one check by hand.
* - **auto** — scan the stack of paper bills and let OCR propose customer and
* amount for every page, which a human still confirms.
*
* Both end in the same place: charges on the customer's ledger, posted against
* one check. They are modes of one screen rather than two menu entries because
* it is one job — staff pick the mode by what's on the desk that morning, a
* stack of bills or a keyboard.
*
* `/estado-cuenta/lote` opens on manual, `/recibos` opens on auto; both render
* this component, so an old bookmark still lands on the right tab.
*/
export type CaptureMode = "manual" | "auto";
const MODE_HINT: Record<CaptureMode, string> = {
manual:
"Captura los recibos de varios clientes contra un mismo cheque y concilia el total antes de guardar.",
auto: "Escanea los recibos del mes y el sistema propone cliente e importe para cada página. Nada se registra sin tu confirmación.",
};
export function Captura({ initialMode = "manual" }: { initialMode?: CaptureMode }) {
const canCapture = useCan("ledger:create");
const canIngest = useCan("statement:ingest");
// Gating is cosmetic (the API enforces every write), but a user who only has
// one of the two abilities should land on the mode they can actually use.
const modes: { key: CaptureMode; label: string }[] = [
...(canCapture ? [{ key: "manual" as const, label: "Captura manual" }] : []),
...(canIngest
? [{ key: "auto" as const, label: "Captura automática (OCR)" }]
: []),
];
const [mode, setMode] = useState<CaptureMode>(
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
);
if (modes.length === 0) {
return (
<div className="state-box state-error">
No tienes permiso para capturar movimientos.
</div>
);
}
return (
<>
<div className="page-head">
<div>
<h1 className="page-title">Captura</h1>
<p className="eyebrow">{MODE_HINT[mode]}</p>
</div>
<Link href="/estado-cuenta" className="btn btn-outline">
Volver a estado de cuenta
</Link>
</div>
{modes.length > 1 && (
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
{modes.map((m) => (
<button
key={m.key}
type="button"
role="tab"
aria-selected={mode === m.key}
className={`seg-btn ${mode === m.key ? "active" : ""}`}
onClick={() => setMode(m.key)}
>
{m.label}
</button>
))}
</div>
)}
{mode === "manual" ? <ManualCheckCapture /> : <StatementIntake />}
</>
);
}
@@ -0,0 +1,114 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { UI_SCALES } from "@/lib/ui-scale";
/**
* Text-size picker. Controlled: AppShell owns the value and handles applying
* and persisting it, because the same setting is edited from two places (the
* appbar popover and the mobile drawer) and reconciled against the account on
* load.
*
* `variant="menu"` is the compact appbar popover; `variant="inline"` is the
* flat row used inside the mobile drawer, where a popover inside a popover
* would be awkward.
*/
export function FontScaleControl({
value,
onChange,
variant = "menu",
}: {
value: number;
onChange: (scale: number) => void;
variant?: "menu" | "inline";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
setOpen(false);
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
function choose(next: number) {
onChange(next);
setOpen(false);
}
const options = UI_SCALES.map((option) => ({
...option,
selected: Math.abs(value - option.value) < 0.001,
}));
if (variant === "inline") {
return (
<div className="scale-inline" role="radiogroup" aria-label="Tamaño de texto">
<span className="appbar-drawer-heading">Tamaño de texto</span>
<div className="scale-inline-options">
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={option.selected}
className={`scale-chip${option.selected ? " active" : ""}`}
style={{ fontSize: `${option.value}em` }}
onClick={() => choose(option.value)}
>
{option.short}
<span className="sr-only"> {option.label}</span>
</button>
))}
</div>
</div>
);
}
return (
<div className="appbar-menu" ref={ref}>
<button
type="button"
className="appbar-scale-trigger"
aria-expanded={open}
aria-haspopup="true"
aria-label="Tamaño de texto"
title="Tamaño de texto"
onClick={() => setOpen((v) => !v)}
>
<span aria-hidden="true">
A<span className="appbar-scale-big">A</span>
</span>
</button>
{open && (
<div className="appbar-dropdown appbar-dropdown-right" role="menu">
{options.map((option) => (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={option.selected}
className={`appbar-dropdown-link scale-option${option.selected ? " active" : ""}`}
onClick={() => choose(option.value)}
>
<span style={{ fontSize: `${option.value}em` }}>{option.short}</span>
<span className="scale-option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,526 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
import type {
BatchCreateInput,
BillingFacets,
ByCheckResponse,
Currency,
LedgerCurrency,
TransactionDomain,
} from "@/lib/types";
/**
* Batch capture by check — the "Editor" screen from the legacy system
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2), and the manual half of the Captura
* screen (see `Captura`).
*
* Staff key many customers' receipts against ONE physical check before cutting
* it, then check that the captured total matches the check's amount. That
* reconciliation is the whole point, so the running total is the most prominent
* thing on the page and an optional "importe del cheque" field turns it into a
* live difference.
*
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
* by it answers every by-check question (see the "Reporte por cheque" report).
*/
const DOMAINS: { key: TransactionDomain; label: string }[] = [
{ key: "UTILITY", label: "Servicios" },
{ key: "INSURANCE", label: "Seguros" },
{ key: "TRUST", label: "Fideicomiso" },
];
interface Line {
/** Local row key — lines have no server identity until the batch posts. */
key: number;
customerId: string;
customerName: string;
amount: string;
reference: string;
period: string;
outstanding: boolean;
}
function blankLine(key: number): Line {
return {
key,
customerId: "",
customerName: "",
amount: "",
reference: "",
period: "",
outstanding: false,
};
}
export function ManualCheckCapture() {
const [facets, setFacets] = useState<BillingFacets | null>(null);
// Check-level fields — shared by every line.
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
const [typeId, setTypeId] = useState("");
const [checkNumber, setCheckNumber] = useState("");
const [transactionDate, setTransactionDate] = useState(
new Date().toISOString().slice(0, 10),
);
/** The physical check's amount, for reconciliation only — never submitted. */
const [checkAmount, setCheckAmount] = useState("");
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
const [nextKey, setNextKey] = useState(4);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
useEffect(() => {
getBillingFacets().then(setFacets).catch(() => setFacets(null));
}, []);
const filled = lines.filter(
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
);
// Charges are captured as positive numbers and signed on submit, matching
// MovementForm — staff type what's on the bill, not a negative.
const total = useMemo(
() =>
filled
.filter((l) => !l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const outstandingTotal = useMemo(
() =>
filled
.filter((l) => l.outstanding)
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
[filled],
);
const checkAmt = Number(checkAmount);
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
const diff = hasCheckAmt ? checkAmt - total : 0;
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
function update(key: number, patch: Partial<Line>) {
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
}
function addLine() {
setLines((ls) => [...ls, blankLine(nextKey)]);
setNextKey((k) => k + 1);
}
function removeLine(key: number) {
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!checkNumber.trim()) {
setError("Indica el número de cheque.");
return;
}
if (filled.length === 0) {
setError("Captura al menos una línea con cliente y monto.");
return;
}
const dupes = filled
.map((l) => l.customerId)
.filter((id, i, arr) => arr.indexOf(id) !== i);
if (dupes.length) {
const names = filled
.filter((l) => dupes.includes(l.customerId))
.map((l) => l.customerName);
if (
!window.confirm(
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
", ",
)}). ¿Continuar?`,
)
)
return;
}
const payload: BatchCreateInput = {
domain,
transactionDate,
checkNumber: checkNumber.trim(),
currency: currency as Currency,
typeId: typeId || undefined,
lines: filled.map((l) => ({
customerId: l.customerId,
// Every line of a check batch is a charge the office paid out.
amount: -Math.abs(Number(l.amount)),
reference: l.reference.trim() || undefined,
period: l.period.trim() || undefined,
outstanding: l.outstanding || undefined,
})),
};
setSaving(true);
setError(null);
try {
await createMovementBatch(payload);
// Re-read through the by-check view so the confirmation shows what's
// actually stored (including anything captured against this check
// earlier), not just what this request sent.
setPosted(await getByCheck(payload.checkNumber));
} catch (e2) {
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
} finally {
setSaving(false);
}
}
function reset() {
setPosted(null);
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
setNextKey((k) => k + 3);
setCheckNumber("");
setCheckAmount("");
}
if (posted) {
return (
<>
<div className="page-head">
<div>
<h2 className="page-title">Lote capturado</h2>
<p className="eyebrow">
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
{posted.count === 1 ? "movimiento" : "movimientos"}
</p>
</div>
<div style={{ display: "flex", gap: 10 }}>
<button type="button" className="btn btn-primary" onClick={reset}>
Capturar otro cheque
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Volver a estado de cuenta
</Link>
</div>
</div>
<div className="filtered-totals" style={{ marginBottom: 16 }}>
{posted.totals.map((t) => (
<div className="filtered-total" key={t.currency}>
<span className="filtered-total-cur">{t.currency}</span>
<span className="filtered-total-net">
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
</span>
<span>{formatNumber(t.count)} movimientos</span>
</div>
))}
{posted.outstandingCount > 0 && (
<div className="filtered-total">
<span>
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
total)
</span>
</div>
)}
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Referencia</th>
<th>Periodo</th>
<th>Estado</th>
<th className="num">Monto</th>
</tr>
</thead>
<tbody>
{posted.items.map((i) => (
<tr key={i.id}>
<td>
<Link
href={`/estado-cuenta/${i.customerId}`}
className="inline-link"
>
{i.customerName}
</Link>
</td>
<td>{i.reference || "—"}</td>
<td>{i.period || "—"}</td>
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
<td className="num">
<span className="tx-amount neg">
{formatMoney(i.amount, i.currency)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="muted" style={{ marginTop: 14 }}>
Para imprimir la conciliación, usa el reporte{" "}
<Link
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
posted.checkNumber,
)}`}
className="inline-link"
>
Reporte por cheque
</Link>
.
</p>
</>
);
}
return (
<>
{error && <div className="state-box state-error">{error}</div>}
<form onSubmit={submit}>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Datos del cheque
</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Número de cheque *</span>
<input
className="input"
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
required
/>
</label>
<label className="field">
<span className="field-label">Fecha *</span>
<input
className="input"
type="date"
required
value={transactionDate}
onChange={(e) => setTransactionDate(e.target.value)}
/>
</label>
<label className="field">
<span className="field-label">Línea de negocio *</span>
<select
className="select"
value={domain}
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
>
{DOMAINS.map((d) => (
<option key={d.key} value={d.key}>
{d.label}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Moneda *</span>
<select
className="select"
value={currency}
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
>
<option value="MXN">Pesos (MXN)</option>
<option value="USD">Dólares (USD)</option>
</select>
</label>
<label className="field">
<span className="field-label">Concepto</span>
<select
className="select"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
>
<option value="">(sin concepto)</option>
{facets?.types.map((t) => (
<option key={t.id} value={t.id}>
{txTypeLabel({ nameEn: t.name })}
</option>
))}
</select>
</label>
<label className="field">
<span className="field-label">Importe del cheque</span>
<input
className="input"
type="number"
step="0.01"
min="0"
value={checkAmount}
onChange={(e) => setCheckAmount(e.target.value)}
placeholder="Para conciliar"
/>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 14,
}}
>
<h2 className="section-title" style={{ margin: 0 }}>
Recibos ({formatNumber(filled.length)})
</h2>
<button type="button" className="btn btn-outline" onClick={addLine}>
Agregar línea
</button>
</div>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th style={{ minWidth: 240 }}>Cliente *</th>
<th style={{ minWidth: 120 }}>Referencia</th>
<th style={{ minWidth: 100 }}>Periodo</th>
<th style={{ minWidth: 110 }} className="num">
Monto *
</th>
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
<th style={{ width: 1 }} />
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
<td>
<CustomerPicker
value={l.customerId}
valueName={l.customerId ? l.customerName : undefined}
onPick={(id, name) =>
update(l.key, { customerId: id, customerName: name })
}
/>
</td>
<td>
<input
className="input"
value={l.reference}
onChange={(e) =>
update(l.key, { reference: e.target.value })
}
/>
</td>
<td>
<input
className="input"
value={l.period}
onChange={(e) => update(l.key, { period: e.target.value })}
placeholder="2026-07"
/>
</td>
<td>
<input
className="input num"
type="number"
step="0.01"
min="0"
value={l.amount}
onChange={(e) => update(l.key, { amount: e.target.value })}
placeholder="0.00"
/>
</td>
<td style={{ textAlign: "center" }}>
<input
type="checkbox"
checked={l.outstanding}
onChange={(e) =>
update(l.key, { outstanding: e.target.checked })
}
aria-label="Sin fondos"
/>
</td>
<td>
<button
type="button"
className="btn btn-ghost"
style={{ padding: "4px 10px", fontSize: 12 }}
onClick={() => removeLine(l.key)}
disabled={lines.length === 1}
>
Quitar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>
Conciliación
</h2>
<div className="filtered-totals">
<div className="filtered-total">
<span className="filtered-total-cur">{currency}</span>
<span className="filtered-total-net">
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
</span>
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
</div>
{outstandingTotal > 0 && (
<div className="filtered-total">
<span>
Sin fondos{" "}
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
(no suma al cheque)
</span>
</div>
)}
{hasCheckAmt && (
<div className="filtered-total">
<span className="filtered-total-net">
{reconciled ? (
<strong className="tx-amount pos">Cuadra con el cheque</strong>
) : (
<>
Diferencia{" "}
<strong className="tx-amount neg">
{formatMoney(String(diff), currency)}
</strong>
</>
)}
</span>
</div>
)}
</div>
</div>
<div className="form-actions">
<button
type="submit"
className="btn btn-primary"
disabled={saving || filled.length === 0}
>
{saving
? "Guardando…"
: `Capturar ${formatNumber(filled.length)} ${
filled.length === 1 ? "recibo" : "recibos"
}`}
</button>
<Link href="/estado-cuenta" className="btn btn-outline">
Cancelar
</Link>
</div>
</form>
</>
);
}
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
getStatementStatus,
listStatementBatches,
uploadStatementBatch,
} from "@/lib/api";
import { useCan } from "@/lib/abilities";
import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels";
import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types";
/**
* Automatic capture — statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2).
*
* Each utility company mails 300+ paper bills a month, one per customer, which
* staff otherwise key in by hand on the manual tab of the same "Captura"
* screen. Here they scan the stack instead, and the machine proposes customer +
* amount for every page; a human still confirms before anything reaches the
* ledger. Same daily job, same ledger path — only the input differs, which is
* why it lives as a mode of Captura rather than a screen of its own.
*
* One batch = one service kind, because the matcher is scoped per kind: a
* water account number and a phone number are compared against different
* columns, and mixing them in one upload is how a bill gets posted to the
* wrong customer.
*/
/** The kinds the parsers actually recognise today. */
const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"];
/** Uploadable, but every page will land in review until a parser learns it. */
const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"];
const STATUS_LABEL: Record<StatementBatchStatus, string> = {
UPLOADED: "Recibido",
PROCESSING: "Procesando…",
READY_FOR_REVIEW: "Listo para revisar",
COMPLETED: "Registrado",
FAILED: "Falló",
};
export function StatementIntake() {
const canIngest = useCan("statement:ingest");
const [batches, setBatches] = useState<StatementBatch[]>([]);
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
const [storageAvailable, setStorageAvailable] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [list, status] = await Promise.all([
listStatementBatches(),
getStatementStatus(),
]);
setBatches(list.items);
setOcrAvailable(status.ocrAvailable);
setStorageAvailable(status.storageAvailable);
setError(null);
} catch (e) {
setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// A batch of 300 pages takes minutes to OCR, so the list refreshes itself
// while anything is still working rather than making staff reload.
const working = batches.some(
(b) => b.status === "PROCESSING" || b.status === "UPLOADED",
);
useEffect(() => {
if (!working) return;
const t = setInterval(() => void load(), 4000);
return () => clearInterval(t);
}, [working, load]);
const ready = ocrAvailable === true && storageAvailable === true;
return (
<div className="stack">
{ocrAvailable === false && (
<div className="state-box state-error">
Este servidor no tiene OCR instalado, así que no se pueden leer recibos
escaneados. Usa la captura manual; el resto del sistema funciona con
normalidad.
</div>
)}
{storageAvailable === false && (
<div className="state-box state-error">
Este servidor no tiene configurado el almacenamiento de documentos, así
que no hay dónde guardar los recibos escaneados. Usa la captura manual
mientras se configura.
</div>
)}
{canIngest && ready && <UploadCard onDone={load} />}
{error && <div className="state-box state-error">{error}</div>}
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Lotes
</h2>
{loading ? (
<div className="state-box">Cargando</div>
) : batches.length === 0 ? (
<div className="state-box">Todavía no hay lotes de recibos.</div>
) : (
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Fecha</th>
<th>Servicio</th>
<th>Referencia</th>
<th>Estado</th>
<th className="num">Páginas</th>
<th>Subido por</th>
<th />
</tr>
</thead>
<tbody>
{batches.map((b) => (
<tr key={b.id}>
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
<td>{serviceKindLabel(b.serviceKind)}</td>
<td>{b.label || "—"}</td>
<td>
<StatusTag status={b.status} />
{b.error && (
<div className="page-sub" style={{ marginTop: 4 }}>
{b.error}
</div>
)}
</td>
<td className="num">{b._count?.documents ?? 0}</td>
<td>{b.uploadedBy?.name ?? "—"}</td>
<td>
<Link className="btn btn-ghost" href={`/recibos/${b.id}`}>
Revisar
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
function StatusTag({ status }: { status: StatementBatchStatus }) {
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
}
function UploadCard({ onDone }: { onDone: () => void }) {
const [files, setFiles] = useState<File[]>([]);
const [serviceKind, setServiceKind] = useState<ServiceKind>("ELECTRIC");
const [label, setLabel] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!files.length) return;
setBusy(true);
setError(null);
try {
await uploadStatementBatch(files, serviceKind, label.trim() || undefined);
setFiles([]);
setLabel("");
onDone();
} catch (e) {
setError((e as Error)?.message ?? "No se pudo subir el lote.");
} finally {
setBusy(false);
}
}
const unsupported = !SUPPORTED.includes(serviceKind);
return (
<section className="card" style={{ padding: 16 }}>
<h2 className="section-title" style={{ marginTop: 0 }}>
Subir recibos escaneados
</h2>
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
<label>
<span className="page-sub">Servicio</span>
<select
className="input"
value={serviceKind}
onChange={(e) => setServiceKind(e.target.value as ServiceKind)}
>
<optgroup label="Con lectura automática">
{SUPPORTED.map((k) => (
<option key={k} value={k}>
{SERVICE_KIND_LABELS[k] ?? k}
</option>
))}
</optgroup>
<optgroup label="Sin lectura automática (revisión manual)">
{OTHER_KINDS.map((k) => (
<option key={k} value={k}>
{SERVICE_KIND_LABELS[k] ?? k}
</option>
))}
</optgroup>
</select>
</label>
<label>
<span className="page-sub">Referencia (opcional)</span>
<input
className="input"
placeholder="ej. CFE julio 2026"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</label>
<label>
<span className="page-sub">Archivos PDF</span>
<input
type="file"
className="input"
accept="application/pdf"
multiple
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
/>
</label>
<button
type="button"
className="btn btn-primary"
disabled={!files.length || busy}
onClick={submit}
>
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
</button>
</div>
{error && (
<div className="state-box state-error" style={{ marginTop: 12 }}>
{error}
</div>
)}
{unsupported && (
<div className="state-box" style={{ marginTop: 12 }}>
Todavía no hay lectura automática para{" "}
{SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará
para revisión manual. Al confirmarlas se guarda el número de cuenta,
así que los recibos del mes siguiente se reconocerán solos.
</div>
)}
<p className="page-sub" style={{ marginTop: 12 }}>
Un lote es de un solo servicio. Cada página del PDF se trata como un
recibo distinto, salvo que el proveedor imprima varias hojas por cliente.
</p>
</section>
);
}
+201 -9
View File
@@ -6,22 +6,34 @@ import type {
BalanceFilter, BalanceFilter,
BalanceListResponse, BalanceListResponse,
BalanceSort, BalanceSort,
BankAccount,
BankCleared, BankCleared,
BankDirection, BankDirection,
BankFacets, BankFacets,
BankInstitution,
BankListResponse, BankListResponse,
BankSort, BankSort,
BankStats, BankStats,
BankSummary, BankSummary,
BatchCreateInput, BatchCreateInput,
ConfirmBatchInput,
ConfirmBatchResult,
BatchCreateResponse, BatchCreateResponse,
BillingFacets, BillingFacets,
BillingStats, BillingStats,
BusinessLine, BusinessLine,
ByCheckResponse, ByCheckResponse,
CreateBankAccountInput,
CreateBankInput,
CreateBankMovementInput, CreateBankMovementInput,
CreateMovementInput, CreateMovementInput,
UpdateBankAccountInput,
ResolveOutstandingInput, ResolveOutstandingInput,
ReviewDocumentInput,
StatementBatch,
StatementBatchDetail,
StatementDocument,
StatementDocumentStatus,
CustomerDetail, CustomerDetail,
CustomerInput, CustomerInput,
CustomerListResponse, CustomerListResponse,
@@ -136,10 +148,30 @@ export function me(): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/me"); return apiFetch<AuthUser>("/auth/me");
} }
/** Persist the caller's own text-size preference on their account. */
export function updateUiScale(uiScale: number): Promise<AuthUser> {
return apiFetch<AuthUser>("/auth/preferences", {
method: "PATCH",
body: JSON.stringify({ uiScale }),
});
}
export function logout(): Promise<{ success: boolean }> { export function logout(): Promise<{ success: boolean }> {
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" }); return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
} }
export interface ServiceVersion {
service: string;
version: string;
gitSha: string;
buildDate: string;
}
/** What the API container reports it is running. Unauthenticated by design. */
export function getApiVersion(): Promise<ServiceVersion> {
return apiFetch<ServiceVersion>("/version");
}
export function getStats(): Promise<CustomerStats> { export function getStats(): Promise<CustomerStats> {
return apiFetch<CustomerStats>("/customers/stats"); return apiFetch<CustomerStats>("/customers/stats");
} }
@@ -601,7 +633,13 @@ export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
/* ------------------------------------------------- Bank register (chequera) */ /* ------------------------------------------------- Bank register (chequera) */
/**
* Every read below is scoped to one chequera. `bankAccountId` is required, not
* defaulted to "all accounts": the office's registers are in different
* currencies, and a combined total would be a figure that never existed.
*/
export interface BankQuery { export interface BankQuery {
bankAccountId: string;
query?: string; query?: string;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@@ -614,7 +652,7 @@ export interface BankQuery {
} }
export function listBankMovements(q: BankQuery): Promise<BankListResponse> { export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
const params = new URLSearchParams(); const params = new URLSearchParams({ bankAccountId: q.bankAccountId });
if (q.query) params.set("query", q.query); if (q.query) params.set("query", q.query);
if (q.page) params.set("page", String(q.page)); if (q.page) params.set("page", String(q.page));
if (q.pageSize) params.set("pageSize", String(q.pageSize)); if (q.pageSize) params.set("pageSize", String(q.pageSize));
@@ -623,20 +661,78 @@ export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
if (q.from) params.set("from", q.from); if (q.from) params.set("from", q.from);
if (q.to) params.set("to", q.to); if (q.to) params.set("to", q.to);
if (q.sort) params.set("sort", q.sort); if (q.sort) params.set("sort", q.sort);
const qs = params.toString(); return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
} }
export function getBankStats(): Promise<BankStats> { export function getBankStats(bankAccountId: string): Promise<BankStats> {
return apiFetch<BankStats>("/bank/stats"); return apiFetch<BankStats>(
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
);
} }
export function getBankFacets(): Promise<BankFacets> { export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
return apiFetch<BankFacets>("/bank/facets"); return apiFetch<BankFacets>(
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
);
} }
export function getBankSummary(year?: number): Promise<BankSummary> { export function getBankSummary(
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`); bankAccountId: string,
year?: number,
): Promise<BankSummary> {
const params = new URLSearchParams({ bankAccountId });
if (year) params.set("year", String(year));
return apiFetch<BankSummary>(`/bank/summary?${params.toString()}`);
}
/* --------------------------------------------- Chequera accounts (catalog) */
/** The account picker's source. Includes closed accounts, which stay readable. */
export function listBankAccounts(): Promise<BankAccount[]> {
return apiFetch<BankAccount[]>("/bank/accounts");
}
export function listBankInstitutions(): Promise<BankInstitution[]> {
return apiFetch<BankInstitution[]>("/bank/banks");
}
export function createBankInstitution(
input: CreateBankInput,
): Promise<BankInstitution> {
return apiFetch<BankInstitution>("/bank/banks", {
method: "POST",
body: JSON.stringify(input),
});
}
export function updateBankInstitution(
id: string,
input: Partial<CreateBankInput>,
): Promise<BankInstitution> {
return apiFetch<BankInstitution>(`/bank/banks/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function createBankAccount(
input: CreateBankAccountInput,
): Promise<unknown> {
return apiFetch("/bank/accounts", {
method: "POST",
body: JSON.stringify(input),
});
}
/** No `currency` — an account's booked movements are denominated in it. */
export function updateBankAccount(
id: string,
input: UpdateBankAccountInput,
): Promise<unknown> {
return apiFetch(`/bank/accounts/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
} }
/** Append a new chequera movement. Booked rows are never edited — fix mistakes /** Append a new chequera movement. Booked rows are never edited — fix mistakes
@@ -806,3 +902,99 @@ export function reportDownloadUrl(
const tail = qs.toString(); const tail = qs.toString();
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`; return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
} }
/* ------------------------------------- Statement OCR intake (recibos) */
/**
* Whether this deployment can ingest scans — automatic capture is hidden
* without it. OCR reads the page, object storage keeps it; both are required.
*/
export function getStatementStatus(): Promise<{
ocrAvailable: boolean;
storageAvailable: boolean;
}> {
return apiFetch("/statements/status");
}
export function listStatementBatches(
page = 1,
pageSize = 25,
): Promise<{
items: StatementBatch[];
total: number;
page: number;
pageSize: number;
pageCount: number;
}> {
return apiFetch(`/statements/batches?page=${page}&pageSize=${pageSize}`);
}
export function getStatementBatch(id: string): Promise<StatementBatchDetail> {
return apiFetch(`/statements/batches/${id}`);
}
export function listStatementDocuments(
batchId: string,
status?: StatementDocumentStatus,
): Promise<StatementDocument[]> {
const q = status ? `?status=${status}` : "";
return apiFetch(`/statements/batches/${batchId}/documents${q}`);
}
/** Multi-file upload — one batch is usually several multi-page scans. */
export async function uploadStatementBatch(
files: File[],
serviceKind: ServiceKind,
label?: string,
): Promise<StatementBatch> {
const body = new FormData();
for (const f of files) body.append("files", f, f.name);
const qs = new URLSearchParams({ serviceKind });
if (label) qs.set("label", label);
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
method: "POST",
credentials: "include",
body,
});
if (!res.ok) {
let message = `Error ${res.status}`;
try {
const b = await res.json();
if (b?.message) message = b.message;
} catch {
/* non-JSON error body */
}
throw new Error(message);
}
return res.json();
}
export function reviewStatementDocument(
id: string,
input: ReviewDocumentInput,
): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
}
export function confirmStatementBatch(
batchId: string,
input: ConfirmBatchInput,
): Promise<ConfirmBatchResult> {
return apiFetch(`/statements/batches/${batchId}/confirm`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** The rendered page image. A plain <img src> — the cookie rides along. */
export function statementPageUrl(documentId: string): string {
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* The web image's own build identity.
*
* Same runtime-injection trick as API_ORIGIN (lib/api.ts): docker/web.Dockerfile
* bakes APP_VERSION / GIT_SHA / BUILD_DATE as ENV, layout.tsx reads them on the
* server per request and paints them into window.__APP_BUILD__. Reading
* process.env directly from a client component would return undefined — Next
* only inlines NEXT_PUBLIC_* into the browser bundle, and baking the version in
* at build time is exactly what we are avoiding elsewhere.
*/
export interface BuildInfo {
version: string;
gitSha: string;
buildDate: string;
}
export const UNKNOWN_BUILD: BuildInfo = {
version: "dev",
gitSha: "unknown",
buildDate: "unknown",
};
/** Server-side read, used by layout.tsx to produce the injected payload. */
export function readBuildInfoFromEnv(): BuildInfo {
return {
version: process.env.APP_VERSION ?? UNKNOWN_BUILD.version,
gitSha: process.env.GIT_SHA ?? UNKNOWN_BUILD.gitSha,
buildDate: process.env.BUILD_DATE ?? UNKNOWN_BUILD.buildDate,
};
}
/** Browser-side read of what layout.tsx injected. */
export function webBuildInfo(): BuildInfo {
if (typeof window === "undefined") return readBuildInfoFromEnv();
const injected = (window as { __APP_BUILD__?: BuildInfo }).__APP_BUILD__;
return injected ?? UNKNOWN_BUILD;
}
+2
View File
@@ -45,6 +45,7 @@ export const SERVICE_KIND_LABELS: Record<string, string> = {
PROPERTY_TAX: "Predial", PROPERTY_TAX: "Predial",
FEDERAL_ZONE: "Zona Federal", FEDERAL_ZONE: "Zona Federal",
ALARM: "Alarma", ALARM: "Alarma",
TELEPHONE: "Teléfono",
OTHER: "Otro", OTHER: "Otro",
}; };
@@ -57,6 +58,7 @@ export const SERVICE_KIND_GLYPH: Record<string, string> = {
WATER: "≈", WATER: "≈",
ELECTRIC: "⚡", ELECTRIC: "⚡",
GAS: "◐", GAS: "◐",
TELEPHONE: "☎",
CABLE: "▤", CABLE: "▤",
PROPERTY_TAX: "⌂", PROPERTY_TAX: "⌂",
FEDERAL_ZONE: "⇲", FEDERAL_ZONE: "⇲",
+145 -4
View File
@@ -19,6 +19,9 @@ export type Ability =
| "ledger:void" | "ledger:void"
| "bank:create" | "bank:create"
| "bank:void" | "bank:void"
| "bank:manage-accounts"
| "statement:ingest"
| "statement:review"
| "lookup:manage" | "lookup:manage"
| "user:manage" | "user:manage"
| "db:manage"; | "db:manage";
@@ -29,6 +32,10 @@ export interface AuthUser {
email: string; email: string;
role: Role; role: Role;
active: boolean; active: boolean;
// Text-size preference, stored per account so it follows the person across
// machines. localStorage still holds a copy, but only as a pre-paint cache —
// this value is the source of truth. See lib/ui-scale.ts.
uiScale: number;
// Resolved server-side from role (abilitiesFor in the API); the UI only ever // Resolved server-side from role (abilitiesFor in the API); the UI only ever
// reads this map, never re-derives the rules. Server still enforces. // reads this map, never re-derives the rules. Server still enforces.
abilities: Record<Ability, boolean>; abilities: Record<Ability, boolean>;
@@ -129,6 +136,7 @@ export type ServiceKind =
| "PROPERTY_TAX" | "PROPERTY_TAX"
| "FEDERAL_ZONE" | "FEDERAL_ZONE"
| "ALARM" | "ALARM"
| "TELEPHONE"
| "OTHER" | "OTHER"
| string; | string;
@@ -997,12 +1005,57 @@ export interface CustomerInput {
/* ------------------------------------------------- Bank register (chequera) */ /* ------------------------------------------------- Bank register (chequera) */
/** /**
* The office's own checking account. Single-currency (MXN) and with no customer * The office's own checking accounts — one register per chequera, no customer
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and * link. See `bank.service.ts`. Positive is a deposit, negative a payment, and
* exactly zero a cancelled cheque. * exactly zero a cancelled cheque. Every figure below belongs to exactly one
* `BankAccount` and is denominated in that account's currency; two accounts'
* figures are never combined.
*/ */
export type BankDirection = "income" | "expense" | "void"; export type BankDirection = "income" | "expense" | "void";
/** A bank the office holds chequeras at. */
export interface BankInstitution {
id: string;
name: string;
/** "MX" | "US" — informational. */
country: string | null;
}
/** One chequera. Its `currency` is what every figure on the page is read in. */
export interface BankAccount {
id: string;
label: string;
currency: Currency;
/** Soft hint about which line of business it serves; never enforced. */
businessLine: TransactionDomain | null;
/** Closed accounts stay readable but take no new movements. */
active: boolean;
bankId: string;
bankName: string;
bankCountry: string | null;
}
export interface CreateBankInput {
name: string;
country?: string;
}
export interface CreateBankAccountInput {
bankId: string;
label: string;
/** Fixed at creation — an account's booked history is denominated in it. */
currency: Currency;
businessLine?: TransactionDomain;
active?: boolean;
}
export interface UpdateBankAccountInput {
bankId?: string;
label?: string;
businessLine?: TransactionDomain;
active?: boolean;
}
export type BankCleared = "cleared" | "pending"; export type BankCleared = "cleared" | "pending";
export type BankSort = export type BankSort =
@@ -1034,8 +1087,10 @@ export interface BankListItem {
} }
/** Payload for POST /bank — a new chequera movement. Sign convention: positive /** Payload for POST /bank — a new chequera movement. Sign convention: positive
* = ingreso, negative = egreso. MXN only. */ * = ingreso, negative = egreso. The currency comes from the account. */
export interface CreateBankMovementInput { export interface CreateBankMovementInput {
/** Which chequera it lands in. Required. */
bankAccountId: string;
amount: number; amount: number;
transactionDate: string; transactionDate: string;
concept?: string; concept?: string;
@@ -1152,3 +1207,89 @@ export interface ReportRunResult {
export interface ReportCatalog { export interface ReportCatalog {
items: ReportDef[]; items: ReportDef[];
} }
/* ------------------------------------- Statement OCR intake (recibos) */
export type StatementBatchStatus =
| "UPLOADED"
| "PROCESSING"
| "READY_FOR_REVIEW"
| "COMPLETED"
| "FAILED";
export type StatementDocumentStatus =
| "PENDING_OCR"
| "OCR_FAILED"
| "NEEDS_REVIEW"
| "MATCHED"
| "CONFIRMED"
| "POSTED"
| "REJECTED";
export interface StatementBatch {
id: string;
serviceKind: ServiceKind;
status: StatementBatchStatus;
label: string | null;
fileCount: number;
error: string | null;
createdAt: string;
completedAt: string | null;
uploadedBy?: { name: string };
_count?: { documents: number };
}
export interface StatementBatchDetail extends StatementBatch {
byStatus: Partial<Record<StatementDocumentStatus, number>>;
/** Sum of the amounts still awaiting posting. */
pendingTotal: string;
}
export interface StatementDocument {
id: string;
pageNumber: number;
status: StatementDocumentStatus;
provider: string | null;
ocrConfidence: string | null;
extractedAccountRef: string | null;
extractedAmount: string | null;
extractedPeriod: string | null;
extractedDueDate: string | null;
extractedCadastralKey: string | null;
matchNote: string | null;
matchedCustomer: { id: string; name: string } | null;
matchedPropertyService: {
id: string;
kind: ServiceKind;
accountNumber: string | null;
meterNumber: string | null;
property: { id: string; addressLine1: string | null };
} | null;
postedTransactionId: string | null;
}
export interface ReviewDocumentInput {
accountRef?: string;
amount?: number;
period?: string;
dueDate?: string;
matchedPropertyServiceId?: string;
matchedCustomerId?: string;
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
}
/** Check-level fields shared by every line posted from a batch. */
export interface ConfirmBatchInput {
checkNumber: string;
transactionDate: string;
currency?: Currency;
typeId?: string;
outstanding?: boolean;
includeReviewed?: boolean;
}
export interface ConfirmBatchResult {
posted: number;
total: string;
checkNumber: string;
}
+54
View File
@@ -0,0 +1,54 @@
// App-wide text size. Every font-size *and* every spacing value in globals.css
// is in rem and the root size is `calc(100% * var(--ui-scale))`, so writing one
// variable on <html> rescales the entire UI — no per-component work, and the
// browser's own base font size still applies underneath.
//
// The account is the source of truth (User.uiScale, served on /auth/me).
// localStorage holds a copy purely so the pre-hydration script in
// app/layout.tsx can paint at the right size before the session is known;
// AppShell reconciles the two once /auth/me answers. Keep UI_SCALE_KEY and the
// bounds in sync with that script and with the API's UpdatePreferencesDto.
export const UI_SCALE_KEY = "jc.ui-scale";
export const DEFAULT_UI_SCALE = 1;
export const MIN_UI_SCALE = 0.9;
export const MAX_UI_SCALE = 1.5;
export const UI_SCALES: { value: number; label: string; short: string }[] = [
{ value: 0.9, label: "Compacto", short: "A" },
{ value: 1, label: "Normal", short: "A" },
{ value: 1.15, label: "Grande", short: "A" },
{ value: 1.3, label: "Muy grande", short: "A" },
{ value: 1.5, label: "Máximo", short: "A" },
];
/** Clamp to the supported range; anything unparseable falls back to default. */
export function normalizeUiScale(value: unknown): number {
const n = typeof value === "number" ? value : Number.parseFloat(String(value));
if (!Number.isFinite(n)) return DEFAULT_UI_SCALE;
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, n));
}
export function readUiScale(): number {
if (typeof window === "undefined") return DEFAULT_UI_SCALE;
try {
const raw = window.localStorage.getItem(UI_SCALE_KEY);
return raw === null ? DEFAULT_UI_SCALE : normalizeUiScale(raw);
} catch {
// Private mode / storage disabled — the default is still usable.
return DEFAULT_UI_SCALE;
}
}
export function applyUiScale(scale: number): void {
if (typeof document === "undefined") return;
document.documentElement.style.setProperty("--ui-scale", String(scale));
}
export function saveUiScale(scale: number): void {
try {
window.localStorage.setItem(UI_SCALE_KEY, String(scale));
} catch {
/* ignore — the setting just won't survive a reload */
}
}
@@ -0,0 +1,126 @@
# NestJS API + Next.js web on galactus (standalone Docker, Portainer endpoint 3).
#
# Standalone port of deploy/jorgecuadros-app.stack.yml — see the header of
# deploy/galactus/jorgecuadros-db.compose.yml for the Swarm keys plain compose
# silently ignores. The one that matters most here: without
# `restart: unless-stopped` neither service returns after a host reboot.
#
# Cross-stack traffic still goes over the HOST, not service DNS. db and minio
# are separate Portainer stacks, so they are on separate compose networks and
# their service names do not resolve from here. DATABASE_URL / S3_ENDPOINT must
# name galactus's own address and the published port — exactly as on cubex
# today. Do not "simplify" them to `mysql:3306`.
#
# ...which means these containers have to resolve galactus's MagicDNS name, and
# by default they CANNOT. The host runs systemd-resolved, whose 127.0.0.53 stub
# is unreachable from a container, so Docker falls back to the upstream resolver
# in /run/systemd/resolve/resolv.conf — the LAN router, which knows nothing
# about the tailnet. Routing to 100.x works fine; only the lookup fails, and the
# API dies with Prisma P1001 "can't reach database server". Pointing the
# containers at Tailscale's own resolver fixes it. 100.100.100.100 is Tailscale's
# fixed anycast MagicDNS address (identical on every tailnet); the search domain
# is this tailnet's suffix.
#
# The web image is NOT URL-baked: the browser's API origin is injected at
# runtime from API_ORIGIN (apps/web/src/app/layout.tsx), so the same image works
# for any deployment. APP_VERSION / GIT_SHA / BUILD_DATE come baked in from
# build.yml and are surfaced at GET /version (api) and in the web footer.
#
# Keep in sync with deploy/jorgecuadros-app.stack.yml when either changes.
services:
api:
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
restart: unless-stopped
# Stable handle for deploy/scripts/pre-migrate-backup.sh, which finds this
# container by label to run mysqldump into the backup volume. A label
# survives stack renames; the compose service name does not.
labels:
io.jorgecuadros.role: "api"
dns:
# MagicDNS first, then a public resolver. Listing ONLY 100.100.100.100
# costs the container public name resolution — apk/npm/any outbound
# hostname stops resolving — because MagicDNS does not forward to an
# upstream unless the tailnet is configured with global nameservers.
- ${TAILSCALE_DNS:-100.100.100.100}
- ${FALLBACK_DNS:-1.1.1.1}
dns_search:
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
# This deployment is HTTP, so a Secure session cookie would never be sent
# and login would silently never establish a session (express-session
# declines to emit a Secure cookie over a plain connection). Acceptable
# here ONLY because galactus is reachable exclusively over Tailscale, so
# WireGuard already encrypts the wire. Set this back to "true" the moment
# the app is served over TLS or exposed off-tailnet.
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set}
PORT: "3001"
INGEST_DIR: /data/ingest
BACKUP_DIR: /data/backups
MIGRATION_ENV: prod
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
# application user: --single-transaction needs the global RELOAD privilege
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
# and re-import fails without this. Host/port/database still come from
# DATABASE_URL — this only changes who logs in. See opsConn() in
# apps/api/src/ops/ops.service.ts.
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
ports:
- "${API_PORT:-3001}:3001"
volumes:
# Uploaded Access files and DB backups. Named, so they survive every
# redeploy — and so the pre-migrate dump the deploy takes is the same
# file the "Operaciones" restore screen lists.
- ingest_data:/data/ingest
- backup_data:/data/backups
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
web:
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
restart: unless-stopped
labels:
io.jorgecuadros.role: "web"
# Next server-side rendering can call the API by API_ORIGIN, which is the
# same MagicDNS name — so the web container needs the resolver too.
dns:
# MagicDNS first, then a public resolver. Listing ONLY 100.100.100.100
# costs the container public name resolution — apk/npm/any outbound
# hostname stops resolving — because MagicDNS does not forward to an
# upstream unless the tailnet is configured with global nameservers.
- ${TAILSCALE_DNS:-100.100.100.100}
- ${FALLBACK_DNS:-1.1.1.1}
dns_search:
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
environment:
# Public API URL the browser calls (injected at runtime, see layout.tsx).
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
ports:
- "${WEB_PORT:-3000}:3000"
depends_on:
# Unlike Swarm — which ignores depends_on entirely — plain compose honours
# this, so web waits for the API to pass its healthcheck.
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ >/dev/null 2>&1 || exit 1"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
volumes:
ingest_data:
backup_data:
@@ -0,0 +1,64 @@
# MySQL for the Jorge Cuadros platform on galactus — the PROD source of truth.
#
# galactus is STANDALONE Docker (Portainer endpoint 3, `swarm: inactive`), not
# the 3-node Swarm on cubex. deploy/jorgecuadros-db.stack.yml is the Swarm
# version of this file; the deltas are called out below because plain compose
# SILENTLY IGNORES the Swarm keys rather than erroring on them:
#
# 1. `deploy.restart_policy` is ignored -> `restart: unless-stopped` instead.
# Without this MySQL does not come back after a host reboot. This is the
# single highest-risk difference.
# 2. `deploy.placement.constraints` is meaningless on one host — dropped,
# along with its `docker node update --label-add jorgecuadros_db=true`
# prerequisite.
# 3. `deploy.replicas` / `update_config` are ignored — dropped.
# 4. `ports: {mode: ingress}` long syntax is Swarm-only -> short syntax.
# 5. Named volumes stay exactly as they were: the node-pinning hazard that
# motivated them was purely a Swarm problem, and Portainer still namespaces
# the volume by stack name.
#
# This node is the REPLICATION MASTER for the whole topology. Every other MySQL
# is a replica of it. server-id must be unique across the topology (prod=1,
# cubex dev=11); a duplicate silently breaks replication. binlog + GTID are on
# from first boot so a replica can attach with SOURCE_AUTO_POSITION=1 and no
# file/position bookkeeping.
#
# Keep in sync with deploy/jorgecuadros-db.stack.yml when either changes.
services:
mysql:
image: mysql:8.4
restart: unless-stopped
command:
# (caching_sha2_password is already the default in 8.4; the old
# --default-authentication-plugin flag was REMOVED in 8.4 and aborts boot.)
- --server-id=${MYSQL_SERVER_ID:-1}
- --log-bin=mysql-bin
- --binlog-format=ROW
- --gtid-mode=ON
- --enforce-gtid-consistency=ON
# A replica offline longer than this needs a full re-seed, because the
# binlogs it still needs are gone. The 8.4 default is 30 days; raise it
# here rather than discovering the gap during an outage.
- --binlog-expire-logs-seconds=${MYSQL_BINLOG_EXPIRE_SECONDS:-5184000}
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE:-jorgecuadros}
MYSQL_USER: ${MYSQL_USER:-jorgecuadros}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
ports:
# Standalone: binds directly on the host. Reachable at
# <galactus>:${MYSQL_PORT}. Replicas connect here — see
# docs/DEPLOY_AND_MIGRATIONS.md on NOT exposing raw 3306 to the internet.
- "${MYSQL_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
volumes:
mysql_data:
@@ -0,0 +1,32 @@
# MinIO object storage on galactus (standalone Docker, Portainer endpoint 3).
#
# Holds the document blobs extracted from the Access LONGBINARY columns; MySQL
# keeps only the storageKey pointer. Standalone port of
# deploy/jorgecuadros-minio.stack.yml — see the header of
# deploy/galactus/jorgecuadros-db.compose.yml for the full list of Swarm keys
# that plain compose silently ignores.
#
# Keep in sync with deploy/jorgecuadros-minio.stack.yml when either changes.
services:
minio:
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD-SHELL", "mc ready local || curl -f http://localhost:9000/minio/health/live || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
volumes:
minio_data:
+15
View File
@@ -27,6 +27,11 @@ version: "3.8"
services: services:
api: api:
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest} image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
# Container label (not `deploy.labels`, which labels the swarm SERVICE).
# deploy/scripts/pre-migrate-backup.mjs finds the container by this label to
# run its pre-migrate mysqldump into the backup volume.
labels:
io.jorgecuadros.role: "api"
environment: environment:
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set} DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
@@ -36,6 +41,14 @@ services:
INGEST_DIR: /data/ingest INGEST_DIR: /data/ingest
BACKUP_DIR: /data/backups BACKUP_DIR: /data/backups
MIGRATION_ENV: prod MIGRATION_ENV: prod
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
# application user: --single-transaction needs the global RELOAD privilege
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
# and re-import fails without this. Host/port/database still come from
# DATABASE_URL — this only changes who logs in. See opsConn() in
# apps/api/src/ops/ops.service.ts.
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
# Object storage — internal endpoint the API (server-side) uses to reach # Object storage — internal endpoint the API (server-side) uses to reach
# the minio stack. Not browser-facing (downloads proxy through the API). # the minio stack. Not browser-facing (downloads proxy through the API).
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set} S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
@@ -68,6 +81,8 @@ services:
web: web:
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest} image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
labels:
io.jorgecuadros.role: "web"
environment: environment:
# Public API URL the browser calls (injected at runtime, see layout.tsx). # Public API URL the browser calls (injected at runtime, see layout.tsx).
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set} API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env node
/**
* Take a mysqldump immediately before a deploy runs `prisma migrate deploy`.
*
* The dump runs in a DEDICATED, throwaway container built from the MySQL image,
* with the API's backup volume mounted — not inside the API container. Three
* reasons, each learned the hard way:
*
* 1. Deadlock. Dumping inside the API container makes the backup depend on
* whatever toolchain that image happens to carry. When the image shipped a
* MySQL client that could not authenticate, the backup failed, which blocked
* the very deploy that would have replaced the broken image. The backup must
* not depend on the thing being deployed.
* 2. The right client. Alpine's `mysql-client` is MariaDB's and cannot perform
* caching_sha2_password (MySQL 8.4's default auth). The official MySQL image
* obviously can.
* 3. Diagnosability. A container's logs can simply be read, whereas a detached
* exec reports nothing but an exit code.
*
* The file still lands in the API's BACKUP_DIR volume, because the only restore
* path this platform has is the "Operaciones" admin screen, which lists whatever
* `*.sql.gz` sits there (apps/api/src/ops/ops.service.ts).
*
* It must run BEFORE the app stack is re-applied, while the old container is up
* — that container is how the backup volume's name is discovered.
*
* Required env:
* PORTAINER_URL https://<host>:9443
* PORTAINER_API_KEY Portainer access token
* PORTAINER_ENDPOINT_ID numeric endpoint id (galactus = 3)
* DATABASE_URL mysql://user:pass@host:port/db — host/port/db only
* MYSQL_ROOT_PASSWORD the dump runs as root, see below
* BACKUP_TAG label for the filename, e.g. the deployed tag
* Optional env:
* ALLOW_MISSING_CONTAINER=true exit 0 when no API container exists yet
* BACKUP_VOLUME override the auto-discovered volume name
* DUMP_IMAGE default mysql:8.4
* API_CONTAINER_LABEL default io.jorgecuadros.role=api
* TAILSCALE_DNS / FALLBACK_DNS / TAILNET_SUFFIX
* EXEC_TIMEOUT_SECONDS default 1800
*
* Why root: mysqldump --single-transaction issues FLUSH TABLES, which needs the
* global RELOAD (or FLUSH_TABLES) privilege. The application user is granted
* only ALL ON `<db>`.* by the MySQL image and deliberately has no global rights,
* so it cannot take a consistent dump. Backups are an administrative operation;
* elevating the app's own runtime user instead would be the worse trade.
*
* TLS: Portainer here is self-signed; the caller sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
*/
function required(name) {
const v = process.env[name];
if (!v) {
console.error(`missing required env: ${name}`);
process.exit(1);
}
return v;
}
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const DATABASE_URL = required("DATABASE_URL");
const ROOT_PASSWORD = required("MYSQL_ROOT_PASSWORD");
const BACKUP_TAG = required("BACKUP_TAG");
const CONTAINER_LABEL =
process.env.API_CONTAINER_LABEL ?? "io.jorgecuadros.role=api";
const ALLOW_MISSING = process.env.ALLOW_MISSING_CONTAINER === "true";
const DUMP_IMAGE = process.env.DUMP_IMAGE ?? "mysql:8.4";
const DNS = [
process.env.TAILSCALE_DNS ?? "100.100.100.100",
process.env.FALLBACK_DNS ?? "1.1.1.1",
];
const DNS_SEARCH = [process.env.TAILNET_SUFFIX ?? "tail01aa2.ts.net"];
const TIMEOUT_MS = Number(process.env.EXEC_TIMEOUT_SECONDS ?? 1800) * 1000;
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
async function docker(path, init = {}) {
const res = await fetch(`${DOCKER}${path}`, {
...init,
headers: {
"X-API-Key": API_KEY,
...(init.body ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
});
const text = await res.text();
if (!res.ok) {
throw new Error(`docker ${path} -> ${res.status} ${text.slice(0, 400)}`);
}
return text ? JSON.parse(text) : null;
}
/** Single-quote for `sh -c`, the same discipline ops.service.ts uses. */
function shq(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function parseDbUrl(raw) {
const u = new URL(raw);
return {
host: u.hostname,
port: u.port || "3306",
database: u.pathname.replace(/^\//, ""),
};
}
/** Matches ops.service.ts's own naming: ISO, colons and dots flattened. */
function timestamp() {
return new Date()
.toISOString()
.replace(/[:.]/g, "-")
.replace("T", "_")
.slice(0, 19);
}
/**
* ops.service.ts refuses to restore any name outside this character set, so a
* file written with, say, a `/` in the tag would be permanently unrestorable
* through the UI. Sanitise before writing, not after.
*/
function safeTag(tag) {
return tag.replace(/[^A-Za-z0-9._-]/g, "-");
}
async function findApiContainer() {
const [key, value] = CONTAINER_LABEL.split("=");
const filters = encodeURIComponent(
JSON.stringify({ label: [`${key}=${value}`], status: ["running"] }),
);
const list = await docker(`/containers/json?filters=${filters}`);
return list.length ? list[0] : null;
}
/** The named volume the API mounts at /data/backups — where restores look. */
function backupVolumeOf(container) {
const mount = (container.Mounts ?? []).find(
(m) => m.Destination === "/data/backups",
);
return mount?.Name ?? null;
}
/**
* Ensure the dump image is present. A `scope: app` deploy never touches the db
* stack, so a host can legitimately be missing it — and container/create fails
* with a bare 404 that reads like a Portainer problem rather than a missing
* image. The image is public, so no registry auth is involved.
*/
async function ensureDumpImage() {
const [repo, tag = "latest"] = DUMP_IMAGE.split(":");
const existing = await docker(`/images/${encodeURIComponent(DUMP_IMAGE)}/json`)
.then(() => true)
.catch(() => false);
if (existing) return;
console.log(`pulling ${DUMP_IMAGE} (not present on the host)...`);
const res = await fetch(
`${DOCKER}/images/create?fromImage=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`,
{ method: "POST", headers: { "X-API-Key": API_KEY } },
);
const body = await res.text();
if (!res.ok) {
throw new Error(`pull ${DUMP_IMAGE} -> HTTP ${res.status} ${body.slice(0, 300)}`);
}
for (const line of body.split("\n").filter((l) => l.trim())) {
try {
const obj = JSON.parse(line);
if (obj.error) throw new Error(`pull ${DUMP_IMAGE} failed: ${obj.error}`);
} catch (e) {
if (e.message.startsWith("pull ")) throw e;
}
}
}
async function runDumpContainer(cmd, env) {
await ensureDumpImage();
const created = await docker(`/containers/create`, {
method: "POST",
body: JSON.stringify({
Image: DUMP_IMAGE,
Entrypoint: ["sh", "-c"],
Cmd: [cmd],
Env: env,
HostConfig: {
AutoRemove: false, // we read the logs before removing it ourselves
Binds: [`${BACKUP_VOLUME}:/data/backups`],
Dns: DNS,
DnsSearch: DNS_SEARCH,
},
}),
});
const id = created.Id;
try {
await docker(`/containers/${id}/start`, { method: "POST" });
const deadline = Date.now() + TIMEOUT_MS;
for (;;) {
const info = await docker(`/containers/${id}/json`);
if (!info.State.Running) {
const logs = await fetch(
`${DOCKER}/containers/${id}/logs?stdout=true&stderr=true&tail=40`,
{ headers: { "X-API-Key": API_KEY } },
).then((r) => r.text());
// Strip Docker's 8-byte stream framing and any stray control bytes.
const clean = logs
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
.trim();
return { code: info.State.ExitCode ?? 1, logs: clean };
}
if (Date.now() > deadline) {
throw new Error(`dump timed out after ${TIMEOUT_MS / 1000}s`);
}
await new Promise((r) => setTimeout(r, 3000));
}
} finally {
await docker(`/containers/${id}?force=true`, { method: "DELETE" }).catch(
() => {},
);
}
}
let BACKUP_VOLUME = process.env.BACKUP_VOLUME ?? null;
async function main() {
const container = await findApiContainer();
if (!container) {
const message = `no running container matching label ${CONTAINER_LABEL}`;
if (ALLOW_MISSING) {
console.warn(`skipping pre-migrate backup: ${message}`);
return;
}
throw new Error(
`${message} — pass bootstrap=true only if this is the first deploy and ` +
`there is genuinely no data to lose`,
);
}
BACKUP_VOLUME = BACKUP_VOLUME ?? backupVolumeOf(container);
if (!BACKUP_VOLUME) {
throw new Error(
"could not determine the backup volume from the API container's mounts; " +
"set BACKUP_VOLUME explicitly",
);
}
const conn = parseDbUrl(DATABASE_URL);
const file = `pre-migrate-${safeTag(BACKUP_TAG)}-${timestamp()}.sql.gz`;
const out = `/data/backups/${file}`;
console.log(`database : ${conn.host}:${conn.port}/${conn.database}`);
console.log(`volume : ${BACKUP_VOLUME}`);
console.log(`image : ${DUMP_IMAGE}`);
console.log(`writing : ${out}`);
// --set-gtid-purged=OFF because this server is the replication SOURCE with
// GTID on. Without it the dump embeds SET @@GLOBAL.GTID_PURGED, which makes
// the file unrestorable onto the very server it came from.
//
// pipefail is essential: without it the exit status is gzip's, so a dump that
// failed on the first statement still produces a small, perfectly valid .gz —
// a "successful" backup containing nothing.
//
// The table count is asserted for the same reason: valid gzip is not evidence
// of a usable dump. It is echoed so the log records how much was captured.
//
// A failed attempt deletes its own output. Otherwise every failure leaves a
// truncated .sql.gz sitting in the volume, and the Operaciones restore screen
// lists it as a perfectly ordinary restore point.
const dump =
`set -o pipefail; ` +
`( mysqldump --host=${conn.host} --port=${conn.port} --user=root ` +
`--single-transaction --routines --triggers --no-tablespaces ` +
`--set-gtid-purged=OFF ${shq(conn.database)} | gzip -c > ${shq(out)} && ` +
`gzip -t ${shq(out)} && ` +
`TABLES=$(gunzip -c ${shq(out)} | grep -c 'CREATE TABLE') && ` +
`echo "tables captured: $TABLES" && ` +
`[ "$TABLES" -ge 1 ] ); ` +
`rc=$?; ` +
`if [ $rc -ne 0 ]; then rm -f ${shq(out)}; ` +
`echo "removed incomplete backup ${file}"; fi; ` +
`exit $rc`;
const { code, logs } = await runDumpContainer(dump, [
// Password via MYSQL_PWD, never argv — argv is readable through `ps`.
`MYSQL_PWD=${ROOT_PASSWORD}`,
]);
if (logs) console.log(logs);
if (code !== 0) {
throw new Error(
`dump failed (exit ${code}) — refusing to migrate. See the output above.`,
);
}
console.log(`ok: ${file} written and verified in ${BACKUP_VOLUME}`);
}
main().catch((err) => {
console.error(`pre-migrate backup FAILED: ${err.message}`);
process.exit(1);
});
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env node
/**
* Pull the api + web images onto the target host before the stack is applied.
*
* This exists because the deploy action's `pull: true` does NOT reliably
* refresh an already-cached tag on a standalone endpoint. Observed on galactus
* 2026-07-30: the registry held web:latest built from 3ff56e6, the host still
* had a web:latest cached from an earlier commit, the deploy reported success,
* and the running container served the OLD build. A moving tag like `latest`
* makes this silent — the stack file names the same string either way, so
* nothing downstream notices.
*
* Pulling explicitly, and failing the deploy if a pull fails, makes "the image
* the host runs" a thing the workflow controls rather than hopes for.
*
* Required env:
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
* REGISTRY, REGISTRY_USERNAME, REGISTRY_PASSWORD
* IMAGES comma-separated repositories, e.g. "owner/api,owner/web"
* TAG the tag to pull
*
* TLS: Portainer here is self-signed; the caller sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
*/
function required(name) {
const v = process.env[name];
if (!v) {
console.error(`missing required env: ${name}`);
process.exit(1);
}
return v;
}
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const REGISTRY = required("REGISTRY");
const USERNAME = required("REGISTRY_USERNAME");
const PASSWORD = required("REGISTRY_PASSWORD");
const IMAGES = required("IMAGES").split(",").map((s) => s.trim()).filter(Boolean);
const TAG = required("TAG");
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
// Docker wants the credentials as base64url'd JSON in a header. Node's
// "base64url" encoding omits the `=` padding, which Portainer's Go decoder
// rejects outright ("Illegal base64 data at input byte N"), so build the
// URL-safe alphabet by hand and KEEP the padding.
const REGISTRY_AUTH = Buffer.from(
JSON.stringify({
username: USERNAME,
password: PASSWORD,
serveraddress: REGISTRY,
}),
)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_");
async function pull(repository) {
const image = `${REGISTRY}/${repository}`;
const url =
`${DOCKER}/images/create` +
`?fromImage=${encodeURIComponent(image)}&tag=${encodeURIComponent(TAG)}`;
const res = await fetch(url, {
method: "POST",
headers: { "X-API-Key": API_KEY, "X-Registry-Auth": REGISTRY_AUTH },
});
const body = await res.text();
if (!res.ok) {
throw new Error(`pull ${image}:${TAG} -> HTTP ${res.status} ${body.slice(0, 300)}`);
}
// The endpoint streams newline-delimited JSON and answers 200 even when the
// pull itself failed — the failure only shows up as an {"error": ...} object
// in the stream, so the status code alone proves nothing.
const lines = body.split("\n").filter((l) => l.trim());
for (const line of lines) {
let obj;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj.error) {
throw new Error(`pull ${image}:${TAG} failed: ${obj.error}`);
}
}
const last = lines.length ? JSON.parse(lines[lines.length - 1]) : {};
console.log(`${image}:${TAG}${last.status ?? "pulled"}`);
}
async function main() {
for (const repository of IMAGES) {
await pull(repository);
}
console.log("all images pulled");
}
main().catch((err) => {
console.error(`image pull FAILED: ${err.message}`);
process.exit(1);
});
+43 -2
View File
@@ -8,7 +8,9 @@ RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
FROM base AS deps FROM base AS deps
# argon2's native addon has no musl prebuild -> compiles from source here. # argon2's native addon has no musl prebuild -> compiles from source here.
RUN apk add --no-cache python3 make g++ # openssl so `prisma generate` in the build stage sees the same platform the
# runtime stage does (see the binaryTargets note in schema.prisma).
RUN apk add --no-cache python3 make g++ openssl
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/package.json COPY apps/api/package.json apps/api/package.json
COPY apps/web/package.json apps/web/package.json COPY apps/web/package.json apps/web/package.json
@@ -32,7 +34,29 @@ ENV NODE_ENV=production
# (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools) # (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools)
# from inside the API container. Build deps are installed in a throwaway virtual # from inside the API container. Build deps are installed in a throwaway virtual
# package so pandas/pyarrow build on musl, then dropped from the final layer. # package so pandas/pyarrow build on musl, then dropped from the final layer.
RUN apk add --no-cache python3 mdbtools mysql-client \ # openssl is NOT optional: Prisma's query engine resolves its binary target at
# runtime (linux-musl-openssl-3.0.x) and aborts with "Please manually install
# OpenSSL" without it. Node bundles its own OpenSSL, so nothing else in this
# image pulls the system package in.
# mariadb-connector-c is REQUIRED, not incidental. Alpine's `mysql-client` is
# MariaDB's client, and it ships with an EMPTY /usr/lib/mariadb/plugin — so it
# cannot perform caching_sha2_password, which is MySQL 8.4's default and
# effectively only auth method. Without this package every mysqldump/mysql call
# from the container dies with:
# ERROR 1045: Plugin caching_sha2_password could not be loaded
# That breaks the pre-migrate deploy backup AND the whole "Operaciones" admin
# panel (backup, restore, sync, re-import all shell out to these binaries).
#
# tesseract-ocr + tesseract-ocr-data-spa + poppler-utils drive the statement
# OCR intake (RECEIPT_CAPTURE_SPEC §2): poppler's `pdftoppm` rasterises each
# scanned page and tesseract reads it, with the Spanish traineddata for the
# accented labels on CFE/CESPT/Telnor bills. These are external binaries rather
# than a native npm addon so the pnpm workspace stays free of a compiled
# dependency. If they are absent the API still boots — the statements module
# reports itself unavailable and only that feature is disabled — but statement
# ingest is the point of shipping them.
RUN apk add --no-cache python3 mdbtools mysql-client mariadb-connector-c openssl \
tesseract-ocr tesseract-ocr-data-spa poppler-utils \
&& apk add --no-cache --virtual .pybuild python3-dev build-base \ && apk add --no-cache --virtual .pybuild python3-dev build-base \
&& rm -rf /var/cache/apk/* && rm -rf /var/cache/apk/*
@@ -40,6 +64,23 @@ COPY --from=build /repo/node_modules node_modules
COPY --from=build /repo/packages/database packages/database COPY --from=build /repo/packages/database packages/database
COPY --from=build /repo/apps/api/dist apps/api/dist COPY --from=build /repo/apps/api/dist apps/api/dist
COPY --from=build /repo/apps/api/package.json apps/api/package.json COPY --from=build /repo/apps/api/package.json apps/api/package.json
# Operational scripts, run on demand — never automatically. seed-user.mjs is the
# only way to create the first sign-in account on a fresh database, and without
# it in the image that had to be done from a developer's machine against a
# production DATABASE_URL. Run it with:
# docker exec <api> node apps/api/scripts/seed-user.mjs
# honouring SEED_EMAIL / SEED_PASSWORD / SEED_NAME. It upserts, so re-running is
# safe — but note it RESETS the password of an existing account.
COPY --from=build /repo/apps/api/scripts apps/api/scripts
# node-linker=hoisted flattens EXTERNAL deps into /repo/node_modules, but the
# workspace dependency is still linked per-package:
# apps/api/node_modules/@jorgecuadros/database -> ../../../../packages/database
# Copying only /repo/node_modules therefore drops it and the API dies at boot
# with "Cannot find module '@jorgecuadros/database'". Copy just the scope dir —
# the rest of apps/api/node_modules is devDependencies (typescript) we don't
# want in the runtime layer. The relative link resolves because packages/database
# is copied to the same place above.
COPY --from=build /repo/apps/api/node_modules/@jorgecuadros apps/api/node_modules/@jorgecuadros
# Migration scripts + their own Python venv (ops.service.ts prefers this venv). # Migration scripts + their own Python venv (ops.service.ts prefers this venv).
COPY migration migration COPY migration migration
+318
View File
@@ -0,0 +1,318 @@
# Releasing, deploying, and changing the schema
How a version gets from this repo onto a server, and the one rule that keeps
rollbacks possible.
## The short version
Dispatch **Cut release** from the Actions tab and pick `patch`, `minor` or
`major` (or `explicit` plus a number). It stamps every `package.json`, commits
`chore(release): vX.Y.Z`, tags, and pushes both refs in one go. It refuses a
version that already exists as a tag, and refuses a no-op bump.
The equivalent by hand, if you would rather cut it locally:
```bash
pnpm version:set 1.2.0 # stamp every package.json
git commit -am "chore(release): v1.2.0"
git tag v1.2.0 && git push origin master v1.2.0
```
Either way that push triggers `.gitea/workflows/build.yml`, which builds **both** images in
one matrix run and publishes:
| tag pushed | image tags produced |
| --- | --- |
| `v1.2.0` | `1.2.0`, `1.2`, `sha-<short>` |
| push to `master` | `master`, `sha-<short>`, `latest` |
Then dispatch a deploy from the Actions tab:
- **galactus** (office server, standalone Docker) — *Deploy to galactus*
- **cubex** (3-node Swarm) — *Deploy to Portainer*
> **The `v` is not part of the image tag.** `docker/metadata-action`'s
> `{{version}}` strips it. Git tag `v1.2.0`, dispatch `1.2.0`. Dispatching
> `v1.2.0` deploys nothing that exists.
**Cut release needs a `RELEASE_TOKEN` secret** — a Gitea PAT with
`write:repository`. It does not use the built-in Actions token on purpose:
whether a push made with that token re-triggers `build.yml` depends on the Gitea
version, and a release that quietly publishes no images is worse than one that
fails outright. If the build somehow does not start, `build.yml` has
`workflow_dispatch` — run it against the new tag by hand.
Because api and web are built from one matrix run, they cannot drift at build
time. They *can* drift at deploy time if a stack is applied with only one image
moved — the web footer shows both versions and flags a mismatch, and the deploy
workflow's last step fails if the API does not report the tag you dispatched.
## What a deploy actually does
1. **db + minio**`scope: full` only. Idempotent; data lives on named volumes.
2. **Pre-migrate backup**`deploy/scripts/pre-migrate-backup.mjs` runs
`mysqldump` *inside the still-running old API container*, via Portainer's
Docker API. The file lands in that container's `BACKUP_DIR` volume as
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
**Operaciones** admin screen lists and can restore. A dump taken on the CI
runner would be unreachable by the only restore path the platform has.
3. **`prisma migrate deploy`** — as a workflow *step*, never the container
`CMD`. If it were the CMD, N replicas would race each other applying the
same migration.
4. **app** — the new api + web images.
5. **Verify**`GET /version` on the running API must report the dispatched
tag.
Rollback is `tag: 1.1.9` re-dispatched. **That rolls back code only.** The
schema stays where it is. Which brings us to the rule.
## The rule: expand / contract
Prisma has no down-migrations. There is no `prisma migrate down`, and there
never will be. So a schema change that the *previous* release cannot tolerate
turns a 30-second rollback into a restore-from-backup outage.
**Every schema change must leave the previous release working.** Split anything
destructive across two releases:
| | Release N (expand) | Release N+1 (contract) |
| --- | --- | --- |
| Rename a column | add the new column, write to both, read the old | drop the old column |
| Drop a column | stop reading and writing it in code | drop it |
| Add a required column | add it nullable (or with a default), backfill | make it `NOT NULL` |
| Split a table | create the new table, dual-write | stop writing the old, drop it |
| Add an enum value | add the value; old code must not choke on unknowns | start emitting it |
Ship N, let it soak, *then* ship N+1. If N has to be rolled back you just
re-dispatch the old tag — the expanded schema still satisfies it.
Restoring from the pre-migrate dump is the **emergency lever, not the routine
path**, and on galactus it is worse than it sounds: galactus is the replication
master, DDL replicates through the binlog, and restoring the master from a dump
diverges every replica. GTIDs will not line up and each replica needs a full
re-seed. Assume a restore is a multi-hour, whole-topology event.
## Migration history
`packages/database/prisma/migrations/0000_init/` is a **baseline**. It is the
full schema as it stood on 2026-07-30, generated with:
```bash
prisma migrate diff --from-empty \
--to-schema-datamodel packages/database/prisma/schema.prisma --script
```
Until then the schema had only ever been applied with `prisma db push`, so no
history existed and the schema state was disconnected from the app version.
### One-time, on every database that already exists
`0000_init` describes tables those databases already have, so `migrate deploy`
would fail with **P3005 "the database schema is not empty"**. Mark it applied
instead of applying it — this writes a `_prisma_migrations` row and changes no
data:
```bash
DATABASE_URL=<the database> npx prisma@5 migrate resolve \
--applied 0000_init --schema packages/database/prisma/schema.prisma
```
Do this once per database (prod, dev, any local copy). Verify first that the
live schema really does match the baseline — this should print an empty
migration:
```bash
prisma migrate diff --from-url "$DATABASE_URL" \
--to-schema-datamodel packages/database/prisma/schema.prisma --script
```
If it prints actual statements, the live database has drifted from
`schema.prisma`. Reconcile *before* baselining, or the first real migration
will fail against a schema Prisma believes it already knows.
### From here on
```bash
# edit schema.prisma, then:
pnpm --filter @jorgecuadros/database exec prisma migrate dev --name add_foo
```
Commit the generated `migrations/<timestamp>_add_foo/` directory. `db push` is
now a local-scratch tool only — using it against a database with history
desynchronises it from `_prisma_migrations`.
## galactus vs cubex
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
Swarm (endpoint **2**). They need different compose files because **plain
compose silently ignores Swarm's `deploy:` keys** rather than erroring:
| | Swarm (`deploy/*.stack.yml`) | standalone (`deploy/galactus/*.compose.yml`) |
| --- | --- | --- |
| restart | `deploy.restart_policy` | `restart: unless-stopped`**without this nothing comes back after a host reboot** |
| placement | `node.labels.jorgecuadros_db == true` | dropped, one host |
| ports | `{mode: ingress}` long syntax | `"3306:3306"` |
| `depends_on` | ignored by Swarm | honoured, with `condition: service_healthy` |
| volumes | named | named (unchanged — the pinning hazard was a Swarm problem) |
Keep the two sets in sync when either changes.
On both hosts, cross-stack traffic goes over the **host address**, not compose
service DNS: db, minio and app are three separate stacks, so three separate
networks. `DATABASE_URL` and `S3_ENDPOINT` name the host and its published
port. Do not "simplify" them to `mysql:3306`.
### galactus is addressed by MagicDNS, and containers need help resolving it
galactus is Tailscale-only once it is installed in the office, so every URL
names `galactus.tail01aa2.ts.net`. Its LAN IP is a DHCP lease and has already
drifted once — never put a `192.168.4.x` address in a secret.
Containers on galactus cannot resolve that name by default. The host runs
systemd-resolved, whose `127.0.0.53` stub is unreachable from inside a
container, so Docker falls back to the upstream resolver listed in
`/run/systemd/resolve/resolv.conf` — the LAN router, which knows nothing about
the tailnet. Routing to `100.x` works fine; only the *lookup* fails, and the
symptom is Prisma **P1001 "can't reach database server"** on a container that
otherwise started cleanly.
`deploy/galactus/jorgecuadros-app.compose.yml` therefore pins the resolver:
```yaml
dns: [100.100.100.100] # Tailscale's fixed anycast MagicDNS address
dns_search: [tail01aa2.ts.net] # this tailnet's suffix
```
Both are overridable (`TAILSCALE_DNS`, `TAILNET_SUFFIX`) if the tailnet changes.
Browser-facing origins need none of this — those names resolve on the client.
## Replication
galactus's MySQL is the **master**; every other MySQL in the estate is a
replica. Consequences that bite:
- `server-id` must be unique across the whole topology (prod `1`, cubex dev
`11`). A duplicate breaks replication silently.
- GTID is on from first boot, so replicas attach with `SOURCE_AUTO_POSITION=1`.
- `binlog_expire_logs_seconds` is raised to 60 days in the galactus compose file
(`MYSQL_BINLOG_EXPIRE_SECONDS`). MySQL 8.4 defaults to 30 days; a replica
offline longer than the retention needs a full re-seed.
Still open, and **not** handled by anything in this repo:
- No replication user with `REPLICATION SLAVE` granted exists yet.
- Nothing sets `read_only` / `super_read_only` on the replicas, so a stray write
to a replica will diverge it.
- The channel to the VPS crosses the public internet. It needs a tunnel or TLS —
do not publish raw 3306.
## Seeding the first sign-in account
A freshly migrated database has a schema and **no users**, so nobody can log in.
`prisma migrate deploy` creates tables, never rows; nothing in the deploy path
seeds an account, by design — creating an administrator should be a deliberate
act, not a side effect of shipping code.
`apps/api/scripts/seed-user.mjs` ships inside the API image. On the target host:
```bash
docker exec -e SEED_PASSWORD='<a strong password>' \
<api-container> node apps/api/scripts/seed-user.mjs
```
Defaults are `admin@jorgecuadros.local` / `ChangeMe!2026` / role `ADMIN`,
overridable with `SEED_EMAIL`, `SEED_PASSWORD`, `SEED_NAME`. **Do not accept the
default password on anything but a dev database** — it is published in this
repo's README. The script upserts by email, so re-running is safe, but it also
**resets the password of an existing account**.
## The session cookie and TLS
`SESSION_COOKIE_SECURE` controls the `Secure` flag on the session cookie. It
defaults to on in production, and it must be explicitly `"false"` for a
deployment served over plain HTTP.
This is not cosmetic. express-session silently declines to emit a `Secure`
cookie over an unencrypted connection: no `Set-Cookie` header is sent at all,
`POST /auth/login` still answers `200` with the user object, no session is
established, every subsequent request gets `403`, and the UI bounces back to
`/login` in a loop. It looks like an auth bug and is really a transport
mismatch.
galactus runs with `SESSION_COOKIE_SECURE=false`, which is acceptable **only**
because it is reachable exclusively over Tailscale — WireGuard already encrypts
the wire, so the cookie never crosses an untrusted network. Turn it back on the
moment the app is served over TLS or reachable off-tailnet. Behind a
TLS-terminating reverse proxy, set `trust proxy` on the Nest app instead of
disabling the flag.
## The MySQL client inside the API image
Alpine's `mysql-client` package is **MariaDB's** client, and it installs an
empty `/usr/lib/mariadb/plugin`. It therefore cannot speak
`caching_sha2_password`, which is MySQL 8.4's default and effectively only auth
method, and every `mysqldump`/`mysql` call from the container fails with:
```
ERROR 1045: Plugin caching_sha2_password could not be loaded:
... /usr/lib/mariadb/plugin/caching_sha2_password.so: No such file or directory
```
`mariadb-connector-c` supplies that plugin and is installed in
`docker/api.Dockerfile` for exactly this reason — do not drop it as an unused
dependency. It affects far more than the deploy backup: the entire
**Operaciones** panel (backup, restore, sync, re-import) shells out to these
binaries, so without it none of those work in a container either. The feature
had only ever been exercised with the API running on a developer machine, where
the Oracle client is installed, which is why this went unnoticed until the
first containerised deploy.
## The Operaciones panel needs its own database login
The panel's four jobs all shell out to `mysqldump`/`mysql`, and they cannot do
so as the application user. `mysqldump --single-transaction` issues
`FLUSH TABLES`, which requires the **global** `RELOAD` privilege; the MySQL
image grants the app user only `ALL PRIVILEGES ON jorgecuadros.*` plus
`USAGE ON *.*`. `--skip-lock-tables` does not avoid it. BACKUP therefore failed
outright, and SYNC and RE-IMPORT with it, because both take a safety backup
first.
The API is given an admin login out of band rather than permanently elevating
the user it serves requests as:
```
OPS_DB_ADMIN_USER=root
OPS_DB_ADMIN_PASSWORD=<MYSQL_ROOT_PASSWORD>
```
Both deploy workflows pass these into the app stack from the existing
`MYSQL_ROOT_PASSWORD` secret. Host, port and database still come from
`DATABASE_URL` — the override changes *who logs in*, never *which server*. With
the pair unset the service falls back to the `DATABASE_URL` credentials and logs
a warning, which is what local development wants.
Two more things the panel's dumps now do, for the same reasons the pre-migrate
backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
- **`--set-gtid-purged=OFF`.** galactus is the replication *source* with GTID
on, so without this every dump embeds `SET @@GLOBAL.GTID_PURGED` and cannot be
restored onto the server it came from — which is precisely what the restore
screen exists to do.
- **`set -o pipefail` and a `CREATE TABLE` count.** `mysqldump | gzip` reports
gzip's exit status, and a `mysqldump` that dies on its first statement still
produces a ~372-byte perfectly valid archive that passes `gzip -t`. Without
both checks a failed backup was recorded as a successful one and listed as an
ordinary restore point. A dump that fails now deletes its own output.
## Known caveats in the deploy path
- The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because
Portainer serves a self-signed certificate. It is scoped to that one step,
which talks to nothing but Portainer. Replacing the certificate and dropping
the flag is the real fix.
- The runner lives on cubex and must reach the target host's Portainer (9443)
**and** MySQL (3306). If it cannot reach 3306, run the migration by hand from
a host that can and dispatch with `skip_migrate: true`.
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container
exists yet. Use it for a first-ever deploy only — it is the one switch that
lets a migration run with no restore point.
+127 -10
View File
@@ -131,6 +131,82 @@ single-movement form.
## 2. PDF / OCR auto-capture ## 2. PDF / OCR auto-capture
> **BUILT — 2026-08-01.** Implemented and verified end to end against real
> scanned statements. `apps/api/src/statements/` holds the module: a swappable
> `OcrProvider` seam with a self-hosted Tesseract implementation, per-provider
> parsers for CFE / CESPT / Telnor, a scoped matcher, and a review queue that
> posts through `BillingService.createBatch` with `source: "OCR"`. Web:
> the "Captura automática (OCR)" tab of the Captura screen (upload + batch
> list) and `/recibos/:id` (review queue with the page image beside the
> extracted fields). New abilities `statement:ingest` / `statement:review`, both
> STAFF.
>
> Auto-capture is a *mode of* §1.2's capture screen, not a separate menu entry:
> it is the same daily job with a scanner instead of a keyboard, and both modes
> post through the same ledger path. `/estado-cuenta/lote` opens the manual tab,
> `/recibos` the automatic one; both render `components/Captura.tsx`.
>
> Requires object storage (`S3_ENDPOINT` + credentials): the scans are kept as
> blobs. `GET /statements/status` reports `ocrAvailable` and `storageAvailable`,
> and the upload card hides itself unless both hold.
>
> **Measured, not assumed.** Ten real scans (46 pages of CFE, CESPT and Telnor
> bills) drove every decision below. Against them the shipped parser identifies
> the provider on **46/46**, reads an account reference on **43/46**, an amount
> on **42/46**, and a due date on **44/46**. Matched against the dev database
> that is **39/46 (85%) exact auto-match, 40/46 (87%) identified**. The
> remainder are legitimate review cases: one account number shared by two
> services, three phone numbers not yet on file, one clave not in the book, and
> one page too poorly scanned to read.
>
> **The OCR-provider question is closed: self-hosted Tesseract.** It clears the
> bar for a queue where a human confirms every row, and at 300+ pages/month/
> company a per-page API would carry real recurring cost for accuracy that is
> not the bottleneck. `OcrProvider` keeps a managed API (Textract, Document
> Intelligence, Document AI) a one-line swap in `statements.module.ts` with no
> schema change.
>
> **Four things the samples proved that this spec had wrong or unknown:**
>
> 1. **Clave catastral ≠ predial — gap 2 below is resolved.** `DATMEX.clave` is
> 934 rows of `[A-Z]{2}[0-9]{6}` (`MM000012`, `KH220204`), the exact format
> printed as `Cve. Cat.` / `CLAVE CATASTRAL` on real CESPT bills
> (`KB078025`, `KA903009`). `DATMEX.predial` — what
> `PROPERTY_TAX.accountNumber` actually holds — is 1135 numeric rows with
> only **663 distinct values**, so it is not a per-property key at all and
> appears on no statement. The clave was never migrated; it now lives on
> `Property.cadastralKey` (property-level, because two different services
> both print it) and is the matcher's secondary key. Predial is left
> untouched. Predial statements match on the clave alone.
> 2. **Gas is not a dead end — gap 3 below was wrong.** `DATMEX.gas` has 334
> filled rows, of which **160 are real numeric account numbers**
> (`900004807`); the other 174 are tank descriptors (`ESTACIONARIO`,
> `CILINDRO`). All 334 went to `notes`. The 160 are recovered into
> `GAS.meterNumber`; only the descriptor rows start cold.
> 3. **Phone is one line per property, not three.** Of 1518 properties, 534
> have `phone1`, 18 have `phone2` and exactly **1** has `phone3`. The
> secondaries are alternate contacts, so `TELEPHONE` backfills from `phone1`
> only rather than fanning out. This answers the open question below.
> 4. **Statements arrive bundled, and their printed names are stale.** One PDF
> holds many customers, one per page (Telnor's own `Pág 3 de 6` refers to
> its internal pagination, not the office's scan). And the name on a utility
> bill is the account registrant, not the current owner: a CESPT receipt for
> account `5365218` prints `ARNAIZ ROSAS ELSA AURORA` where the office's
> book — corroborated by the clave — has `CATT, RANDY`. **The matcher never
> reads the name.**
>
> **Two OCR traps worth keeping in mind if the parsers are ever extended:**
> scanned logos read badly (a CESPT header came back as `E BAJA ES PAGO /
> EALIFORNIA`), so provider detection falls back to layout anchors — but only
> after *every* brand check has run, since a Telnor page contains words a CFE
> structural rule would otherwise claim. And amounts must be parsed by
> separator position: a real Telnor bill OCR'd as `$ 649,00`, which naive
> comma-stripping turns into $64,900.
>
> **Not covered:** handwritten folder numbers. Staff pencil a customer number on
> each bill (`9`, `405`, `406`); Tesseract read `405` as `205`. Handwriting is
> a review hint at best and is deliberately not an input to matching.
### Motivation (from the meeting) ### Motivation (from the meeting)
Each utility company (CFE, water, phone, gas...) sends 300+ individual Each utility company (CFE, water, phone, gas...) sends 300+ individual
@@ -401,6 +477,40 @@ document-understanding problem. Recommend:
## 3. Multi-bank chequera ## 3. Multi-bank chequera
> **BUILT — 2026-07-27.** Everything below is implemented and verified against
> the dev database and browser. `Bank` / `BankAccount` exist, every
> `BankTransaction` carries a required `bankAccountId`, and all 22,669 migrated
> rows were backfilled onto the Utilities/Scotiabank MXN account by
> `migration/backfill_bank_accounts.py` (now wired into `run_all.py`, both
> modes, ahead of `transform_bank.py`). Every read path in `bank.service.ts` is
> account-scoped — including both raw-SQL rollups in `summary()` and the
> previously-unfiltered `facets()`. `/banco` gained an account picker,
> `/banco/cuentas` manages banks and accounts under the new MANAGER
> `bank:manage-accounts` ability, and `/inicio`'s chequera card now names the
> account it is reading rather than implying a single register.
>
> **Verified end to end:** a second account (USD) was created through the API,
> a movement captured into it, and the MXN register's totals confirmed
> unchanged (22,669 movements, net 1,014,266.97) with zero cross-account leak
> in list/stats/facets/summary. Missing `bankAccountId` returns 400, unknown
> returns 404, capture into a closed account returns 400, and an attempt to
> PATCH an account's `currency` is rejected by DTO whitelisting. The test
> account was then deleted — the real Seguros bank is still the open question
> below, so nothing was left behind guessing at it.
>
> **Two deviations from the design below**, both tightening it:
> - `bank_transactions` also gained an `@@index([bankAccountId, transactionDate])`.
> Every read is now filtered by account and ordered/grouped by date; without
> it each of them is a full scan of the 22k-row table.
> - `UpdateBankAccountDto` deliberately has **no `currency` field**. The
> movements already booked in an account are denominated in it, so editing it
> would silently re-denominate history instead of converting it. Currency is
> set once, at creation.
>
> Still open: which bank the Seguros USD account is actually at (see Open
> questions). Until that answer arrives the office has exactly one chequera and
> the UI behaves as it always did, just scoped explicitly.
### Motivation ### Motivation
Seguros uses a US bank account; Utilities uses a Mexican bank account. The Seguros uses a US bank account; Utilities uses a Mexican bank account. The
@@ -776,16 +886,23 @@ action (`customer:purge`) taken well after release — not bundled into
## Open questions to take back to Jorge (collected) ## Open questions to take back to Jorge (collected)
- OCR provider/budget for §2 (self-hosted vs. managed API, given 300+ - ~~OCR provider/budget for §2~~ — **CLOSED**: self-hosted Tesseract, chosen on
pages/month/company). measured accuracy against real scans (see §2's BUILT note). No per-page cost.
- Whether source PDFs arrive pre-split per customer or as one bundled file - ~~Whether source PDFs arrive pre-split per customer or bundled~~ —
needing page-range detection (§2). **CLOSED**: bundled, one customer per page. Split per page.
- Whether "Clave Catastral" and the already-migrated `PREDIAL`-sourced - ~~Whether "Clave Catastral" and the `PREDIAL`-sourced
`PROPERTY_TAX.accountNumber` are the same number — blocks OCR matching `PROPERTY_TAX.accountNumber` are the same number~~**CLOSED**: they are
for predial statements specifically until confirmed (§2). different. `clave` is the cadastral key and is now on
- Whether phone billing is really one service per phone number on file, or `Property.cadastralKey`; `predial` is not unique and is not printed on
one per property regardless of how many numbers are recorded — decides statements.
how the new `TELEPHONE` service kind gets backfilled (§2). - ~~Whether phone billing is one service per number or one per property~~ —
**CLOSED**: effectively one (534 / 18 / 1 across phone1/2/3), backfilled
from `phone1`.
- **Still open (§2):** whether the CFE amount staff should owe is the rounded
headline (`$268`, what the barcode encodes and what is paid at the window) or
the exact `Total` in the breakdown (`$268.88`). The parser currently takes
the barcode figure, which matches what the office actually pays; worth one
confirmation from Jorge.
- The actual bank name/currency/details for the Seguros USD account, and - The actual bank name/currency/details for the Seguros USD account, and
whether any historical Seguros bank data exists to migrate (§3). whether any historical Seguros bank data exists to migrate (§3).
- Whether `BankAccount.businessLine` should be enforced or a soft hint - Whether `BankAccount.businessLine` should be enforced or a soft hint
+192
View File
@@ -0,0 +1,192 @@
"""
One-off schema+data step for the multi-bank chequera
(docs/RECEIPT_CAPTURE_SPEC.md §3).
`bank_transactions.bankAccountId` is REQUIRED in the Prisma schema, so
`prisma db push` cannot introduce it on a table that already holds 22k rows.
This script does the ordered dance that push can't:
1. create `banks` / `bank_accounts` (same DDL Prisma generates)
2. seed the one account every existing row belongs to — Scotiabank MXN,
the office's Utilities chequera, which is all `SCOTHIA.mdb` ever was
3. add `bankAccountId` NULLable, backfill every row to that account,
then promote it to NOT NULL and attach the FK + index
On a database that predates the feature, run it BEFORE `prisma db push`; push
then sees no drift. On a fresh environment push creates the tables itself and
this only seeds the rows. Either way `transform_bank.py` needs the account to
exist, so `run_all.py` runs it first. Idempotent — safe to re-run, and
re-running once a second account exists does NOT re-point rows (the backfill
only touches NULLs).
./.venv/bin/python backfill_bank_accounts.py --env dev
"""
from __future__ import annotations
import uuid
from dbenv import connect
from sync import parse_mode
# The account every migrated SCOTHIA row belongs to. Its id is derived, not
# random, so a re-run against a half-applied database finds the same row and
# `transform_bank.py` can resolve it by label without a lookup table.
SCOTIABANK = "Scotiabank"
UTILITIES_ACCOUNT = "Utilities — Scotiabank (MXN)"
def table_exists(c, name: str) -> bool:
c.execute(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = %s",
(name,),
)
return c.fetchone()[0] > 0
def column_exists(c, table: str, column: str) -> bool:
c.execute(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s",
(table, column),
)
return c.fetchone()[0] > 0
def constraint_exists(c, table: str, name: str) -> bool:
c.execute(
"SELECT COUNT(*) FROM information_schema.table_constraints "
"WHERE table_schema = DATABASE() AND table_name = %s AND constraint_name = %s",
(table, name),
)
return c.fetchone()[0] > 0
def index_exists(c, table: str, name: str) -> bool:
c.execute(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() AND table_name = %s AND index_name = %s",
(table, name),
)
return c.fetchone()[0] > 0
def main():
# `--sync` is accepted and ignored: this step is idempotent by nature, so
# it behaves identically in both modes and can sit in run_all's two lists.
env, _sync_mode = parse_mode()
conn = connect(env)
c = conn.cursor()
print(f"[bank-accounts] target env: {env}")
# --- 1. tables ----------------------------------------------------------
if not table_exists(c, "banks"):
c.execute(
"""
CREATE TABLE `banks` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `banks_name_key` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
)
print(" created banks")
if not table_exists(c, "bank_accounts"):
c.execute(
"""
CREATE TABLE `bank_accounts` (
`id` VARCHAR(191) NOT NULL,
`bankId` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`currency` ENUM('USD','MXN') NOT NULL,
`businessLine` ENUM('UTILITY','INSURANCE','TRUST') NULL,
`active` TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `bank_accounts_bankId_fkey` (`bankId`),
CONSTRAINT `bank_accounts_bankId_fkey` FOREIGN KEY (`bankId`)
REFERENCES `banks` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
)
print(" created bank_accounts")
# --- 2. seed the Utilities/Scotiabank chequera --------------------------
c.execute("SELECT id FROM banks WHERE name = %s", (SCOTIABANK,))
row = c.fetchone()
if row:
bank_id = row[0]
else:
bank_id = str(uuid.uuid4())
c.execute(
"INSERT INTO banks (id, name, country) VALUES (%s, %s, %s)",
(bank_id, SCOTIABANK, "MX"),
)
print(f" seeded bank {SCOTIABANK}")
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
row = c.fetchone()
if row:
account_id = row[0]
else:
account_id = str(uuid.uuid4())
c.execute(
"INSERT INTO bank_accounts (id, bankId, label, currency, businessLine, active) "
"VALUES (%s, %s, %s, 'MXN', 'UTILITY', 1)",
(account_id, bank_id, UTILITIES_ACCOUNT),
)
print(f" seeded account {UTILITIES_ACCOUNT}")
print(f" account id: {account_id}")
# --- 3. column, backfill, promote to NOT NULL ---------------------------
if not column_exists(c, "bank_transactions", "bankAccountId"):
c.execute("ALTER TABLE `bank_transactions` ADD COLUMN `bankAccountId` VARCHAR(191) NULL")
print(" added bank_transactions.bankAccountId (nullable)")
c.execute(
"UPDATE bank_transactions SET bankAccountId = %s WHERE bankAccountId IS NULL",
(account_id,),
)
print(f" backfilled {c.rowcount} movement(s) to {UTILITIES_ACCOUNT}")
c.execute("SELECT COUNT(*) FROM bank_transactions WHERE bankAccountId IS NULL")
orphans = c.fetchone()[0]
if orphans:
raise SystemExit(f"abort: {orphans} bank_transactions still have no account")
c.execute("ALTER TABLE `bank_transactions` MODIFY `bankAccountId` VARCHAR(191) NOT NULL")
if not index_exists(c, "bank_transactions", "bank_transactions_bankAccountId_transactionDate_idx"):
c.execute(
"CREATE INDEX `bank_transactions_bankAccountId_transactionDate_idx` "
"ON `bank_transactions` (`bankAccountId`, `transactionDate`)"
)
print(" created (bankAccountId, transactionDate) index")
if not constraint_exists(c, "bank_transactions", "bank_transactions_bankAccountId_fkey"):
c.execute(
"ALTER TABLE `bank_transactions` "
"ADD CONSTRAINT `bank_transactions_bankAccountId_fkey` FOREIGN KEY (`bankAccountId`) "
"REFERENCES `bank_accounts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE"
)
print(" attached bankAccountId FK")
conn.commit()
c.execute(
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
)
print("=== Multi-bank chequera ready ===")
for label, currency, n, total in c.fetchall():
print(f" {label:36} {currency} {n:6} movimientos neto {total}")
print(" validation: OK")
conn.close()
if __name__ == "__main__":
main()
@@ -0,0 +1,155 @@
"""
Closes the three data gaps the statement-OCR matcher depends on
(docs/RECEIPT_CAPTURE_SPEC.md §2, "Matching logic").
OCR matching is only as good as the field it matches against, and a
field-by-field check of real scanned statements against what
`transform_properties.py` actually loaded turned up three mismatches. This
script fixes them on an existing database; `transform_properties.py` has been
updated in the same commit so a full re-migration produces them directly.
1. CLAVE CATASTRAL — printed on both the CESPT water bill ("Cve. Cat.:
KB078025") and the predial statement, and held in `DATMEX.clave` (934
rows, format `[A-Z]{2}[0-9]{6}`). It was never migrated. What
`PROPERTY_TAX.accountNumber` carries instead is `DATMEX.predial`, a
different, purely numeric column that is *not* unique — 663 distinct
values across 1135 filled rows — and appears on no statement. So predial
is left exactly where it is, and the clave lands on `Property` (it is a
property-level key, which is why two different services both print it).
2. GAS — `GAS.meterNumber` is empty for all 334 rows because the transform
put `DATMEX.gas` into `notes`. That column is mixed: 160 rows hold a real
numeric account/meter number, the remaining 174 hold a tank descriptor
("ESTACIONARIO", "CILINDRO"). The numeric ones are recoverable now; the
descriptors legitimately have no number, so those statements still start
cold and get their number from the first human confirmation.
3. TELEPHONE — no such `ServiceKind` existed, so a Telnor bill had nothing to
match against. One service row is created per property with a `phone1`.
Only phone1: 534 properties have one, 18 have a phone2 and exactly 1 has a
phone3, so the secondaries are alternate contacts rather than separately
billed lines.
Idempotent — re-running updates nothing it has already done, and it never
overwrites a value a human has since corrected.
./.venv/bin/python backfill_statement_match_fields.py --env dev
"""
from __future__ import annotations
import uuid
from pathlib import Path
import pandas as pd
from dbenv import connect
from sync import parse_mode
STG = Path(__file__).parent / "output" / "stg_utilities"
NULL = ""
def s(v):
if v is None or pd.isna(v):
return None
v = str(v).strip()
return None if v in ("", NULL, "0") else v
def main():
# `--sync` is accepted and ignored — the script is idempotent either way.
env, _sync_mode = parse_mode()
conn = connect(env)
c = conn.cursor()
print(f"[statement-match-fields] target env: {env}")
# --- 1. clave catastral -> properties.cadastralKey ----------------------
# Joined on provenance, the same key transform_properties.py writes, so a
# property that was re-created by a later sync still lines up.
dm = pd.read_parquet(STG / "datmex.parquet")
claves = []
for _, row in dm.iterrows():
clave = s(row["clave"])
if clave:
claves.append((clave, str(int(row["_row_num"]))))
updated = 0
for clave, legacy_id in claves:
c.execute(
"UPDATE properties SET cadastralKey = %s "
"WHERE legacySourceTable = 'DATMEX' AND legacyId = %s AND cadastralKey IS NULL",
(clave, legacy_id),
)
updated += c.rowcount
print(f" cadastralKey: set on {updated} propert(ies) ({len(claves)} in source)")
# --- 2. gas account numbers out of notes -> GAS.meterNumber -------------
# REGEXP rather than a Python loop: the value is already sitting in the
# notes column, so this is one pass over 334 rows inside the database.
c.execute(
"UPDATE property_services SET meterNumber = notes "
"WHERE kind = 'GAS' AND meterNumber IS NULL "
"AND notes REGEXP '^[0-9]{5,}$'"
)
print(f" GAS.meterNumber: recovered {c.rowcount} account number(s) from notes")
# --- 3. TELEPHONE service rows ------------------------------------------
# Digits only, matching how the transform now writes them: a scanned Telnor
# bill prints "664 609 3444" and reduces to the stored local 6093444 once
# the LADA is stripped, which is the matcher's job, not this script's.
c.execute(
"SELECT p.id, p.phone1 FROM properties p "
"WHERE p.phone1 IS NOT NULL AND p.phone1 <> '' "
"AND NOT EXISTS (SELECT 1 FROM property_services ps "
" WHERE ps.propertyId = p.id AND ps.kind = 'TELEPHONE')"
)
rows = c.fetchall()
made = []
for pid, phone in rows:
digits = "".join(ch for ch in str(phone) if ch.isdigit())
if digits:
made.append((str(uuid.uuid4()), pid, digits))
if made:
c.executemany(
"INSERT INTO property_services "
"(id, propertyId, kind, accountNumber, active, notes) "
"VALUES (%s, %s, 'TELEPHONE', %s, 1, 'from DATMEX.telefono')",
made,
)
print(f" TELEPHONE: created {len(made)} service row(s)")
conn.commit()
# --- validation ---------------------------------------------------------
c.execute("SELECT COUNT(*) FROM properties WHERE cadastralKey IS NOT NULL")
n_clave = c.fetchone()[0]
c.execute(
"SELECT COUNT(*) FROM property_services WHERE kind='GAS' AND meterNumber IS NOT NULL"
)
n_gas = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM property_services WHERE kind='TELEPHONE'")
n_tel = c.fetchone()[0]
# A clave that is not unique would silently make the secondary match key
# ambiguous, which is worse than not having one — surface it rather than
# letting the matcher discover it a statement at a time.
c.execute(
"SELECT COUNT(*) FROM (SELECT cadastralKey FROM properties "
"WHERE cadastralKey IS NOT NULL GROUP BY cadastralKey HAVING COUNT(*) > 1) d"
)
dupe_claves = c.fetchone()[0]
print("=== Statement match fields ready ===")
print(f" properties with cadastralKey : {n_clave}")
print(f" GAS services with meterNumber: {n_gas}")
print(f" TELEPHONE services : {n_tel}")
print(f" duplicated cadastralKey values: {dupe_claves}"
+ (" (matcher treats these as ambiguous)" if dupe_claves else ""))
assert n_clave > 0 and n_tel > 0, "backfill produced nothing — check staging output"
print(" validation: OK")
conn.close()
if __name__ == "__main__":
main()
+18
View File
@@ -41,9 +41,18 @@ PY = sys.executable # the venv python running this orchestrator
STEPS = [ STEPS = [
"transform_customers.py", "transform_customers.py",
"transform_properties.py", "transform_properties.py",
# Statement-OCR match fields. transform_properties.py now produces these
# directly, so on a full rebuild this is a no-op that re-asserts they are
# there; on a database predating the OCR module it is what fills them in.
# Must follow transform_properties.py, which truncates both tables it
# touches.
"backfill_statement_match_fields.py",
"transform_policies.py", "transform_policies.py",
"transform_transactions.py", "transform_transactions.py",
"prune_empty_customers.py", "prune_empty_customers.py",
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
# transform_bank.py fails fast without it.
"backfill_bank_accounts.py",
"transform_bank.py", "transform_bank.py",
"blob_extract.py", "blob_extract.py",
] ]
@@ -51,11 +60,20 @@ STEPS = [
SYNC_STEPS = [ SYNC_STEPS = [
"transform_customers.py", "transform_customers.py",
"transform_properties.py", "transform_properties.py",
# Statement-OCR match fields. transform_properties.py now produces these
# directly, so on a full rebuild this is a no-op that re-asserts they are
# there; on a database predating the OCR module it is what fills them in.
# Must follow transform_properties.py, which truncates both tables it
# touches.
"backfill_statement_match_fields.py",
"transform_policies.py", "transform_policies.py",
"transform_transactions.py", "transform_transactions.py",
# Manual-safe prune: drops legacy-owned empties that the customer upsert # Manual-safe prune: drops legacy-owned empties that the customer upsert
# re-creates from Parquet, but leaves manually-added customers alone. # re-creates from Parquet, but leaves manually-added customers alone.
"prune_empty_customers.py", "prune_empty_customers.py",
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
# transform_bank.py fails fast without it.
"backfill_bank_accounts.py",
"transform_bank.py", "transform_bank.py",
] ]
+44 -4
View File
@@ -10,12 +10,20 @@ Sources:
from the spelled-out "cantidad en letra" from the spelled-out "cantidad en letra"
- TABLA RAMODOS -> business_line_categories (line-of-business lookup) - TABLA RAMODOS -> business_line_categories (line-of-business lookup)
Bank account: SCOTHIA is the Utilities MXN chequera and nothing else — DATOS
E/I carry no bank or currency column — so every row loads against the single
account seeded by `backfill_bank_accounts.py`, which must have run first.
`banks` / `bank_accounts` are NOT truncated here; only the movements are. (In
full-rebuild mode that still clears app-captured rows on every account, the
same whole-database truncate every transform in this pipeline does — use
`--sync` to upsert instead.)
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
key. So the categories are loaded but bank_transactions.categoryId is left key. So the categories are loaded but bank_transactions.categoryId is left
NULL for now; a concept->ramo classifier is a later enhancement. NULL for now; a concept->ramo classifier is a later enhancement.
Idempotent (truncate + rebuild). Run: Idempotent (rebuild the legacy rows). Run:
./.venv/bin/python transform_bank.py --env dev ./.venv/bin/python transform_bank.py --env dev
""" """
@@ -27,6 +35,7 @@ from pathlib import Path
import pandas as pd import pandas as pd
from backfill_bank_accounts import UTILITIES_ACCOUNT
from dbenv import connect, env_arg from dbenv import connect, env_arg
from sync import parse_mode from sync import parse_mode
@@ -78,6 +87,19 @@ def main():
print(f"[bank] target env: {env}") print(f"[bank] target env: {env}")
c = conn.cursor() c = conn.cursor()
# Every SCOTHIA row belongs to the one Utilities MXN chequera. Resolved by
# label rather than created here, so this script can't silently open a
# second copy of the account if the backfill hasn't run.
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
row = c.fetchone()
if not row:
raise SystemExit(
f"missing bank account {UTILITIES_ACCOUNT!r} — run "
f"backfill_bank_accounts.py --env {env} first"
)
account_id = row[0]
print(f"[bank] account: {UTILITIES_ACCOUNT} ({account_id})")
# business_line_categories (dedup TABLA RAMODOS) # business_line_categories (dedup TABLA RAMODOS)
cats, seen = [], set() cats, seen = [], set()
for _, r in load("tabla_ramodos").iterrows(): for _, r in load("tabla_ramodos").iterrows():
@@ -96,7 +118,8 @@ def main():
skip_date += 1 skip_date += 1
return return
rows.append(( rows.append((
str(uuid.uuid4()), td, s(r["tipo"]), s(r["num"]), s(r["concepto"]), str(uuid.uuid4()), account_id,
td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
amount, None, # categoryId left NULL (see header) amount, None, # categoryId left NULL (see header)
1 if truthy(r["operado"]) else 0, 1 if truthy(r["operado"]) else 0,
1 if (income and truthy(r["transferido"])) else 0, 1 if (income and truthy(r["transferido"])) else 0,
@@ -110,9 +133,17 @@ def main():
for _, r in load("datos_e").iterrows(): for _, r in load("datos_e").iterrows():
add(r, -(dec(r["egreso"], Decimal(0))), income=False) add(r, -(dec(r["egreso"], Decimal(0))), income=False)
COLS = (
"id,bankAccountId,transactionDate,transactionType,reference,concept,amount,"
"categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId"
)
PLACEHOLDERS = ",".join(["%s"] * 14)
if sync_mode: if sync_mode:
# bankAccountId is deliberately absent from the UPDATE clause: an
# account moved by hand in the app must not be dragged back.
for row in rows: for row in rows:
c.execute("INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row) c.execute(f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS}) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
else: else:
c.execute("SET FOREIGN_KEY_CHECKS=0") c.execute("SET FOREIGN_KEY_CHECKS=0")
for t in ("bank_transactions", "business_line_categories"): for t in ("bank_transactions", "business_line_categories"):
@@ -120,7 +151,7 @@ def main():
c.execute("SET FOREIGN_KEY_CHECKS=1") c.execute("SET FOREIGN_KEY_CHECKS=1")
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats) c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
c.executemany( c.executemany(
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows) f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS})", rows)
conn.commit() conn.commit()
def count(t): def count(t):
@@ -136,6 +167,15 @@ def main():
for src, n, tot in by_src: for src, n, tot in by_src:
print(f" {(src or '(manual)'):10} {n:6} sum {tot}") print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
print(f" net balance movement : {net}") print(f" net balance movement : {net}")
# Per account, never a cross-account total: the registers are in different
# currencies and summing them produces a figure that never existed.
c.execute(
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
)
for label, currency, n, total in c.fetchall():
print(f" {label:34} {currency} {n:6} neto {total}")
print(f" -> business_line_categories: {count('business_line_categories')}") print(f" -> business_line_categories: {count('business_line_categories')}")
print(" validation: OK") print(" validation: OK")
conn.close() conn.close()
+25 -5
View File
@@ -147,7 +147,7 @@ def main():
props.append(( props.append((
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None, pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]), s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
s(row["zona"]), "DATMEX", legacy_id, s(row["zona"]), s(row["clave"]), "DATMEX", legacy_id,
)) ))
@@ -178,9 +178,16 @@ def main():
svc("ELECTRIC", account=s(row[rc]), svc("ELECTRIC", account=s(row[rc]),
notes=s(row["luz_tipo"]) if rc == "rpu" else None, notes=s(row["luz_tipo"]) if rc == "rpu" else None,
active=flag("electric")) active=flag("electric"))
# GAS # GAS — the column mixes two things: an account/meter number for 160 of
# the 334 filled rows, and a tank descriptor ("ESTACIONARIO",
# "CILINDRO") for the rest. Only the numeric form can be matched
# against a scanned gas statement, so it is promoted to meterNumber;
# the descriptor stays a note, as before.
if s(row["gas"]) or flag("gas1", False): if s(row["gas"]) or flag("gas1", False):
svc("GAS", due=s(row["gas_vence"]), notes=s(row["gas"]), active=flag("gas1")) gas_val = s(row["gas"])
gas_meter = gas_val if gas_val and gas_val.isdigit() and len(gas_val) >= 5 else None
svc("GAS", meter=gas_meter, due=s(row["gas_vence"]),
notes=s(row["gas"]), active=flag("gas1"))
# CABLE # CABLE
if s(row["cable_num"]) or (s_keep0(row["cable_sky"]) or "0") in _TRUE: if s(row["cable_num"]) or (s_keep0(row["cable_sky"]) or "0") in _TRUE:
svc("CABLE", account=s(row["cable_num"]), route=s(row["cia_cable"]), svc("CABLE", account=s(row["cable_num"]), route=s(row["cia_cable"]),
@@ -194,6 +201,19 @@ def main():
if s(row["zfed"]) or flag("federalzone", False): if s(row["zfed"]) or flag("federalzone", False):
svc("FEDERAL_ZONE", account=s(row["zfed"]), notes=s(row["zfed_t"]), svc("FEDERAL_ZONE", account=s(row["zfed"]), notes=s(row["zfed_t"]),
active=flag("federalzone")) active=flag("federalzone"))
# TELEPHONE — DATMEX never had a phone *service*, only the contact
# numbers unpivoted into Property.phone1/2/3 above, even though the
# legacy ledger billed phone as its own transaction type. A Telnor bill
# can only be matched against a service row, so the primary number
# becomes one. Only phone1: of 1518 properties, 534 have phone1, 18
# phone2 and exactly 1 phone3 — the secondaries are alternate contacts,
# not additional billed lines. Stored as the bare local number, which is
# how DATMEX holds it and what a printed bill reduces to once the 664
# Tijuana LADA is stripped.
tel = s(row["telefono"])
if tel:
svc("TELEPHONE", account="".join(ch for ch in tel if ch.isdigit()) or None,
notes="from DATMEX.telefono")
# ALARM # ALARM
if s(row["alarm_system"]): if s(row["alarm_system"]):
svc("ALARM", notes=s(row["alarm_system"])) svc("ALARM", notes=s(row["alarm_system"]))
@@ -218,7 +238,7 @@ def main():
cur.execute("DELETE ps FROM property_services ps JOIN properties p ON p.id=ps.propertyId WHERE p.legacyId IS NOT NULL") cur.execute("DELETE ps FROM property_services ps JOIN properties p ON p.id=ps.propertyId WHERE p.legacyId IS NOT NULL")
cur.execute("DELETE ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL") cur.execute("DELETE ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL")
cur.executemany( cur.executemany(
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),phone1=VALUES(phone1),phone2=VALUES(phone2),phone3=VALUES(phone3),zone=VALUES(zone),archivedAt=NULL", props) "INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,cadastralKey,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),phone1=VALUES(phone1),phone2=VALUES(phone2),phone3=VALUES(phone3),zone=VALUES(zone),cadastralKey=VALUES(cadastralKey),archivedAt=NULL", props)
delete_missing(cur, "properties", ("legacySourceTable", "legacyId"), prop_keys, "WHERE legacyId IS NOT NULL") delete_missing(cur, "properties", ("legacySourceTable", "legacyId"), prop_keys, "WHERE legacyId IS NOT NULL")
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services) cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts) cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
@@ -227,7 +247,7 @@ def main():
for t in ("property_services", "service_documents", "trust_accounts", "properties"): for t in ("property_services", "service_documents", "trust_accounts", "properties"):
cur.execute(f"TRUNCATE TABLE {t}") cur.execute(f"TRUNCATE TABLE {t}")
cur.execute("SET FOREIGN_KEY_CHECKS=1") cur.execute("SET FOREIGN_KEY_CHECKS=1")
cur.executemany("INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props) cur.executemany("INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,cadastralKey,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services) cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts) cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
conn.commit() conn.commit()
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "jorgecuadros-platform", "name": "jorgecuadros-platform",
"version": "0.1.0", "version": "1.0.2",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -12,7 +12,9 @@
"build": "npm run build -ws --if-present", "build": "npm run build -ws --if-present",
"prisma:generate": "npm run generate -w packages/database", "prisma:generate": "npm run generate -w packages/database",
"prisma:migrate": "npm run migrate:dev -w packages/database", "prisma:migrate": "npm run migrate:dev -w packages/database",
"prisma:studio": "npm run studio -w packages/database" "prisma:deploy": "npm run migrate:deploy -w packages/database",
"prisma:studio": "npm run studio -w packages/database",
"version:set": "node scripts/set-version.mjs"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/database", "name": "@jorgecuadros/database",
"version": "0.1.0", "version": "1.0.2",
"private": true, "private": true,
"main": "generated/client/index.js", "main": "generated/client/index.js",
"types": "generated/client/index.d.ts", "types": "generated/client/index.d.ts",
@@ -0,0 +1,538 @@
-- CreateTable
CREATE TABLE `customers` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`nameSource` VARCHAR(191) NULL,
`nameMissing` BOOLEAN NOT NULL DEFAULT false,
`addressLine1` VARCHAR(191) NULL,
`addressLine2` VARCHAR(191) NULL,
`city` VARCHAR(191) NULL,
`state` VARCHAR(191) NULL,
`zipCode` VARCHAR(191) NULL,
`country` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`mobile` VARCHAR(191) NULL,
`fax` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
`notes` TEXT NULL,
`identificationType` VARCHAR(191) NULL,
`identificationNumber` VARCHAR(191) NULL,
`identificationExpiration` DATETIME(3) NULL,
`customerSince` DATETIME(3) NULL,
`status` BOOLEAN NOT NULL DEFAULT true,
`minimumBalance` DECIMAL(12, 2) NULL,
`feeAmount` DECIMAL(12, 2) NULL,
`preferredCurrency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'USD',
`archivedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `customer_legacy_refs` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`sourceSystem` VARCHAR(191) NOT NULL,
`sourceTable` VARCHAR(191) NOT NULL,
`legacyId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `customer_legacy_refs_sourceSystem_sourceTable_legacyId_key`(`sourceSystem`, `sourceTable`, `legacyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `insurance_providers` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
UNIQUE INDEX `insurance_providers_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `policy_types` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`shortDescription` VARCHAR(191) NULL,
UNIQUE INDEX `policy_types_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `policies` (
`id` VARCHAR(191) NOT NULL,
`policyNumber` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`policyTypeId` VARCHAR(191) NULL,
`insuranceProviderId` VARCHAR(191) NULL,
`agentName` VARCHAR(191) NULL,
`policyDate` DATETIME(3) NULL,
`policyFrom` DATETIME(3) NULL,
`policyTo` DATETIME(3) NULL,
`coveragePeriodDays` INTEGER NULL DEFAULT 365,
`netPremium` DECIMAL(12, 2) NULL,
`policyFee` DECIMAL(12, 2) NULL,
`brokerFee` DECIMAL(12, 2) NULL,
`commission` DECIMAL(12, 2) NULL,
`total` DECIMAL(12, 2) NULL,
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
`observations` TEXT NULL,
`notes` TEXT NULL,
`coveragesJson` JSON NULL,
`endorsement` BOOLEAN NOT NULL DEFAULT false,
`liquidated` BOOLEAN NOT NULL DEFAULT false,
`liquidationNumber` VARCHAR(191) NULL,
`liquidationDate` DATETIME(3) NULL,
`archivedAt` DATETIME(3) NULL,
`legacySourceDb` VARCHAR(191) NULL,
`legacySourceTable` VARCHAR(191) NULL,
`legacyId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `policies_policyNumber_idx`(`policyNumber`),
UNIQUE INDEX `policies_legacySourceDb_legacySourceTable_legacyId_key`(`legacySourceDb`, `legacySourceTable`, `legacyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `renewal_notices` (
`id` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NOT NULL,
`generation` INTEGER NOT NULL,
`channel` ENUM('MAIL', 'EMAIL') NOT NULL DEFAULT 'MAIL',
`sentAt` DATETIME(3) NULL,
`sentById` VARCHAR(191) NULL,
`notes` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `renewal_notices_policyId_generation_key`(`policyId`, `generation`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `policy_payment_installments` (
`id` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NOT NULL,
`sequence` INTEGER NOT NULL,
`amount` DECIMAL(12, 2) NULL,
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
`dueDate` DATETIME(3) NULL,
`paidDate` DATETIME(3) NULL,
`checkNumber` VARCHAR(191) NULL,
`isCash` BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `vehicles` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NULL,
`policyId` VARCHAR(191) NULL,
`make` VARCHAR(191) NULL,
`model` VARCHAR(191) NULL,
`modelYear` VARCHAR(191) NULL,
`bodyType` VARCHAR(191) NULL,
`engineNumber` VARCHAR(191) NULL,
`licensePlate` VARCHAR(191) NULL,
`vinNumber` VARCHAR(191) NULL,
`stateCode` VARCHAR(191) NULL,
`notes` TEXT NULL,
`legacySourceTable` VARCHAR(191) NULL,
`legacyId` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `insured_drivers` (
`id` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NOT NULL,
`fullName` VARCHAR(191) NULL,
`birthDate` DATETIME(3) NULL,
`sex` VARCHAR(191) NULL,
`occupation` VARCHAR(191) NULL,
`licenseNumber` VARCHAR(191) NULL,
`licenseState` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `policy_beneficiaries` (
`id` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NULL,
`address` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`email` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `claims` (
`id` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NOT NULL,
`claimType` VARCHAR(191) NULL,
`incidentDate` DATETIME(3) NULL,
`reportedDate` DATETIME(3) NULL,
`description` TEXT NULL,
`adjusterId` VARCHAR(191) NULL,
`claimedAmount` DECIMAL(12, 2) NULL,
`settledAmount` DECIMAL(12, 2) NULL,
`settlementDate` DATETIME(3) NULL,
`checkNumber` VARCHAR(191) NULL,
`resolved` BOOLEAN NOT NULL DEFAULT false,
`resolutionNotes` TEXT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `adjusters` (
`id` VARCHAR(191) NOT NULL,
`company` VARCHAR(191) NULL,
`city` VARCHAR(191) NULL,
`name` VARCHAR(191) NULL,
`phone` VARCHAR(191) NULL,
`beeper` VARCHAR(191) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `policy_documents` (
`id` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NOT NULL,
`documentType` VARCHAR(191) NOT NULL,
`storageKey` VARCHAR(191) NOT NULL,
`originalColumn` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `properties` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`policyId` VARCHAR(191) NULL,
`addressLine1` VARCHAR(191) NULL,
`addressLine2` VARCHAR(191) NULL,
`phone1` VARCHAR(191) NULL,
`phone2` VARCHAR(191) NULL,
`phone3` VARCHAR(191) NULL,
`zone` VARCHAR(191) NULL,
`archivedAt` DATETIME(3) NULL,
`legacySourceTable` VARCHAR(191) NULL,
`legacyId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `properties_legacySourceTable_legacyId_key`(`legacySourceTable`, `legacyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `property_services` (
`id` VARCHAR(191) NOT NULL,
`propertyId` VARCHAR(191) NOT NULL,
`kind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'OTHER') NOT NULL,
`accountNumber` VARCHAR(191) NULL,
`meterNumber` VARCHAR(191) NULL,
`route` VARCHAR(191) NULL,
`dueDay` VARCHAR(191) NULL,
`active` BOOLEAN NOT NULL DEFAULT true,
`notes` TEXT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `service_documents` (
`id` VARCHAR(191) NOT NULL,
`propertyId` VARCHAR(191) NOT NULL,
`documentType` VARCHAR(191) NOT NULL,
`storageKey` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `trust_accounts` (
`id` VARCHAR(191) NOT NULL,
`propertyId` VARCHAR(191) NOT NULL,
`bankName` VARCHAR(191) NULL,
`trustNumber` VARCHAR(191) NULL,
`bankFee` DECIMAL(12, 2) NULL,
`dueDate1` DATETIME(3) NULL,
`dueDate2` DATETIME(3) NULL,
UNIQUE INDEX `trust_accounts_propertyId_key`(`propertyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `type_transactions` (
`id` VARCHAR(191) NOT NULL,
`nameEn` VARCHAR(191) NOT NULL,
`nameEs` VARCHAR(191) NULL,
`isService` BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `transactions` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`domain` ENUM('UTILITY', 'INSURANCE', 'TRUST') NOT NULL,
`typeId` VARCHAR(191) NULL,
`transactionDate` DATETIME(3) NOT NULL,
`period` VARCHAR(191) NULL,
`reference` VARCHAR(191) NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
`exchangeRate` DECIMAL(10, 4) NULL,
`checkNumber` VARCHAR(191) NULL,
`message` TEXT NULL,
`outstanding` BOOLEAN NOT NULL DEFAULT false,
`captureSource` ENUM('MANUAL', 'BATCH', 'OCR') NULL,
`captureRef` VARCHAR(191) NULL,
`voidedAt` DATETIME(3) NULL,
`voidedById` VARCHAR(191) NULL,
`legacySourceDb` VARCHAR(191) NULL,
`legacySourceTable` VARCHAR(191) NULL,
`legacyId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `transactions_customerId_transactionDate_idx`(`customerId`, `transactionDate`),
INDEX `transactions_checkNumber_idx`(`checkNumber`),
INDEX `transactions_captureRef_idx`(`captureRef`),
UNIQUE INDEX `transactions_legacySourceDb_legacySourceTable_legacyId_key`(`legacySourceDb`, `legacySourceTable`, `legacyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `exchange_rates` (
`id` VARCHAR(191) NOT NULL,
`rate` DECIMAL(10, 4) NOT NULL,
`effectiveDate` DATETIME(3) NOT NULL,
`effectiveHour` DATETIME(3) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `business_line_categories` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
UNIQUE INDEX `business_line_categories_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `banks` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`country` VARCHAR(191) NULL,
UNIQUE INDEX `banks_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `bank_accounts` (
`id` VARCHAR(191) NOT NULL,
`bankId` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NOT NULL,
`currency` ENUM('USD', 'MXN') NOT NULL,
`businessLine` ENUM('UTILITY', 'INSURANCE', 'TRUST') NULL,
`active` BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `bank_transactions` (
`id` VARCHAR(191) NOT NULL,
`bankAccountId` VARCHAR(191) NOT NULL,
`transactionDate` DATETIME(3) NOT NULL,
`transactionType` VARCHAR(191) NULL,
`reference` VARCHAR(191) NULL,
`concept` VARCHAR(191) NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`categoryId` VARCHAR(191) NULL,
`cleared` BOOLEAN NOT NULL DEFAULT false,
`transferred` BOOLEAN NOT NULL DEFAULT false,
`notes` TEXT NULL,
`amountInWords` VARCHAR(191) NULL,
`voidedAt` DATETIME(3) NULL,
`voidedById` VARCHAR(191) NULL,
`legacySourceTable` VARCHAR(191) NULL,
`legacyId` VARCHAR(191) NULL,
INDEX `bank_transactions_bankAccountId_transactionDate_idx`(`bankAccountId`, `transactionDate`),
UNIQUE INDEX `bank_transactions_legacySourceTable_legacyId_key`(`legacySourceTable`, `legacyId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `users` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NOT NULL,
`passwordHash` VARCHAR(191) NOT NULL,
`role` ENUM('ADMIN', 'MANAGER', 'STAFF', 'VIEWER') NOT NULL DEFAULT 'STAFF',
`active` BOOLEAN NOT NULL DEFAULT true,
`uiScale` DOUBLE NOT NULL DEFAULT 1,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `users_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `activity_logs` (
`id` VARCHAR(191) NOT NULL,
`userId` VARCHAR(191) NULL,
`event` VARCHAR(191) NOT NULL,
`level` VARCHAR(191) NOT NULL,
`message` JSON NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `email_templates` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`subject` VARCHAR(191) NOT NULL,
`templateSource` TEXT NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `email_campaigns` (
`id` VARCHAR(191) NOT NULL,
`campaignName` VARCHAR(191) NOT NULL,
`subject` VARCHAR(191) NULL,
`body` TEXT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'in_progress',
`emailSentCount` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `email_log` (
`id` VARCHAR(191) NOT NULL,
`customerId` VARCHAR(191) NULL,
`emailAddress` VARCHAR(191) NULL,
`emailType` VARCHAR(191) NULL,
`requestBody` TEXT NULL,
`responseBody` TEXT NULL,
`sentAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ops_jobs` (
`id` VARCHAR(191) NOT NULL,
`kind` ENUM('BACKUP', 'RESTORE', 'REIMPORT', 'SYNC') NOT NULL,
`status` ENUM('RUNNING', 'SUCCESS', 'FAILED') NOT NULL DEFAULT 'RUNNING',
`log` LONGTEXT NOT NULL,
`params` JSON NULL,
`createdById` VARCHAR(191) NULL,
`startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`finishedAt` DATETIME(3) NULL,
INDEX `ops_jobs_status_idx`(`status`),
INDEX `ops_jobs_startedAt_idx`(`startedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `customer_legacy_refs` ADD CONSTRAINT `customer_legacy_refs_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policies` ADD CONSTRAINT `policies_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policies` ADD CONSTRAINT `policies_policyTypeId_fkey` FOREIGN KEY (`policyTypeId`) REFERENCES `policy_types`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policies` ADD CONSTRAINT `policies_insuranceProviderId_fkey` FOREIGN KEY (`insuranceProviderId`) REFERENCES `insurance_providers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `renewal_notices` ADD CONSTRAINT `renewal_notices_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_payment_installments` ADD CONSTRAINT `policy_payment_installments_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `vehicles` ADD CONSTRAINT `vehicles_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `vehicles` ADD CONSTRAINT `vehicles_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `insured_drivers` ADD CONSTRAINT `insured_drivers_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_beneficiaries` ADD CONSTRAINT `policy_beneficiaries_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `claims` ADD CONSTRAINT `claims_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `claims` ADD CONSTRAINT `claims_adjusterId_fkey` FOREIGN KEY (`adjusterId`) REFERENCES `adjusters`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `policy_documents` ADD CONSTRAINT `policy_documents_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `properties` ADD CONSTRAINT `properties_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `properties` ADD CONSTRAINT `properties_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `property_services` ADD CONSTRAINT `property_services_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `service_documents` ADD CONSTRAINT `service_documents_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `trust_accounts` ADD CONSTRAINT `trust_accounts_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `transactions` ADD CONSTRAINT `transactions_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `transactions` ADD CONSTRAINT `transactions_typeId_fkey` FOREIGN KEY (`typeId`) REFERENCES `type_transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `bank_accounts` ADD CONSTRAINT `bank_accounts_bankId_fkey` FOREIGN KEY (`bankId`) REFERENCES `banks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `bank_transactions` ADD CONSTRAINT `bank_transactions_bankAccountId_fkey` FOREIGN KEY (`bankAccountId`) REFERENCES `bank_accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `bank_transactions` ADD CONSTRAINT `bank_transactions_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `business_line_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `activity_logs` ADD CONSTRAINT `activity_logs_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,79 @@
-- AlterTable
ALTER TABLE `properties` ADD COLUMN `cadastralKey` VARCHAR(191) NULL;
-- AlterTable
ALTER TABLE `property_services` MODIFY `kind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'TELEPHONE', 'OTHER') NOT NULL;
-- CreateTable
CREATE TABLE `statement_batches` (
`id` VARCHAR(191) NOT NULL,
`serviceKind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'TELEPHONE', 'OTHER') NOT NULL,
`status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED') NOT NULL DEFAULT 'UPLOADED',
`uploadedById` VARCHAR(191) NOT NULL,
`label` VARCHAR(191) NULL,
`fileCount` INTEGER NOT NULL DEFAULT 0,
`error` TEXT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`completedAt` DATETIME(3) NULL,
INDEX `statement_batches_status_createdAt_idx`(`status`, `createdAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `statement_documents` (
`id` VARCHAR(191) NOT NULL,
`batchId` VARCHAR(191) NOT NULL,
`pageNumber` INTEGER NOT NULL,
`storageKey` VARCHAR(191) NOT NULL,
`status` ENUM('PENDING_OCR', 'OCR_FAILED', 'NEEDS_REVIEW', 'MATCHED', 'CONFIRMED', 'POSTED', 'REJECTED') NOT NULL DEFAULT 'PENDING_OCR',
`ocrRawText` TEXT NULL,
`ocrConfidence` DECIMAL(4, 3) NULL,
`provider` VARCHAR(191) NULL,
`extractedAccountRef` VARCHAR(191) NULL,
`extractedAmount` DECIMAL(12, 2) NULL,
`extractedPeriod` VARCHAR(191) NULL,
`extractedDueDate` DATETIME(3) NULL,
`extractedCadastralKey` VARCHAR(191) NULL,
`matchedPropertyServiceId` VARCHAR(191) NULL,
`matchedCustomerId` VARCHAR(191) NULL,
`matchNote` VARCHAR(191) NULL,
`reviewedById` VARCHAR(191) NULL,
`reviewedAt` DATETIME(3) NULL,
`postedTransactionId` VARCHAR(191) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `statement_documents_postedTransactionId_key`(`postedTransactionId`),
INDEX `statement_documents_status_idx`(`status`),
INDEX `statement_documents_matchedCustomerId_idx`(`matchedCustomerId`),
UNIQUE INDEX `statement_documents_batchId_pageNumber_key`(`batchId`, `pageNumber`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateIndex
CREATE INDEX `properties_cadastralKey_idx` ON `properties`(`cadastralKey`);
-- CreateIndex
CREATE INDEX `property_services_kind_accountNumber_idx` ON `property_services`(`kind`, `accountNumber`);
-- CreateIndex
CREATE INDEX `property_services_kind_meterNumber_idx` ON `property_services`(`kind`, `meterNumber`);
-- AddForeignKey
ALTER TABLE `statement_batches` ADD CONSTRAINT `statement_batches_uploadedById_fkey` FOREIGN KEY (`uploadedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_batchId_fkey` FOREIGN KEY (`batchId`) REFERENCES `statement_batches`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_matchedPropertyServiceId_fkey` FOREIGN KEY (`matchedPropertyServiceId`) REFERENCES `property_services`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_matchedCustomerId_fkey` FOREIGN KEY (`matchedCustomerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_reviewedById_fkey` FOREIGN KEY (`reviewedById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
+195
View File
@@ -8,6 +8,13 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
output = "../generated/client" output = "../generated/client"
// "native" covers local dev. The musl target is declared EXPLICITLY because
// Prisma picks the engine by sniffing the build environment: the Docker build
// stage has no openssl, so it detected plain "linux-musl", while the runtime
// stage (which needs openssl for other reasons) then demanded
// "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
// engine that ships independent of what happens to be installed at build time.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
} }
datasource db { datasource db {
@@ -46,6 +53,12 @@ enum ServiceKind {
PROPERTY_TAX PROPERTY_TAX
FEDERAL_ZONE FEDERAL_ZONE
ALARM ALARM
/// Telephone was never unpivoted out of DATMEX — the numbers sat on
/// `Property.phone1/2/3` as contact fields even though the legacy ledger
/// billed phone as its own transaction type. OCR matching needs a real
/// service row to match a Telnor bill against, so it becomes one; see
/// `migration/backfill_statement_match_fields.py`.
TELEPHONE
OTHER OTHER
} }
@@ -108,6 +121,8 @@ model Customer {
vehicles Vehicle[] vehicles Vehicle[]
transactions Transaction[] transactions Transaction[]
statementDocuments StatementDocument[]
@@map("customers") @@map("customers")
} }
@@ -366,6 +381,14 @@ model Property {
phone2 String? phone2 String?
phone3 String? phone3 String?
zone String? zone String?
/// Clave catastral (DATMEX.clave) — the cadastral key, format `KA903009`.
/// Property-level, not per-service: it is printed on both the CESPT water
/// bill and the predial statement, which is exactly why it is a useful
/// secondary match key when a bill's account number does not OCR cleanly.
/// Distinct from the numeric DATMEX.predial that `PROPERTY_TAX.accountNumber`
/// carries — that column is not unique (663 distinct across 1135 rows) and
/// is not what any statement prints.
cadastralKey String?
// Soft-delete marker (see Customer.archivedAt). // Soft-delete marker (see Customer.archivedAt).
archivedAt DateTime? archivedAt DateTime?
legacySourceTable String? legacySourceTable String?
@@ -376,6 +399,8 @@ model Property {
documents ServiceDocument[] documents ServiceDocument[]
trustAccount TrustAccount? trustAccount TrustAccount?
@@index([cadastralKey])
@@unique([legacySourceTable, legacyId]) @@unique([legacySourceTable, legacyId])
@@map("properties") @@map("properties")
} }
@@ -394,6 +419,13 @@ model PropertyService {
active Boolean @default(true) active Boolean @default(true)
notes String? @db.Text notes String? @db.Text
statementDocuments StatementDocument[]
// The OCR matcher looks a service up by (kind, accountNumber) — always
// scoped to one kind, never fuzzily across every identifier column, so a
// water account number cannot collide with an unrelated phone number.
@@index([kind, accountNumber])
@@index([kind, meterNumber])
@@map("property_services") @@map("property_services")
} }
@@ -408,6 +440,120 @@ model ServiceDocument {
@@map("service_documents") @@map("service_documents")
} }
// ---------------------------------------------------------------------------
// Statement OCR intake (RECEIPT_CAPTURE_SPEC §2)
//
// Each utility company mails 300+ paper statements a month, one per customer,
// which staff key in by hand. These two tables are the intake side of removing
// that: a batch of scanned PDFs is split per page, OCR'd, matched to a
// PropertyService by its scoped account number, and queued for review. Nothing
// here writes to the ledger — confirming a document posts it through
// `BillingService.createBatch`, the same path hand-keyed batches take.
//
// Everything ingested is a CHARGE (a bill awaiting payment), never a proof of
// payment: the office scans what it must pay, and settles it by check through
// the existing capture flow.
// ---------------------------------------------------------------------------
enum StatementBatchStatus {
UPLOADED
PROCESSING
READY_FOR_REVIEW
COMPLETED
FAILED
}
enum StatementDocumentStatus {
PENDING_OCR
OCR_FAILED
/// No confident match, or the extraction itself was low-confidence.
NEEDS_REVIEW
/// Confident auto-match, awaiting a human confirm.
MATCHED
/// Staff confirmed; not yet posted.
CONFIRMED
POSTED
/// Duplicate, unreadable, or wrong batch.
REJECTED
}
/// One upload session — e.g. "October CFE statements".
model StatementBatch {
id String @id @default(uuid())
/// What kind of service every statement in this batch bills. The parser
/// still detects the provider per page and flags any page that disagrees,
/// rather than trusting the uploader's label.
serviceKind ServiceKind
status StatementBatchStatus @default(UPLOADED)
uploadedById String
uploadedBy User @relation("StatementBatchUploader", fields: [uploadedById], references: [id])
label String?
fileCount Int @default(0)
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
error String? @db.Text
createdAt DateTime @default(now())
completedAt DateTime?
documents StatementDocument[]
@@index([status, createdAt])
@@map("statement_batches")
}
/// One statement — one customer, one period — after splitting the batch.
model StatementDocument {
id String @id @default(uuid())
batchId String
batch StatementBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
/// 1-based page of the source PDF this was split from.
pageNumber Int
/// The rendered page image in object storage. The source PDF is kept too, so
/// a reviewer can always see exactly what the parser read.
storageKey String
status StatementDocumentStatus @default(PENDING_OCR)
/// Raw OCR text, kept even after a manual correction so a mismatch between
/// what the machine read and what staff entered stays auditable.
ocrRawText String? @db.Text
/// Mean per-word confidence reported by the OCR engine, 0..1.
ocrConfidence Decimal? @db.Decimal(4, 3)
/// Which parser claimed the page ("CFE", "CESPT", "TELNOR").
provider String?
// Extracted, then staff-corrected in place. `extractedAccountRef` is already
// normalised for matching (CFE leading zeros stripped, Telnor LADA removed).
extractedAccountRef String?
extractedAmount Decimal? @db.Decimal(12, 2)
extractedPeriod String?
extractedDueDate DateTime?
/// Clave catastral when the statement prints one — a second key to match on
/// when the account number is unreadable.
extractedCadastralKey String?
matchedPropertyServiceId String?
matchedPropertyService PropertyService? @relation(fields: [matchedPropertyServiceId], references: [id])
matchedCustomerId String?
matchedCustomer Customer? @relation(fields: [matchedCustomerId], references: [id])
/// Why this landed where it did — "exact account match", "no candidate",
/// "2 candidates". Shown in the review queue so staff can trust or distrust
/// the suggestion without opening the image.
matchNote String?
reviewedById String?
reviewedBy User? @relation("StatementDocumentReviewer", fields: [reviewedById], references: [id])
reviewedAt DateTime?
postedTransactionId String? @unique
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
createdAt DateTime @default(now())
@@unique([batchId, pageNumber])
@@index([status])
@@index([matchedCustomerId])
@@map("statement_documents")
}
/// From TRUSTVENCE. /// From TRUSTVENCE.
model TrustAccount { model TrustAccount {
id String @id @default(uuid()) id String @id @default(uuid())
@@ -476,6 +622,9 @@ model Transaction {
legacyId String? legacyId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
/// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument?
@@index([customerId, transactionDate]) @@index([customerId, transactionDate])
// By-check reconciliation (billing.byCheck / the cheque-count report) looks // By-check reconciliation (billing.byCheck / the cheque-count report) looks
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup. // rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
@@ -510,10 +659,46 @@ model BusinessLineCategory {
@@map("business_line_categories") @@map("business_line_categories")
} }
/// The institution a chequera is held at. Purely a grouping label for the
/// accounts under it — no money hangs off a Bank directly.
model Bank {
id String @id @default(uuid())
name String @unique
/// "MX" | "US" — informational, used only to label the account picker.
country String?
accounts BankAccount[]
@@map("banks")
}
/// One physical chequera. Currency is fixed per account, because a real bank
/// account is: there is deliberately NO currency column on BankTransaction, a
/// movement inherits its account's. This is what keeps the MXN (Utilities /
/// Scotiabank) and USD (Seguros) registers from ever being summed together,
/// the same rule the customer ledger follows per currency.
model BankAccount {
id String @id @default(uuid())
bankId String
bank Bank @relation(fields: [bankId], references: [id])
/// Staff-facing name, e.g. "Utilities — Scotiabank (MXN)".
label String
currency Currency
/// Hint only, never enforced — one chequera can pay for more than one line.
businessLine TransactionDomain?
active Boolean @default(true)
movements BankTransaction[]
@@map("bank_accounts")
}
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one /// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
/// signed-amount table: income positive, expense negative. /// signed-amount table: income positive, expense negative.
model BankTransaction { model BankTransaction {
id String @id @default(uuid()) id String @id @default(uuid())
// Required: a movement with no known account isn't reconcilable against a
// statement. Every migrated row is SCOTHIA = the Utilities MXN account.
bankAccountId String
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
transactionDate DateTime transactionDate DateTime
transactionType String? transactionType String?
reference String? reference String?
@@ -531,7 +716,10 @@ model BankTransaction {
legacySourceTable String? legacySourceTable String?
legacyId String? legacyId String?
// Provenance stays globally unique: every legacy row belongs to the one
// Scotiabank account, so adding accounts never collides here.
@@unique([legacySourceTable, legacyId]) @@unique([legacySourceTable, legacyId])
@@index([bankAccountId, transactionDate])
@@map("bank_transactions") @@map("bank_transactions")
} }
@@ -546,10 +734,17 @@ model User {
passwordHash String passwordHash String
role UserRole @default(STAFF) role UserRole @default(STAFF)
active Boolean @default(true) active Boolean @default(true)
// UI text-size preference, so it follows the person between machines
// instead of living only in one browser's localStorage. Range is clamped
// API-side (see UpdatePreferencesDto) to match the web's presets.
uiScale Float @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
activityLogs ActivityLog[] activityLogs ActivityLog[]
statementBatches StatementBatch[] @relation("StatementBatchUploader")
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
@@map("users") @@map("users")
} }
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
/**
* Stamp one version across every package.json in the workspace.
*
* The git tag is what actually drives the image tags (docker/metadata-action in
* .gitea/workflows/build.yml reads the tag, not any package.json). This script
* exists so the checked-in manifests stop lying: they all sat at 0.1.0 while
* real releases went out as v1.x, which makes a checkout impossible to place
* against a running container.
*
* Usage:
* node scripts/set-version.mjs 1.2.0
* pnpm version:set 1.2.0
*
* Then, as one release commit:
* git commit -am "chore(release): v1.2.0"
* git tag v1.2.0 && git push origin master v1.2.0
*
* Note the tag carries the leading `v` but the deploy workflow's `tag` input
* does NOT — metadata-action's {{version}} strips it, so the published image is
* `1.2.0`. Dispatch `1.2.0`, tag `v1.2.0`.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const MANIFESTS = [
"package.json",
"apps/api/package.json",
"apps/web/package.json",
"packages/database/package.json",
];
const version = process.argv[2];
if (!version) {
console.error("usage: node scripts/set-version.mjs <x.y.z>");
process.exit(1);
}
// Plain semver only — a leading `v` here would end up in the image tag and in
// every manifest, which is not what any consumer expects.
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
console.error(`invalid version: ${version} (expected x.y.z, no leading "v")`);
process.exit(1);
}
for (const rel of MANIFESTS) {
const file = join(REPO, rel);
const raw = readFileSync(file, "utf8");
const pkg = JSON.parse(raw);
const previous = pkg.version;
pkg.version = version;
// Match the 2-space + trailing-newline shape the files already have so the
// release commit is a one-line diff per manifest.
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(`${rel}: ${previous} -> ${version}`);
}
console.log(`\nnext: git commit -am "chore(release): v${version}" && git tag v${version}`);