Compare commits
36
Commits
159dcc4963
..
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905fa31e47 | ||
|
|
5e9cb12fba | ||
|
|
5bce0e4c94 | ||
|
|
d6501f1d74 | ||
|
|
216309190c | ||
|
|
e589bda28b | ||
|
|
98f7aa8a2d | ||
|
|
898cf48c80 | ||
|
|
70fe425043 | ||
|
|
567b033c46 | ||
|
|
1934470d53 | ||
|
|
fdbe9fdb88 | ||
|
|
e082113640 | ||
|
|
860d483bad | ||
|
|
783ec83464 | ||
|
|
a9b4aab7ec | ||
|
|
a8afd87c3f | ||
|
|
b59abda895 | ||
|
|
4d5008b545 | ||
|
|
121952fdc1 | ||
|
|
15f533b984 | ||
|
|
db2bd545a1 | ||
|
|
30dfc7dc3e | ||
|
|
d5ebb86cae | ||
|
|
19f03198d6 | ||
|
|
b2cdcbe2cd | ||
|
|
7e3b530174 | ||
|
|
1cba9bfc32 | ||
|
|
3ff56e6b72 | ||
|
|
27f04f1073 | ||
|
|
4ee7ec71f0 | ||
|
|
9ba5d2d09a | ||
|
|
c100dfa224 | ||
|
|
0bf97e6d2c | ||
|
|
7df928c3ab | ||
|
|
26a4faa33e |
@@ -4,6 +4,23 @@ SESSION_SECRET=change-me-to-a-random-string
|
||||
WEB_ORIGIN=http://localhost:3000
|
||||
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
|
||||
# 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.
|
||||
|
||||
@@ -37,6 +37,16 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.image }}
|
||||
# release.yml pushes the release commit and its tag in a single `git push`,
|
||||
# so Gitea creates two runs for the same commit: one for master, one for the
|
||||
# tag. Only the tag run matters — it is the one that emits the X.Y.Z / X.Y
|
||||
# image tags, and it publishes `latest` and `sha-<short>` too, since it is
|
||||
# the same commit. Skip the branch run rather than racing or cancelling it.
|
||||
# Ordinary pushes to master (any message but `chore(release):`) still build.
|
||||
if: >-
|
||||
github.event_name != 'push' ||
|
||||
startsWith(github.ref, 'refs/tags/') ||
|
||||
!startsWith(github.event.head_commit.message, 'chore(release):')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:27-dind
|
||||
|
||||
@@ -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
@@ -8,6 +8,17 @@
|
||||
# app = web + api only (the usual app release) [default]
|
||||
# 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
|
||||
# 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
|
||||
@@ -36,6 +47,14 @@
|
||||
# # Database stack (full only)
|
||||
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||
# 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
|
||||
|
||||
@@ -54,6 +73,16 @@ on:
|
||||
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
|
||||
@@ -63,10 +92,57 @@ jobs:
|
||||
name: Deploy (${{ github.event.inputs.scope }})
|
||||
runs-on: docker
|
||||
container:
|
||||
image: node:18-alpine
|
||||
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.
|
||||
- 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 ---------------------------------------------
|
||||
- name: Deploy database stack
|
||||
if: ${{ github.event.inputs.scope == 'full' }}
|
||||
@@ -77,7 +153,7 @@ jobs:
|
||||
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||
file: deploy/jorgecuadros-db.stack.yml
|
||||
type: file
|
||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
env_data: |
|
||||
{
|
||||
"MYSQL_SERVER_ID": "1",
|
||||
@@ -98,7 +174,7 @@ jobs:
|
||||
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||
file: deploy/jorgecuadros-minio.stack.yml
|
||||
type: file
|
||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
env_data: |
|
||||
{
|
||||
"MINIO_API_PORT": "9000",
|
||||
@@ -107,6 +183,65 @@ jobs:
|
||||
"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) -------------------------------------
|
||||
- name: Deploy app stack
|
||||
uses: cssnr/portainer-stack-deploy-action@v1
|
||||
@@ -116,8 +251,8 @@ jobs:
|
||||
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||
file: deploy/jorgecuadros-app.stack.yml
|
||||
type: file
|
||||
pull_image: true
|
||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
pull: true
|
||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||
env_data: |
|
||||
{
|
||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||
@@ -129,6 +264,58 @@ jobs:
|
||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||
"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_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
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
# 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.
|
||||
#
|
||||
# The output is captured because a failing *post-receive* hook does not
|
||||
# fail the push: git prints `remote: error: ...`, updates both refs and
|
||||
# exits 0. That is how v1.0.3 was cut — the hook 500'd, so Gitea never
|
||||
# created the build run, and this step went green anyway.
|
||||
if ! git push origin "HEAD:master" "refs/tags/v${VERSION}" 2>push.log; then
|
||||
cat push.log
|
||||
echo "::error::Push failed. Nothing was released."
|
||||
exit 1
|
||||
fi
|
||||
cat push.log
|
||||
|
||||
if grep -q '^remote: error' push.log; then
|
||||
echo "::warning::The remote's post-receive hook errored. Both refs landed,"
|
||||
echo "::warning::but Gitea most likely created no workflow run for them."
|
||||
echo "::warning::The next step checks and dispatches build.yml if needed."
|
||||
fi
|
||||
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
id: push
|
||||
|
||||
# Gitea creates workflow runs from the post-receive hook, so a hook error
|
||||
# silently costs you the build: the tag exists, no image is ever published,
|
||||
# and the failure only surfaces later as a 404 when deploy pulls the image.
|
||||
# Confirm the run exists; dispatch it if it does not; fail loudly if that
|
||||
# does not work either.
|
||||
- name: Verify build.yml started
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
SHA: ${{ steps.push.outputs.sha }}
|
||||
run: |
|
||||
node -e '
|
||||
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
|
||||
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
|
||||
const sha = process.env.SHA;
|
||||
const tag = `v${process.env.VERSION}`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// The master push and the tag push carry the SAME commit, so a sha
|
||||
// match alone is not enough: build.yml skips the master run by
|
||||
// design, and that skipped run would satisfy a sha-only check even
|
||||
// if the tag run were never created. When the API reports a ref for
|
||||
// the run, require it to be the tag; when it reports none, fall back
|
||||
// to the sha match rather than failing a release over a field name.
|
||||
const isTagRun = (r) => {
|
||||
const ref = r.head_branch || r.ref || "";
|
||||
return !ref || ref === tag || ref === `refs/tags/${tag}`;
|
||||
};
|
||||
|
||||
const started = async () => {
|
||||
const res = await fetch(`${base}/actions/runs?limit=30`, { headers });
|
||||
if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
return (body.workflow_runs || []).some(
|
||||
(r) =>
|
||||
r.head_sha === sha &&
|
||||
String(r.path || "").includes("build.yml") &&
|
||||
isTagRun(r),
|
||||
);
|
||||
};
|
||||
|
||||
// The hook fires synchronously with the push, so a run that is coming
|
||||
// is usually already there; the retries cover a busy instance.
|
||||
const poll = async (attempts) => {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (await started()) return true;
|
||||
await sleep(10_000);
|
||||
}
|
||||
return started();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
if (await poll(3)) {
|
||||
console.log(`build.yml is running for ${sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`No build.yml run for ${sha}. Dispatching against ${tag}.`);
|
||||
const res = await fetch(
|
||||
`${base}/actions/workflows/build.yml/dispatches`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
// Must be the tag, not master: metadata-action only emits the
|
||||
// X.Y.Z and X.Y image tags when the ref is a semver tag. And
|
||||
// it must be the fully qualified ref — Gitea 404s on `v1.0.3`.
|
||||
body: JSON.stringify({ ref: `refs/tags/${tag}` }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) console.log(`Dispatch returned HTTP ${res.status}.`);
|
||||
|
||||
if (await poll(3)) {
|
||||
console.log(`build.yml is running for ${sha}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`::error::${tag} is pushed but nothing is building it, and`);
|
||||
console.log(`::error::the dispatch did not take. Run "Build and Push Images"`);
|
||||
console.log(`::error::by hand with ref=${tag} (the tag, not master), then`);
|
||||
console.log(`::error::deploy. Check the Gitea server log for the`);
|
||||
console.log(`::error::post-receive error while you are at it.`);
|
||||
process.exit(1);
|
||||
})();
|
||||
'
|
||||
|
||||
- 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"
|
||||
@@ -131,12 +131,26 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
||||
9. Sync worker (push replicated tables' relevant subset, poll inbox tables for payment/propane submissions) — depends on step 8. **The separate Phase B Access additive sync is implemented:** `migration/run_all.py --sync` and the admin `SYNC` job upsert legacy-owned rows without truncating the database or touching manual rows. Portal write points confirmed present in `utility_dbo`: `peticion_gas` (propane requests), PayPal payment writes, `notifications_settings`, `verification_codes` — these define the VPS→internal inbox set.
|
||||
10. Reports/email campaigns/admin — parity with old app's `reports.php`/`emailCampaigns.php` intent, rebuilt properly.
|
||||
11. **Receipt capture ("Editor") completion + three net-new ops features — NOT STARTED, spec written.** Full design in [`docs/RECEIPT_CAPTURE_SPEC.md`](docs/RECEIPT_CAPTURE_SPEC.md), from the 2026-07-25/26 meeting with Jorge:
|
||||
- **Receipt capture module** — the legacy "Editor" replacement: wires up the already-existing but unused `Transaction.outstanding` field (NOPAGO workflow), adds batch-by-check capture, and a check-reconciliation view replacing `REPORTE CHEQUE COUNT`. Builds directly on the single-movement capture already shipped in `billing/` (step 6) — smallest piece, do first.
|
||||
- **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.
|
||||
- **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()`.
|
||||
- **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.
|
||||
**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 — 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.
|
||||
**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).
|
||||
|
||||
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.
|
||||
12. **Insurance features — NOT STARTED, spec written.** Full design in [`docs/INSURANCE_FEATURES_SPEC.md`](docs/INSURANCE_FEATURES_SPEC.md), the insurance half of the same 2026-07-25/26 meeting with Jorge that produced step 11:
|
||||
- **Renewal notification emails** — a daily `@nestjs/schedule` sweep that mails the customer 30 days before expiry, 15 days before, and 7 days after, mapping onto `RenewalNotice.generation` 1/2/3 with **no schema change**. Sending is **Amazon SES** (`@aws-sdk/client-sesv2`, mirroring `StorageService`'s optional-client/degrade-don't-crash pattern) — the office already runs SES, so provider and budget are settled, not open. The letter body is the *existing* `aviso-renovacion` report (`reports.registry.ts:623-799`); `@@unique([policyId, generation])` is already-in-place idempotency, so a re-run cannot double-send. Volume ≈260 mails/month, and **815 of the 893 policyholders (91%) have an email**. Also adds the manual mark-as-sent mutation the report's own comment anticipates, so the report's permanently-zero `enviadas` total becomes real. Smallest useful piece — do first.
|
||||
- **Liquidación batch workflow** — ~70% already built (`liquidated`/`liquidationNumber`/`liquidationDate` are wired through DTOs, list filter, stats, form and detail page); only the *batch* print-and-mark step is missing, against a live pending set of 226 policies. Adds a ramo-parameterized pending report plus `POST /policies/liquidate-batch` under a new MANAGER `policy:liquidate` ability. Parameterized by ramo, not MULT-only — legacy `TABLA LIQUIDA MF` served `MULT`, `INCENDIO` and `M EMPR` alike.
|
||||
- **Certificate / "Solicitud Atlas"** — renders from the same `format: "letter"` machinery `aviso-renovacion` uses, then reaches customers as an extension of the step-8/9 replication (PDF generated here, pushed to MinIO, pointer replicated), **not** as a new public surface in this repo. Half-blocked: "Solicitud" has zero referent in the legacy system and normally means an *application form*, a different artifact from a certificate.
|
||||
- **Carrier API integration (ANA Seguros + GMX)** — shape only (`CarrierConnector` + an import-review queue rather than direct `Policy` writes, matching how step 11's OCR results are routed). Carrier research done 2026-07-27: **the two carriers are one company** — both belong to **Grupo Valore** (ANA writes autos, GMX writes daños, which is exactly this database's `AUTO`/`LICENCIAS` vs `MULT`/`INCENDIO`/`M_EMPR` split), so it is one commercial relationship, not two. **ANA has a real live SOAP service** (`server.anaseguros.com.mx/ananetws/service.asmx`, ASP.NET `.asmx`) with a published operation list — catalogs, `CalculaValor`/`CalculaMSI`, `ValidaSerie`, `RecuperaCotizacion`, `Transaccion`. **GMX publishes no machine interface at all**, only human agent portals. ⚠️ **Critical mismatch:** every ANA operation serves *new-business quoting/issuance*, not "list the policies where I am agent of record" — so if the ask is inbound portfolio sync, no evidence exists that either carrier sells it. Blocked on one phone call to Grupo Valore ((55) 5480-4000) for credentials + a direction answer, not on further research. ("GDMX" in the meeting notes was a typo for `GMX` — confirmed 2026-07-27.)
|
||||
|
||||
**Two pre-existing defects were found while verifying this spec and should be fixed as part of the liquidación work:** (a) `policy_types` is missing its `INCENDIO` and `M_EMPR` rows and, because `policies_policyTypeId_fkey` is `ON DELETE SET NULL`, 5 `m_empr` policies silently lost their ramo — 4 of them are pending liquidación and are invisible to every ramo-filtered query; (b) the legacy settlement slots don't match what the target model assumed — `MULT`/`INCENDIO` carry two and `M EMPR` carries four, while `Policy` collapses to one, so ≤41 MULT second settlements were dropped in migration. Spec recommends moving settlement onto `PolicyPaymentInstallment` rather than adding a second slot.
|
||||
|
||||
**One long-standing open question is closed by this spec:** `DATGRAL.[NUM UTIL]` is authoritative for Utilities↔Seguros reconciliation and **`UTILSEG` must not be used** — its numbers resolve to unrelated people under every reading tested (name match 58/1,024 vs. 298/563 for `NUM UTIL`), and where the two sources overlap they contradict each other on 170 of 218 shared ids. This matters to step 11's customer-number recycling, which touches the same identity space.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -146,7 +160,9 @@ 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.
|
||||
|
||||
**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.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
@@ -164,16 +180,30 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
||||
- **VPS provisioning:** provider (Hetzner vs DigitalOcean), size, and Tailscale + MySQL replica setup on it — an ops task, still pending. Design is settled; only the box is missing.
|
||||
- **Old external-DB credential** (hardcoded plaintext MySQL password in the old repo's `dbConnection.php`, in git history) — rotate it regardless, since it's already exposed.
|
||||
|
||||
## Open design questions (step 11 — need Jorge before/while building)
|
||||
## Open design questions (steps 11 & 12 — need Jorge before/while building)
|
||||
|
||||
Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md`:
|
||||
Unlike the ops items above, these block design decisions, not just infrastructure. Full detail in each section of `docs/RECEIPT_CAPTURE_SPEC.md` (step 11) and `docs/INSURANCE_FEATURES_SPEC.md` (step 12):
|
||||
|
||||
- OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider).
|
||||
- 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.
|
||||
- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate.
|
||||
**Step 11 — utilities/ops side:**
|
||||
|
||||
- ~~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` (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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
**Step 12 — insurance side:**
|
||||
|
||||
- Which SES region + verified sending identity/configuration set the renewal mail goes out under, and whether it reuses the existing IAM credentials or gets its own scoped `ses:SendEmail` user. (Provider and budget are *not* open — SES is settled.)
|
||||
- What to do with the 78 policyholders who have no email on file: skip silently, or produce a print worklist? Recommended: the worklist, since `aviso-renovacion` already renders exactly those letters.
|
||||
- Whether renewal notices go out in Spanish or English — `Customer` carries no language preference.
|
||||
- What "garantías" refers to — it has zero referent in the legacy data, and it blocks the liquidación batch's exclusion filter.
|
||||
- Whether policy settlement should move onto `PolicyPaymentInstallment` (recommended) or gain a second slot on `Policy`, and whether to backfill the ≤41 MULT second settlements lost in migration.
|
||||
- Whether batch liquidación warrants a new MANAGER-level `policy:liquidate` ability (recommended) or should reuse the existing STAFF-level `policy:update`.
|
||||
- **What "Solicitud Atlas" actually is** — an application form or a certificate. These are different artifacts with different data and timing; this blocks the whole certificate feature.
|
||||
- **Carrier integration direction** — outbound quote/issue (which ANA's SOAP service supports today) or inbound sync of the office's existing book (which nothing found suggests either carrier offers)? This decides whether the feature is buildable at all. Bundle with the other three carrier questions into one call to Grupo Valore ((55) 5480-4000): WSDL + credentials for the ANA service, whether a cartera/portfolio download exists for an agent's own book, whether GMX daños has any machine interface, and whether one credential spans both carriers. ("GDMX" is resolved — it was a typo for `GMX`.)
|
||||
|
||||
## Verification
|
||||
|
||||
- Migration: automated row-count/sum reconciliation between `staging` and final schema per table group (see step 5 above), run as part of the migration script, not a manual spot-check.
|
||||
|
||||
@@ -200,9 +200,12 @@ the reconciliation pass (done, then corrected) are all closed. See §3 and §8.
|
||||
- **`npm` is pnpm-aliased**, and pnpm ignores the `workspaces` field. Consequences:
|
||||
- there is **no root `node_modules/.bin`**. Binaries live per-app: `apps/api/node_modules/.bin/nest`, `apps/web/node_modules/.bin/next`.
|
||||
- Prisma CLI is run as `npx prisma@5`.
|
||||
- **Dev servers** (both must be up to use the UI):
|
||||
- API `cd apps/api && ./node_modules/.bin/nest start --watch` → `:3001`
|
||||
- Web `cd apps/web && ./node_modules/.bin/next dev` → `:3000`
|
||||
- **Dev servers** (both must be up to use the UI). ⚠️ **Ports come from the env files, not the
|
||||
framework defaults** — `apps/api/.env` sets `PORT=4501` and `WEB_ORIGIN=http://localhost:4500`,
|
||||
and `apps/web/.env.local` points at `NEXT_PUBLIC_API_ORIGIN=http://localhost:4501`. This doc
|
||||
said `:3001`/`:3000` until 2026-07-27; that was wrong and cost a debugging detour.
|
||||
- API `cd apps/api && ./node_modules/.bin/nest start --watch` → **`:4501`**
|
||||
- Web `cd apps/web && ./node_modules/.bin/next dev -p 4500` → **`:4500`**
|
||||
- Dev login: `admin@jorgecuadros.local`, password from `apps/api/scripts/seed-user.mjs` (`SEED_PASSWORD` env overrides the default).
|
||||
- **Dev DB**: `192.168.4.212:3307` (cubex Swarm stack `jorgecuadros-dev-db`). Credentials in gitignored `deploy/.env.dev`. **MinIO** for documents: `192.168.4.212:9100`, bucket `jorgecuadros-documents`.
|
||||
|
||||
@@ -378,9 +381,18 @@ for what's actually next.
|
||||
insurance/servicios/fideicomiso split the migration comment implied. A
|
||||
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.
|
||||
(c) **Single currency (MXN).** `bank_transactions` has no currency column and
|
||||
every `amountInWords` is spelled out in PESOS — so, unlike the customer
|
||||
ledger, everything here is one currency and not split per-currency.
|
||||
(c) ~~**Single currency (MXN).**~~ **SUPERSEDED 2026-07-27 by the multi-bank
|
||||
chequera** (step 11, `docs/RECEIPT_CAPTURE_SPEC.md` §3). The office keeps
|
||||
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
|
||||
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
|
||||
@@ -388,9 +400,18 @@ for what's actually next.
|
||||
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
||||
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
||||
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 →
|
||||
properties → policies → transactions → prune → bank → blobs in order (all idempotent);
|
||||
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
|
||||
properties → policies → transactions → prune → bank accounts → bank → blobs in order
|
||||
(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
|
||||
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
||||
@@ -421,3 +442,87 @@ for what's actually next.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
rootDir: "src",
|
||||
testEnvironment: "node",
|
||||
testRegex: ".*\\.spec\\.ts$",
|
||||
transform: { "^.+\\.ts$": "ts-jest" },
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/api",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -6,4 +6,25 @@ export class AppController {
|
||||
health() {
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { CustomersModule } from "./customers/customers.module";
|
||||
import { PoliciesModule } from "./policies/policies.module";
|
||||
import { PropertiesModule } from "./properties/properties.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
import { StatementsModule } from "./statements/statements.module";
|
||||
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||
import { BankModule } from "./bank/bank.module";
|
||||
import { OpsModule } from "./ops/ops.module";
|
||||
import { ReportsModule } from "./reports/reports.module";
|
||||
@@ -26,6 +28,8 @@ import { AppController } from "./app.controller";
|
||||
PoliciesModule,
|
||||
PropertiesModule,
|
||||
BillingModule,
|
||||
StatementsModule,
|
||||
PolicyOcrModule,
|
||||
BankModule,
|
||||
OpsModule,
|
||||
ReportsModule,
|
||||
|
||||
@@ -24,6 +24,8 @@ export type Ability =
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
| "policy:ingest"
|
||||
| "policy:ocr-review"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
@@ -31,6 +33,9 @@ export type Ability =
|
||||
| "ledger:void"
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "bank:manage-accounts"
|
||||
| "statement:ingest"
|
||||
| "statement:review"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
@@ -43,6 +48,10 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"policy:create": "STAFF",
|
||||
"policy:update": "STAFF",
|
||||
"policy:delete": "MANAGER",
|
||||
// Insurance OCR intake is the same trust tier as statement OCR: STAFF can
|
||||
// upload + confirm, nothing reaches the books unconfirmed.
|
||||
"policy:ingest": "STAFF",
|
||||
"policy:ocr-review": "STAFF",
|
||||
"property:create": "STAFF",
|
||||
"property:update": "STAFF",
|
||||
"property:delete": "MANAGER",
|
||||
@@ -50,6 +59,15 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
||||
"ledger:void": "MANAGER",
|
||||
"bank:create": "STAFF",
|
||||
"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",
|
||||
"user:manage": "ADMIN",
|
||||
"db:manage": "ADMIN",
|
||||
|
||||
@@ -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 { LocalAuthGuard } from "./local-auth.guard";
|
||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||
import { LoginDto } from "./login.dto";
|
||||
import { UpdatePreferencesDto } from "./update-preferences.dto";
|
||||
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. */
|
||||
function withAbilities(user: unknown) {
|
||||
@@ -14,6 +26,8 @@ function withAbilities(user: unknown) {
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
// LoginDto is only used for request-shape documentation/validation here —
|
||||
// the actual credential check happens inside LocalStrategy via Passport,
|
||||
// which populates req.user before this handler runs.
|
||||
@@ -30,6 +44,19 @@ export class AuthController {
|
||||
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")
|
||||
@HttpCode(200)
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,10 +2,14 @@ import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-vali
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class CreateBankMovementDto {
|
||||
/** Which chequera this lands in. Required — see BankAccount in the schema. */
|
||||
@IsString() @MinLength(1) bankAccountId!: string;
|
||||
|
||||
@IsNumber() amount!: number;
|
||||
@IsString() @MinLength(1) transactionDate!: string;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
@@ -20,6 +21,12 @@ import {
|
||||
BankSort,
|
||||
} from "./bank.service";
|
||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||
import {
|
||||
CreateBankAccountDto,
|
||||
CreateBankDto,
|
||||
UpdateBankAccountDto,
|
||||
UpdateBankDto,
|
||||
} from "./bank-account.dto";
|
||||
|
||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||
@@ -54,28 +61,108 @@ export class BankController {
|
||||
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")
|
||||
stats() {
|
||||
return this.bank.stats();
|
||||
async stats(@Query("bankAccountId") bankAccountId?: string) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
return this.bank.stats(account.id);
|
||||
}
|
||||
|
||||
@Get("facets")
|
||||
facets() {
|
||||
return this.bank.facets();
|
||||
async facets(@Query("bankAccountId") bankAccountId?: string) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
return this.bank.facets(account.id);
|
||||
}
|
||||
|
||||
/** Year and month rollups with a running net-movement figure. */
|
||||
@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);
|
||||
return this.bank.summary(
|
||||
account.id,
|
||||
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** The register browser. */
|
||||
@Get()
|
||||
list(
|
||||
async list(
|
||||
@Query("bankAccountId") bankAccountId?: string,
|
||||
@Query("query") query?: string,
|
||||
@Query("page") page?: string,
|
||||
@Query("pageSize") pageSize?: string,
|
||||
@@ -85,7 +172,9 @@ export class BankController {
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
const account = await this.bank.requireAccount(bankAccountId);
|
||||
return this.bank.list({
|
||||
bankAccountId: account.id,
|
||||
query,
|
||||
page: Math.max(1, Number(page) || 1),
|
||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||
@@ -105,6 +194,7 @@ export class BankController {
|
||||
const row = await this.bank.createMovement(dto);
|
||||
void this.audit.log(this.actingId(req), "bank.create", {
|
||||
bankTransactionId: row.id,
|
||||
bankAccountId: row.bankAccountId,
|
||||
amount: dto.amount,
|
||||
});
|
||||
return row;
|
||||
|
||||
@@ -2,6 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
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
|
||||
@@ -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
|
||||
* in the customer ledger.
|
||||
*
|
||||
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
|
||||
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
|
||||
* spelled out in PESOS. All figures in this module are MXN.
|
||||
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
|
||||
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
|
||||
* 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
|
||||
* rows and this module does not filter or group by it, because the data cannot
|
||||
@@ -64,6 +79,8 @@ export type BankSort =
|
||||
| "reference";
|
||||
|
||||
export interface BankListParams {
|
||||
/** Which chequera to read. Required — see the module header. */
|
||||
bankAccountId: string;
|
||||
query?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
@@ -98,7 +115,9 @@ export class BankService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||
const and: Prisma.BankTransactionWhereInput[] = [];
|
||||
const and: Prisma.BankTransactionWhereInput[] = [
|
||||
{ bankAccountId: p.bankAccountId },
|
||||
];
|
||||
|
||||
if (p.query && 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(
|
||||
@@ -233,22 +254,25 @@ export class BankService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Top-line figures for the bank page header. */
|
||||
async stats() {
|
||||
/** Top-line figures for the bank page header, for one chequera. */
|
||||
async stats(bankAccountId: string) {
|
||||
const account = { bankAccountId };
|
||||
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({
|
||||
where: NOT_VOIDED,
|
||||
where: { AND: [account, NOT_VOIDED] },
|
||||
_min: { transactionDate: true },
|
||||
_max: { transactionDate: true },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
||||
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
|
||||
}),
|
||||
this.prisma.bankTransaction.count({
|
||||
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
||||
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
|
||||
}),
|
||||
this.totalsFor({}),
|
||||
this.totalsFor(account),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -261,14 +285,16 @@ export class BankService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Year list for the period filter, newest first. */
|
||||
async facets() {
|
||||
/** Year list for the period filter, newest first, for one chequera. */
|
||||
async facets(bankAccountId: string) {
|
||||
// Tagged-template `$queryRaw`: the interpolation below is a bound
|
||||
// parameter, not string concatenation.
|
||||
const years = await this.prisma.$queryRaw<
|
||||
{ year: number; count: bigint | number | string }[]
|
||||
>`
|
||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||
FROM bank_transactions
|
||||
WHERE voidedAt IS NULL
|
||||
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||
GROUP BY year
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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[]>`
|
||||
SELECT
|
||||
YEAR(transactionDate) AS period,
|
||||
@@ -297,7 +327,7 @@ export class BankService {
|
||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||
SUM(amount) AS net
|
||||
FROM bank_transactions
|
||||
WHERE voidedAt IS NULL
|
||||
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`;
|
||||
@@ -311,7 +341,9 @@ export class BankService {
|
||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||
SUM(amount) AS net
|
||||
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
|
||||
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) -----------------------------------------------
|
||||
|
||||
async createMovement(dto: CreateBankMovementDto) {
|
||||
const date = new Date(dto.transactionDate);
|
||||
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({
|
||||
data: {
|
||||
bankAccountId: account.id,
|
||||
amount: dto.amount,
|
||||
transactionDate: date,
|
||||
concept: dto.concept,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -22,7 +23,11 @@ import {
|
||||
LedgerDirection,
|
||||
MovementSort,
|
||||
} from "./billing.service";
|
||||
import { CreateMovementDto } from "./movement.dto";
|
||||
import {
|
||||
BatchCreateDto,
|
||||
CreateMovementDto,
|
||||
ResolveOutstandingDto,
|
||||
} from "./movement.dto";
|
||||
|
||||
const DOMAINS: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||
const CURRENCIES: LedgerCurrency[] = ["MXN", "USD"];
|
||||
@@ -46,6 +51,11 @@ function one<T>(allowed: T[], value: string | undefined): T | undefined {
|
||||
return allowed.includes(value as T) ? (value as T) : undefined;
|
||||
}
|
||||
|
||||
/** Tri-state query flag: "true"/"false" filter, anything else means no filter. */
|
||||
function flag(v: string | undefined): boolean | undefined {
|
||||
return v === "true" ? true : v === "false" ? false : undefined;
|
||||
}
|
||||
|
||||
/** A `YYYY-MM-DD` bound; anything unparseable is treated as absent. */
|
||||
function parseDate(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v) return undefined;
|
||||
@@ -97,6 +107,18 @@ export class BillingController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every movement cut against one check, with its total — the reconciliation
|
||||
* view replacing the legacy REPORTE CHEQUE COUNT. Declared before the
|
||||
* `customers/:id` and `:id`-shaped routes so the literal path wins.
|
||||
*/
|
||||
@Get("by-check")
|
||||
byCheck(@Query("checkNumber") checkNumber?: string) {
|
||||
const n = checkNumber?.trim();
|
||||
if (!n) throw new BadRequestException("checkNumber es obligatorio");
|
||||
return this.billing.byCheck(n);
|
||||
}
|
||||
|
||||
/** One customer's full statement across both business lines. */
|
||||
@Get("customers/:id")
|
||||
statement(@Param("id") id: string) {
|
||||
@@ -115,6 +137,8 @@ export class BillingController {
|
||||
@Query("typeId") typeId?: string,
|
||||
@Query("source") source?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("outstanding") outstanding?: string,
|
||||
@Query("checkNumber") checkNumber?: string,
|
||||
@Query("from") from?: string,
|
||||
@Query("to") to?: string,
|
||||
@Query("sort") sort?: string,
|
||||
@@ -129,6 +153,8 @@ export class BillingController {
|
||||
typeId: typeId || undefined,
|
||||
source: source || undefined,
|
||||
customerId: customerId || undefined,
|
||||
outstanding: flag(outstanding),
|
||||
checkNumber: checkNumber?.trim() || undefined,
|
||||
from: parseDate(from),
|
||||
to: parseDate(to, true),
|
||||
sort: one(MOVEMENT_SORTS, sort) ?? "date_desc",
|
||||
@@ -150,6 +176,42 @@ export class BillingController {
|
||||
return tx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch capture: many customers' receipts against one physical check.
|
||||
* Same ability as single capture — batching is still capturing.
|
||||
*/
|
||||
@Post("batch")
|
||||
@RequireAbility("ledger:create")
|
||||
async createBatch(@Body() dto: BatchCreateDto, @Req() req: Request) {
|
||||
const result = await this.billing.createBatch(dto);
|
||||
void this.audit.log(this.actingId(req), "ledger.batch", {
|
||||
checkNumber: dto.checkNumber,
|
||||
count: result.count,
|
||||
total: result.total,
|
||||
currency: result.currency,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an outstanding (NOPAGO) row — `ledger:create`, not `ledger:void`:
|
||||
* resolving completes a capture, it doesn't reverse one.
|
||||
*/
|
||||
@Post(":id/resolve-outstanding")
|
||||
@RequireAbility("ledger:create")
|
||||
async resolveOutstanding(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ResolveOutstandingDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const tx = await this.billing.resolveOutstanding(id, dto);
|
||||
void this.audit.log(this.actingId(req), "ledger.resolve-outstanding", {
|
||||
transactionId: id,
|
||||
checkNumber: dto.checkNumber,
|
||||
});
|
||||
return tx;
|
||||
}
|
||||
|
||||
@Post(":id/void")
|
||||
@RequireAbility("ledger:void")
|
||||
async void(@Param("id") id: string, @Req() req: Request) {
|
||||
|
||||
@@ -5,5 +5,8 @@ import { BillingService } from "./billing.service";
|
||||
@Module({
|
||||
controllers: [BillingController],
|
||||
providers: [BillingService],
|
||||
// The statements module posts confirmed OCR captures through
|
||||
// BillingService.createBatch rather than writing Transaction rows itself.
|
||||
exports: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, TransactionDomain } from "@jorgecuadros/database";
|
||||
import {
|
||||
Prisma,
|
||||
TransactionCaptureSource,
|
||||
TransactionDomain,
|
||||
} from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateMovementDto } from "./movement.dto";
|
||||
import {
|
||||
BatchCreateDto,
|
||||
CreateMovementDto,
|
||||
ResolveOutstandingDto,
|
||||
} from "./movement.dto";
|
||||
|
||||
/**
|
||||
* Shared billing / statements module — plan step 6.
|
||||
@@ -54,12 +62,29 @@ export interface MovementParams {
|
||||
typeId?: string;
|
||||
source?: string;
|
||||
customerId?: string;
|
||||
/** Restrict to captured-but-unpaid rows (the legacy NOPAGO worklist). */
|
||||
outstanding?: boolean;
|
||||
/** Groups a capture batch: every row cut against one physical check. */
|
||||
checkNumber?: string;
|
||||
/** Inclusive ISO date bounds on `transactionDate`. */
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
sort: MovementSort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-client-supplied options for a capture. Kept out of the DTO on purpose:
|
||||
* these are set by the calling *module*, never by an HTTP body, so a client
|
||||
* can't label its own rows as machine-captured or forge a capture ref.
|
||||
* See `BillingService.createBatch` for the seam contract.
|
||||
*/
|
||||
export interface CaptureOptions {
|
||||
/** Defaults to BATCH for the HTTP path; the OCR pipeline passes OCR. */
|
||||
source?: TransactionCaptureSource;
|
||||
/** Per-line artifact ids, positionally parallel to `dto.lines`. */
|
||||
refs?: (string | undefined)[];
|
||||
}
|
||||
|
||||
export interface BalanceParams {
|
||||
query?: string;
|
||||
page: number;
|
||||
@@ -112,6 +137,21 @@ function dec(v: Prisma.Decimal | null | undefined): string {
|
||||
*/
|
||||
const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
||||
|
||||
/**
|
||||
* Outstanding ("NOPAGO") rows are captured but unpaid — the office recorded the
|
||||
* bill without funds to cover it. They are excluded from every *balance*
|
||||
* aggregate, exactly as the legacy `SALDOS ULTIMO 0` query did with its
|
||||
* `HAVING NOPAGO = 0`: the office hasn't paid the bill, so it isn't yet owed by
|
||||
* the customer. Resolving one (POST /billing/:id/resolve-outstanding) clears the
|
||||
* flag and the amount starts counting.
|
||||
*
|
||||
* This is deliberately narrower than NOT_VOIDED. Voided rows are excluded
|
||||
* everywhere; outstanding rows are excluded only from balances — the movement
|
||||
* browser still totals them, because "how much water did we capture in April"
|
||||
* means every captured row regardless of whether the check cleared.
|
||||
*/
|
||||
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||
|
||||
/**
|
||||
* Source tables excluded from the customer-facing statement.
|
||||
*
|
||||
@@ -160,6 +200,10 @@ export class BillingService {
|
||||
if (p.typeId) and.push({ typeId: p.typeId });
|
||||
if (p.source) and.push({ legacySourceTable: p.source });
|
||||
if (p.customerId) and.push({ customerId: p.customerId });
|
||||
if (p.outstanding !== undefined) and.push({ outstanding: p.outstanding });
|
||||
// Exact match, not `contains`: this is the by-check reconciliation lookup,
|
||||
// where "1234" must not drag in "51234".
|
||||
if (p.checkNumber) and.push({ checkNumber: p.checkNumber });
|
||||
if (p.from || p.to) {
|
||||
and.push({
|
||||
transactionDate: {
|
||||
@@ -216,6 +260,7 @@ export class BillingService {
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
voidedAt: true,
|
||||
outstanding: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
customer: {
|
||||
select: { id: true, name: true, nameSource: true, city: true },
|
||||
@@ -263,6 +308,7 @@ export class BillingService {
|
||||
source: r.legacySourceTable,
|
||||
type: r.type,
|
||||
voided: r.voidedAt != null,
|
||||
outstanding: r.outstanding,
|
||||
customerId: r.customer.id,
|
||||
customerName: r.customer.name,
|
||||
customerNameSource: r.customer.nameSource,
|
||||
@@ -356,7 +402,7 @@ export class BillingService {
|
||||
MAX(t.transactionDate) AS lastMovement
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE t.voidedAt IS NULL ${nameFilter} ${txFilter}
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||
${having}
|
||||
${orderBy}
|
||||
@@ -368,7 +414,10 @@ export class BillingService {
|
||||
SELECT c.id
|
||||
FROM customers c
|
||||
JOIN transactions t ON t.customerId = c.id
|
||||
WHERE 1 = 1 ${nameFilter} ${txFilter}
|
||||
-- Must match the page query's filters exactly, or the total disagrees
|
||||
-- with the rows. (The void exclusion was missing here before the
|
||||
-- outstanding work; a voided-only customer inflated the count.)
|
||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
||||
GROUP BY c.id
|
||||
${having}
|
||||
) x
|
||||
@@ -601,7 +650,20 @@ export class BillingService {
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: {
|
||||
customerId,
|
||||
legacySourceTable: { notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[] },
|
||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
||||
// (which has no legacySourceTable) silently vanished from the
|
||||
// statement while still showing in the movement browser. Rows the app
|
||||
// books must appear on the customer's statement, so the null case is
|
||||
// spelled out.
|
||||
OR: [
|
||||
{ legacySourceTable: null },
|
||||
{
|
||||
legacySourceTable: {
|
||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
@@ -616,6 +678,7 @@ export class BillingService {
|
||||
message: true,
|
||||
legacySourceTable: true,
|
||||
voidedAt: true,
|
||||
outstanding: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
},
|
||||
});
|
||||
@@ -624,9 +687,10 @@ export class BillingService {
|
||||
const movements = rows.map((r) => {
|
||||
const voided = r.voidedAt != null;
|
||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||
// A voided row does not move the running balance — it shows struck-through
|
||||
// with the balance unchanged from the previous live movement.
|
||||
const next = voided ? prev : prev.plus(r.amount);
|
||||
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
||||
// balance — both show tagged, with the balance unchanged from the previous
|
||||
// live movement. Outstanding rows start counting once resolved.
|
||||
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
|
||||
running.set(r.currency, next);
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -642,6 +706,7 @@ export class BillingService {
|
||||
source: r.legacySourceTable,
|
||||
type: r.type,
|
||||
voided,
|
||||
outstanding: r.outstanding,
|
||||
/** Balance in this row's currency after applying it. */
|
||||
balanceAfter: next.toFixed(2),
|
||||
};
|
||||
@@ -675,7 +740,9 @@ export class BillingService {
|
||||
>();
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.voidedAt != null) continue; // voided rows never enter a total
|
||||
// Voided rows never enter a total; outstanding rows don't either until
|
||||
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
||||
if (r.voidedAt != null || r.outstanding) continue;
|
||||
const c =
|
||||
perCurrency.get(r.currency) ??
|
||||
{
|
||||
@@ -723,7 +790,7 @@ export class BillingService {
|
||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
if (r.voidedAt != null) continue;
|
||||
if (r.voidedAt != null || r.outstanding) continue;
|
||||
if (!r.amount.lessThan(0)) continue;
|
||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||
const key = `${name}|${r.currency}`;
|
||||
@@ -795,10 +862,220 @@ export class BillingService {
|
||||
reference: dto.reference,
|
||||
checkNumber: dto.checkNumber,
|
||||
message: dto.message,
|
||||
outstanding: dto.outstanding ?? false,
|
||||
captureSource: "MANUAL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch capture by check — many customers' receipts against one physical
|
||||
* check. One `$transaction`, so a bad line rejects the whole batch rather
|
||||
* than leaving a half-captured check that reconciles against nothing.
|
||||
*
|
||||
* Returns the check-level total alongside the rows so the UI can show it
|
||||
* against the physical check amount, which is the entire point of the legacy
|
||||
* flow this replaces (`CAPTURA *` feeding `EDITA CHEQUE COUNT`).
|
||||
*
|
||||
* ── Integration seam for OCR auto-capture (RECEIPT_CAPTURE_SPEC §2) ────────
|
||||
* This method is the SINGLE write path for multi-row capture, and the OCR
|
||||
* pipeline is required to post through it rather than writing `Transaction`
|
||||
* rows itself — one validation path, one audit trail. Three guarantees exist
|
||||
* for that caller specifically, and must not be broken:
|
||||
*
|
||||
* 1. `items[i]` corresponds to `dto.lines[i]`. Prisma's array
|
||||
* `$transaction` preserves order, so the caller can zip the result back
|
||||
* onto its own records — which is how `StatementDocument.postedTransactionId`
|
||||
* gets set after a confirmed batch posts.
|
||||
* 2. `opts.refs[i]` stamps `captureRef` on row `i` (a `StatementDocument.id`).
|
||||
* Re-posting a ref that already has a live row is rejected, so a
|
||||
* double-clicked "confirm" or a retried job cannot double-charge a
|
||||
* customer. Voided rows don't block a re-post — a corrected statement
|
||||
* must be re-postable after its bad row is voided.
|
||||
* 3. `opts.source` records the capture path; it is NOT accepted over HTTP,
|
||||
* so a client cannot label its hand-keyed rows as machine-captured.
|
||||
*
|
||||
* Everything the OCR module adds on top (batches, per-document status, the
|
||||
* review queue) lives in its own module; nothing about it needs to change
|
||||
* this signature.
|
||||
*/
|
||||
async createBatch(dto: BatchCreateDto, opts: CaptureOptions = {}) {
|
||||
const date = new Date(dto.transactionDate);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||
|
||||
// Validate every customer up front, in one query — a per-line lookup inside
|
||||
// the transaction would be N round-trips and would fail halfway through.
|
||||
const ids = [...new Set(dto.lines.map((l) => l.customerId))];
|
||||
const found = await this.prisma.customer.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (found.length !== ids.length) {
|
||||
const known = new Set(found.map((c) => c.id));
|
||||
const missing = ids.filter((id) => !known.has(id));
|
||||
throw new BadRequestException(
|
||||
`Cliente(s) no encontrado(s): ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Duplicate-post guard (seam guarantee 2). Only live rows block: a voided
|
||||
// row means the earlier post was reversed, so the corrected statement must
|
||||
// be allowed through.
|
||||
const refs = (opts.refs ?? []).filter((r): r is string => !!r);
|
||||
if (refs.length) {
|
||||
const clash = await this.prisma.transaction.findMany({
|
||||
where: { captureRef: { in: refs }, voidedAt: null },
|
||||
select: { captureRef: true },
|
||||
});
|
||||
if (clash.length) {
|
||||
const dupes = [...new Set(clash.map((c) => c.captureRef))];
|
||||
throw new BadRequestException(
|
||||
`Ya existen movimientos para: ${dupes.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const currency = dto.currency ?? "MXN";
|
||||
const source = opts.source ?? "BATCH";
|
||||
const created = await this.prisma.$transaction(
|
||||
dto.lines.map((line, i) =>
|
||||
this.prisma.transaction.create({
|
||||
data: {
|
||||
customerId: line.customerId,
|
||||
domain: dto.domain,
|
||||
amount: line.amount,
|
||||
transactionDate: date,
|
||||
currency,
|
||||
typeId: dto.typeId,
|
||||
checkNumber: dto.checkNumber,
|
||||
period: line.period,
|
||||
reference: line.reference,
|
||||
message: line.message,
|
||||
outstanding: line.outstanding ?? false,
|
||||
captureSource: source,
|
||||
captureRef: opts.refs?.[i],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Outstanding lines are captured but unfunded, so they don't belong in the
|
||||
// figure staff reconcile against the physical check.
|
||||
const total = created.reduce(
|
||||
(sum, t) => (t.outstanding ? sum : sum.plus(t.amount)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
|
||||
return {
|
||||
/** Parallel to `dto.lines` — see seam guarantee 1. */
|
||||
items: created,
|
||||
checkNumber: dto.checkNumber,
|
||||
currency,
|
||||
source,
|
||||
count: created.length,
|
||||
outstandingCount: created.filter((t) => t.outstanding).length,
|
||||
total: total.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an outstanding row: the check was finally cut. Takes the resolution
|
||||
* date and check number and clears the flag, so the amount starts counting
|
||||
* toward the balance. Legacy: "se actualiza registro con fecha del día y el
|
||||
* cheque a pagar y quitas outstanding".
|
||||
*/
|
||||
async resolveOutstanding(id: string, dto: ResolveOutstandingDto) {
|
||||
const tx = await this.prisma.transaction.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, voidedAt: true, outstanding: true },
|
||||
});
|
||||
if (!tx) throw new NotFoundException(`Transaction ${id} not found`);
|
||||
if (tx.voidedAt) {
|
||||
throw new BadRequestException("El movimiento está anulado");
|
||||
}
|
||||
if (!tx.outstanding) {
|
||||
throw new BadRequestException("El movimiento no está pendiente de pago");
|
||||
}
|
||||
const date = new Date(dto.resolvedDate);
|
||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||
|
||||
return this.prisma.transaction.update({
|
||||
where: { id },
|
||||
data: {
|
||||
outstanding: false,
|
||||
checkNumber: dto.checkNumber,
|
||||
transactionDate: date,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every live movement cut against one check, plus its total — the
|
||||
* reconciliation view replacing `EDITA CHEQUE ALF/COUNT/NUM` and
|
||||
* `REPORTE POR CHEQUE`. Voided rows are dropped entirely (they reconcile
|
||||
* against nothing); outstanding rows are listed but excluded from the total,
|
||||
* since the check didn't fund them.
|
||||
*/
|
||||
async byCheck(checkNumber: string) {
|
||||
const rows = await this.prisma.transaction.findMany({
|
||||
where: { checkNumber, voidedAt: null },
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
transactionDate: true,
|
||||
domain: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
reference: true,
|
||||
period: true,
|
||||
message: true,
|
||||
outstanding: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
customer: { select: { id: true, name: true, nameSource: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Per currency: a check is one currency in practice, but the ledger has
|
||||
// both and this module never sums across them.
|
||||
const totals = new Map<string, { currency: string; total: Prisma.Decimal; count: number }>();
|
||||
for (const r of rows) {
|
||||
if (r.outstanding) continue;
|
||||
const e =
|
||||
totals.get(r.currency) ??
|
||||
{ currency: r.currency, total: new Prisma.Decimal(0), count: 0 };
|
||||
e.total = e.total.plus(r.amount);
|
||||
e.count += 1;
|
||||
totals.set(r.currency, e);
|
||||
}
|
||||
|
||||
return {
|
||||
checkNumber,
|
||||
items: rows.map((r) => ({
|
||||
id: r.id,
|
||||
transactionDate: r.transactionDate,
|
||||
domain: r.domain,
|
||||
amount: r.amount,
|
||||
currency: r.currency,
|
||||
direction: r.amount.lessThan(0) ? "charge" : "credit",
|
||||
reference: r.reference,
|
||||
period: r.period,
|
||||
message: r.message,
|
||||
outstanding: r.outstanding,
|
||||
type: r.type,
|
||||
customerId: r.customer.id,
|
||||
customerName: r.customer.name,
|
||||
customerNameSource: r.customer.nameSource,
|
||||
})),
|
||||
count: rows.length,
|
||||
outstandingCount: rows.filter((r) => r.outstanding).length,
|
||||
totals: [...totals.values()].map((t) => ({
|
||||
currency: t.currency,
|
||||
total: t.total.toFixed(2),
|
||||
count: t.count,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reverse a movement by marking it voided; it stops counting toward totals. */
|
||||
async voidMovement(id: string, userId: string) {
|
||||
const tx = await this.prisma.transaction.findUnique({
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { Currency, TransactionDomain } from "@jorgecuadros/database";
|
||||
|
||||
/**
|
||||
@@ -24,4 +30,56 @@ export class CreateMovementDto {
|
||||
@IsOptional() @IsString() reference?: string;
|
||||
@IsOptional() @IsString() checkNumber?: string;
|
||||
@IsOptional() @IsString() message?: string;
|
||||
/**
|
||||
* Legacy "NOPAGO": the bill was captured but not actually paid (no funds).
|
||||
* The row posts normally and stays visible, but is kept out of every balance
|
||||
* aggregate until resolved — see BillingService's NOT_OUTSTANDING.
|
||||
*/
|
||||
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolving an outstanding row: the check finally got cut, so the movement
|
||||
* takes the resolution date and check number and starts counting toward the
|
||||
* balance. Legacy behavior: "se actualiza registro con fecha del día y el
|
||||
* cheque a pagar y quitas outstanding".
|
||||
*/
|
||||
export class ResolveOutstandingDto {
|
||||
@IsString() @MinLength(1) checkNumber!: string;
|
||||
@IsString() @MinLength(1) resolvedDate!: string;
|
||||
}
|
||||
|
||||
/** One customer's line within a batch; check-level fields live on the parent. */
|
||||
export class BatchLineDto {
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
@IsNumber() amount!: number;
|
||||
|
||||
@IsOptional() @IsString() reference?: string;
|
||||
@IsOptional() @IsString() period?: string;
|
||||
@IsOptional() @IsString() message?: string;
|
||||
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch capture by check — the legacy "Editor" flow: key many customers'
|
||||
* receipts against one check, then reconcile the captured total against the
|
||||
* physical check. Deliberately NOT a persisted batch entity: `checkNumber` is
|
||||
* already a column, and grouping by it answers every legacy by-check query.
|
||||
*/
|
||||
export class BatchCreateDto {
|
||||
@IsEnum(TransactionDomain) domain!: TransactionDomain;
|
||||
@IsString() @MinLength(1) transactionDate!: string;
|
||||
@IsString() @MinLength(1) checkNumber!: string;
|
||||
|
||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||
@IsOptional() @IsString() typeId?: string;
|
||||
|
||||
// Capped so one request can't open a transaction over an unbounded row set;
|
||||
// a physical check batch is tens of lines, not thousands.
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(500)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BatchLineDto)
|
||||
lines!: BatchLineDto[];
|
||||
}
|
||||
|
||||
+21
-1
@@ -25,6 +25,26 @@ async function bootstrap() {
|
||||
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(
|
||||
session({
|
||||
secret: sessionSecret,
|
||||
@@ -32,7 +52,7 @@ async function bootstrap() {
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: cookieSecure,
|
||||
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OCR_PROVIDER } from "../statements/ocr/ocr.provider";
|
||||
import { TesseractOcrProvider } from "../statements/ocr/tesseract.provider";
|
||||
|
||||
/**
|
||||
* Lifts the OCR seam out of StatementsModule so other modules (today:
|
||||
* PolicyOcrModule) can inject OCR_PROVIDER without taking on the rest of
|
||||
* the statement intake. StatementsModule itself imports this and gets the
|
||||
* provider the same way.
|
||||
*
|
||||
* The concrete engine is still bound here — Tesseract today, a managed
|
||||
* extraction API later is a one-line change in this file.
|
||||
*/
|
||||
@Module({
|
||||
providers: [{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider }],
|
||||
exports: [OCR_PROVIDER],
|
||||
})
|
||||
export class OcrModule {}
|
||||
@@ -31,6 +31,14 @@ export const INGEST_FILES = [
|
||||
] as const;
|
||||
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 {
|
||||
host: 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 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
|
||||
* the child env, never on the command line (which would leak via `ps`). */
|
||||
private connFlags(c: MysqlConn): string {
|
||||
@@ -217,6 +256,58 @@ export class OpsService implements OnModuleInit {
|
||||
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.
|
||||
*
|
||||
* The dumper is probed at runtime rather than assumed. This command runs
|
||||
* inside the API image, whose `mysql-client` is Alpine's — i.e. MariaDB's —
|
||||
* where `mysqldump` is a deprecation-warning shim over `mariadb-dump` that
|
||||
* rejects --set-gtid-purged outright:
|
||||
* mysqldump: unknown variable 'set-gtid-purged=OFF'
|
||||
* which failed every backup, including the safety backups SYNC and REIMPORT
|
||||
* take first. MariaDB's dumper emits no GTID state unless asked (--gtid), so
|
||||
* there is nothing to suppress there; the flag is passed only when the dumper
|
||||
* on PATH advertises it, and the real binary is called directly only in the
|
||||
* MariaDB case (calling `mariadb-dump` whenever it merely exists would pick
|
||||
* it over a MySQL `mysqldump` earlier in PATH on a host carrying both).
|
||||
*
|
||||
* The probe is a command substitution, not `--help | grep -q`: PIPEFAIL is in
|
||||
* effect and grep closing the pipe early would make a supported flag look
|
||||
* unsupported.
|
||||
*
|
||||
* --set-gtid-purged=OFF (MySQL only): 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 (
|
||||
`DUMP=mysqldump; GTID=; ` +
|
||||
`case "$(mysqldump --help 2>/dev/null || true)" in ` +
|
||||
`*set-gtid-purged*) GTID=--set-gtid-purged=OFF;; ` +
|
||||
`*) command -v mariadb-dump >/dev/null 2>&1 && DUMP=mariadb-dump;; esac; ` +
|
||||
`( $DUMP ${flags} --single-transaction --routines --triggers ` +
|
||||
`--no-tablespaces $GTID ${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(
|
||||
kind: OpsJobKind,
|
||||
params: Record<string, unknown>,
|
||||
@@ -229,7 +320,7 @@ export class OpsService implements OnModuleInit {
|
||||
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||
const out = shq(path.join(this.backupDir, file));
|
||||
return {
|
||||
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`,
|
||||
cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
|
||||
resolvedParams: { file },
|
||||
};
|
||||
}
|
||||
@@ -241,7 +332,10 @@ export class OpsService implements OnModuleInit {
|
||||
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
||||
});
|
||||
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 },
|
||||
};
|
||||
}
|
||||
@@ -252,8 +346,8 @@ export class OpsService implements OnModuleInit {
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
const cmd =
|
||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`${this.dumpCommand(flags, db, out)} && ` +
|
||||
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
@@ -266,8 +360,8 @@ export class OpsService implements OnModuleInit {
|
||||
const py = await this.pythonBin();
|
||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||
const cmd =
|
||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||
`${this.dumpCommand(flags, db, out)} && ` +
|
||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||
@@ -287,6 +381,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 {
|
||||
const child = spawn("sh", ["-c", cmd], {
|
||||
cwd: this.migrationDir,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { OcrPage } from "../../statements/ocr/ocr.provider";
|
||||
import {
|
||||
detectPolicyProvider,
|
||||
parsePolicy,
|
||||
type ParsedCoverage,
|
||||
} from "./policy-parser";
|
||||
|
||||
/**
|
||||
* Verbatim excerpts of what the GMX portal's translation PDF actually
|
||||
* rendered through pdftotext — same convention as the statement parser
|
||||
* tests, where invented-clean input would test nothing because clean input
|
||||
* is not the failure mode.
|
||||
*/
|
||||
function page(text: string): OcrPage {
|
||||
return { text, words: [], confidence: 0.95 };
|
||||
}
|
||||
|
||||
describe("detectPolicyProvider", () => {
|
||||
it("claims GMX from the brand wordmark on the letterhead", () => {
|
||||
expect(
|
||||
detectPolicyProvider(
|
||||
"Grupo Mexicano de Seguros, S.A. de C.V.\nTecoyotitla 412, Edificio GMX",
|
||||
),
|
||||
).toBe("GMX");
|
||||
});
|
||||
|
||||
it("claims GMX from the 'gmx.com.mx' footer URL", () => {
|
||||
expect(detectPolicyProvider("JUNTOS EL RIESGO ES MENOR\nwww.gmx.com.mx")).toBe("GMX");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePolicy / GMX", () => {
|
||||
// Verbatim text extracted from ~/Downloads/HC_Folio_000767_Traduccion.pdf via
|
||||
// `pdftotext -layout`. Two pages joined by "\n\n".
|
||||
const GMX_FULL = page(
|
||||
"Multiple Policy\nHome\n" +
|
||||
"Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses, to insurance:\n" +
|
||||
"Insured JON ASHLEY STRABALA\n" +
|
||||
"Additional insured VIVIAN\n" +
|
||||
"Legal address BONAMPACK No. EXT26 No.INT 0 COL. Punta Bandera, Tijuana, Baja California, C.P. 22550\n" +
|
||||
"ZIP 22550 Income Tax No. XEXX-010101-000\n" +
|
||||
"Broker (1176) Jorge Humberto Cuadros\n" +
|
||||
"Term 12 months\n" +
|
||||
"From 19/07/2026\n" +
|
||||
"To 19/07/2027 at twelve hours (noon) Mexico City time.\n" +
|
||||
"Currency DOLARES Premium payment CONTADO\n" +
|
||||
"Free translation from the Spanish Insurance contract. The English text is just copy given by courtesy. In case of a dispute, the Spanish will prevail over the English version.\n" +
|
||||
"Agreed clauses:\n" +
|
||||
"•The insured and GMX Hereby declared...\n" +
|
||||
"From the above, the present contract shall not be considered under the condition mentioned within article 36-B from the Insurance Companies General Law. Therefore it shall not be required its registration before the Comision National de Seguros y Fianzas.\n" +
|
||||
"July 23, 2026\n" +
|
||||
"Authority sign.\n" +
|
||||
"Grupo Mexicano de Seguros, S.A. de C.V.\n" +
|
||||
"Tecoyotitla 412, Edificio GMX\n" +
|
||||
"JUNTOS EL RIESGO ES MENOR\n" +
|
||||
"www.gmx.com.mx\n\n" +
|
||||
"Risk Insured Amount Deductible Loss Participation\n" +
|
||||
"Building $350,000.00 Not applies Not applies\n" +
|
||||
"Contents $60,000.00 Not applies Not applies\n" +
|
||||
"ADDITIONAL RISK\n" +
|
||||
"Risk Insured Amount Deductible Loss Participation\n" +
|
||||
"Debris removal Building $35,000.00 Not applies Not applies\n" +
|
||||
"Debris removal Contents $6,000.00 Not applies Not applies\n" +
|
||||
"Outdoors Constructions $10,000.00 5% 10%\n" +
|
||||
"Coverage Extention Covered Not applies Not applies\n" +
|
||||
"All Risk Covered Not applies Not applies\n" +
|
||||
"Earthquake and/or volcanic eruption Covered 2% of the sum insured for each damage structure 20%\n" +
|
||||
"Extra Expenses $41,000.00 Not applies Not applies\n" +
|
||||
"Robbery with violence $10,000.00 Not applies Not applies\n" +
|
||||
"Jewerly $3,900.00 Not applies Not applies\n" +
|
||||
"Electronic Equipment $10,000.00 Not applies Not applies\n" +
|
||||
"Glasses $10,000.00 Not applies Not applies\n" +
|
||||
"Tenant $200,000.00 Not applies Not applies\n" +
|
||||
"Family $200,000.00 Not applies Not applies\n" +
|
||||
"Family $200,000.00 Not applies Not applies\n" +
|
||||
"Domestic workers $7,010.00 Not applies Not applies\n" +
|
||||
"VALUES ADDED, HOME GMX",
|
||||
);
|
||||
|
||||
it("extracts the policy number, insured name, broker, dates, and currency", () => {
|
||||
const p = parsePolicy(GMX_FULL);
|
||||
expect(p.provider).toBe("GMX");
|
||||
expect(p.policyNumber).toBe("007-037-07005947-0000-02");
|
||||
expect(p.insuredName).toBe("JON ASHLEY STRABALA");
|
||||
expect(p.additionalInsured).toBe("VIVIAN");
|
||||
expect(p.agentName).toBe("Jorge Humberto Cuadros");
|
||||
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-07-19");
|
||||
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2027-07-19");
|
||||
expect(p.policyDate?.toISOString().slice(0, 10)).toBe("2026-07-23");
|
||||
expect(p.currency).toBe("USD");
|
||||
expect(p.zip).toBe("22550");
|
||||
expect(p.legalAddress).toContain("BONAMPACK");
|
||||
expect(p.premiumPayment).toBe("CONTADO");
|
||||
});
|
||||
|
||||
it("extracts every coverage row off the second page table", () => {
|
||||
const p = parsePolicy(GMX_FULL);
|
||||
const byName = Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
||||
expect(byName.Building?.insuredAmount).toBe(350000);
|
||||
expect(byName.Contents?.insuredAmount).toBe(60000);
|
||||
expect(byName["Debris removal Building"]?.insuredAmount).toBe(35000);
|
||||
expect(byName["Outdoors Constructions"]?.insuredAmount).toBe(10000);
|
||||
expect(byName["Outdoors Constructions"]?.deductible).toBe("5%");
|
||||
expect(byName["Outdoors Constructions"]?.lossParticipation).toBe("10%");
|
||||
// Free-text coverage cells kept verbatim (the policy form surfaces them
|
||||
// as observations, not as numbers).
|
||||
expect(byName["Earthquake and/or volcanic eruption"]?.insuredAmount).toBeNull();
|
||||
expect(byName["Earthquake and/or volcanic eruption"]?.deductible).toContain("2%");
|
||||
expect(byName["Earthquake and/or volcanic eruption"]?.lossParticipation).toBe("20%");
|
||||
expect(byName["All Risk"]?.insuredAmount).toBeNull();
|
||||
expect(p.coverages.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it("leaves premium fields null on the certificate page and notes it", () => {
|
||||
const p = parsePolicy(GMX_FULL);
|
||||
expect(p.netPremium).toBeNull();
|
||||
expect(p.total).toBeNull();
|
||||
expect(p.policyFee).toBeNull();
|
||||
expect(p.notes.join(" ")).toMatch(/prima/i);
|
||||
});
|
||||
|
||||
it("still parses when the broker parens are missing", () => {
|
||||
const p = parsePolicy(
|
||||
page(
|
||||
"Insured JON ASHLEY STRABALA\nBroker Jorge Humberto Cuadros\n" +
|
||||
"From 19/07/2026\nTo 19/07/2027\nCurrency DOLARES\n" +
|
||||
"Grupo Mexicano de Seguros",
|
||||
),
|
||||
);
|
||||
expect(p.agentName).toBe("Jorge Humberto Cuadros");
|
||||
});
|
||||
|
||||
it("rejects a page that carries no GMX signal at all", () => {
|
||||
const p = parsePolicy(page("Random unrelated document with no policy data."));
|
||||
expect(p.provider).toBe("");
|
||||
expect(p.notes.join(" ")).toContain("no se reconoció el proveedor");
|
||||
});
|
||||
|
||||
it("captures the deductible / loss-participation columns verbatim as strings", () => {
|
||||
const p = parsePolicy(GMX_FULL);
|
||||
const eq = p.coverages.find((c) => c.risk === "Earthquake and/or volcanic eruption");
|
||||
expect(eq).toBeDefined();
|
||||
const eqTyped = eq as ParsedCoverage;
|
||||
expect(eqTyped.deductible).toContain("sum insured");
|
||||
expect(eqTyped.lossParticipation).toBe("20%");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { OcrPage } from "../../statements/ocr/ocr.provider";
|
||||
|
||||
/**
|
||||
* What one parsed policy page yields. All fields are nullable because each
|
||||
* provider prints a different subset (GMX's certificate has no premium
|
||||
* breakdown, only insured amounts; GMX's receipt page would carry the
|
||||
* premium), and the matcher + the review queue both work better with
|
||||
* "field was read" vs "field was not" rather than guessing.
|
||||
*/
|
||||
export interface ParsedPolicy {
|
||||
/** "GMX" today; the dispatcher lives on `detectProvider`. */
|
||||
provider: string;
|
||||
policyNumber: string | null;
|
||||
insuredName: string | null;
|
||||
additionalInsured: string | null;
|
||||
/** The "Broker" line on GMX — mapped onto `Policy.agentName`. */
|
||||
agentName: string | null;
|
||||
legalAddress: string | null;
|
||||
zip: string | null;
|
||||
policyFrom: Date | null;
|
||||
policyTo: Date | null;
|
||||
/** Signature/issue date — `Policy.policyDate`. */
|
||||
policyDate: Date | null;
|
||||
/** "MXN" | "USD" | …, derived from the printed currency word. */
|
||||
currency: string | null;
|
||||
netPremium: number | null;
|
||||
policyFee: number | null;
|
||||
brokerFee: number | null;
|
||||
total: number | null;
|
||||
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
|
||||
premiumPayment: string | null;
|
||||
/**
|
||||
* GMX prints per-coverage rows in a table: Building / Contents /
|
||||
* Earthquake / … with insured amount, deductible, loss participation.
|
||||
* Preserved verbatim so a missing premium receipt still leaves the
|
||||
* coverages auditable on the Policy row.
|
||||
*/
|
||||
coverages: ParsedCoverage[];
|
||||
/** Human-readable trail of what was read, surfaced in the review queue. */
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface ParsedCoverage {
|
||||
/** "Building", "Contents", "Debris removal Building", "Earthquake…". */
|
||||
risk: string;
|
||||
insuredAmount: number | null;
|
||||
deductible: string | null;
|
||||
lossParticipation: string | null;
|
||||
}
|
||||
|
||||
// --- shared helpers ---------------------------------------------------------
|
||||
|
||||
const DIGIT_CONFUSIONS: Record<string, string> = {
|
||||
O: "0", o: "0", D: "0", I: "1", l: "1", "|": "1", S: "5", B: "8",
|
||||
};
|
||||
|
||||
/**
|
||||
* Tesseract confuses these glyphs inside numeric runs with some regularity.
|
||||
* Same map and same caveat as the statement parser: ONLY apply to fields
|
||||
* known to be digits, never to free text.
|
||||
*/
|
||||
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. Same algorithm as the statement parser — kept here so the
|
||||
* policy module is self-contained, since importing from `../../statements`
|
||||
* would couple two unrelated domains through a helper.
|
||||
*/
|
||||
function money(s: string | null | undefined): number | null {
|
||||
if (!s) return null;
|
||||
const cleaned = s.replace(/[\s$]/g, "");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/**
|
||||
* DD/MM/YYYY (GMX) and the dash-separated ISO variants. Two-digit years are
|
||||
* windowed: < 50 → 20YY, ≥ 50 → 19YY, matching what a 1950-2049 window
|
||||
* expects from a paper document.
|
||||
*/
|
||||
function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const s = raw.trim();
|
||||
|
||||
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
||||
|
||||
m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[-\s/](\d{2,4})$/i);
|
||||
if (m && MONTHS[m[2].toUpperCase()] !== undefined) {
|
||||
const yr = +m[3];
|
||||
const y = m[3].length === 2 ? (yr < 50 ? 2000 + yr : 1900 + yr) : yr;
|
||||
return utc(y, MONTHS[m[2].toUpperCase()], +m[1]);
|
||||
}
|
||||
|
||||
m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
|
||||
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
||||
|
||||
// "July 23, 2026" — the signature date on the GMX certificate.
|
||||
m = s.match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/);
|
||||
if (m) {
|
||||
const MONTH_NAMES: Record<string, number> = {
|
||||
january: 0, february: 1, march: 2, april: 3, may: 4, june: 5,
|
||||
july: 6, august: 7, september: 8, october: 9, november: 10, december: 11,
|
||||
};
|
||||
const mo = MONTH_NAMES[m[1].toLowerCase()];
|
||||
if (mo !== undefined) return utc(+m[3], mo, +m[2]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Map the printed currency word onto an ISO code. */
|
||||
function currencyCode(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const s = raw.trim().toUpperCase();
|
||||
if (s.startsWith("PESO") || s === "MXN" || s.includes("NACIONAL")) return "MXN";
|
||||
if (s.startsWith("DOLAR") || s === "USD" || s.includes("DOLLAR")) return "USD";
|
||||
if (s === "EUR" || s.includes("EURO")) return "EUR";
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- provider detection -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Brand first, layout as a fallback. Same ordering rule as the statement
|
||||
* parser: a brand wordmark is the cheapest, most reliable discriminator, and
|
||||
* a layout rule that runs first can wrongly claim a page that happens to
|
||||
* carry the same shape string (the statement parser's lesson with CFE vs
|
||||
* GAS on "PERIODO FACTURADO").
|
||||
*/
|
||||
const BRAND: [string, RegExp][] = [
|
||||
["GMX", /\bGMX\b|Grupo\s*Mexicano\s*de\s*Seguros|gmx\.com\.mx|JUNTOS\s*EL\s*RIESGO\s*ES\s*MENOR/i],
|
||||
];
|
||||
|
||||
const LAYOUT: [string, RegExp][] = [
|
||||
["GMX", /Multiple\s*Policy|IMPUESTO\s*PREDIAL[\s\S]{0,80}EN\s*FECHA|Material\s*damages\s*Section/i],
|
||||
];
|
||||
|
||||
export function detectPolicyProvider(text: string): string | null {
|
||||
for (const group of [BRAND, LAYOUT]) {
|
||||
for (const [name, pattern] of group) {
|
||||
if (pattern.test(text)) return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- parsers ----------------------------------------------------------------
|
||||
|
||||
const PARSERS: Record<string, (page: OcrPage) => ParsedPolicy> = {
|
||||
GMX: parseGmx,
|
||||
};
|
||||
|
||||
const EMPTY_COVERAGE: ParsedCoverage = {
|
||||
risk: "",
|
||||
insuredAmount: null,
|
||||
deductible: null,
|
||||
lossParticipation: null,
|
||||
};
|
||||
|
||||
export function parsePolicy(page: OcrPage): ParsedPolicy {
|
||||
const provider = detectPolicyProvider(page.text);
|
||||
if (!provider) {
|
||||
return {
|
||||
provider: "",
|
||||
policyNumber: null,
|
||||
insuredName: null,
|
||||
additionalInsured: null,
|
||||
agentName: null,
|
||||
legalAddress: null,
|
||||
zip: null,
|
||||
policyFrom: null,
|
||||
policyTo: null,
|
||||
policyDate: null,
|
||||
currency: null,
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
brokerFee: null,
|
||||
total: null,
|
||||
premiumPayment: null,
|
||||
coverages: [],
|
||||
notes: ["no se reconoció el proveedor"],
|
||||
};
|
||||
}
|
||||
return PARSERS[provider](page);
|
||||
}
|
||||
|
||||
// --- GMX --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* GMX policy certificate layout (this is the translation PDF — the Spanish
|
||||
* version is the canonical source, but every GMX portal download is a
|
||||
* translation so the parser can rely on these English labels).
|
||||
*
|
||||
* Page 1 carries the contract header in a single boxed table:
|
||||
* Policy | Insured | Additional insured | Legal address | ZIP | Income Tax No.
|
||||
* Broker | Term | From | To | Currency | Premium payment
|
||||
* followed by an "Agreed clauses" block, the signature date, and the GMX
|
||||
* letterhead.
|
||||
*
|
||||
* Page 2 carries the per-coverage table (Risk / Insured Amount / Deductible /
|
||||
* Loss Participation) under "Material damages Section" and "ADDITIONAL RISK".
|
||||
*
|
||||
* Premium / total / fees are NOT on the certificate page — they live on
|
||||
* GMX's separate "recibo" PDF. The parser leaves them null and flags the
|
||||
* gap in `notes`; the matcher still proposes a Policy update from the
|
||||
* certificate alone, and the staff confirm step fills premium in by hand
|
||||
* or after a follow-up receipt upload.
|
||||
*/
|
||||
function parseGmx(page: OcrPage): ParsedPolicy {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
// ----- header table (page 1) --------------------------------------------
|
||||
// The Policy row repeats the number in a long run:
|
||||
// "Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses…"
|
||||
// so taking the first token-shaped number is correct; the trailing prose
|
||||
// never looks like one. The dashes are part of the printed number — keep
|
||||
// them (don't run toDigits, which would flatten them).
|
||||
const policyNumber = firstMatch(text, [
|
||||
/\bPolicy\s+([0-9OIlSBD]{3,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{8}[-\s][0-9OIlSBD]{4}[-\s][0-9OIlSBD]{2})/i,
|
||||
/\bPolicy\s+([0-9OIlSBD][0-9OIlSBD\s-]{9,30})/,
|
||||
]);
|
||||
|
||||
// "Insured JON ASHLEY STRABALA" — label, then 1+ whitespace, then the name.
|
||||
// Names can carry accents (ÁVILA) or apostrophes (O'NEILL); the label is
|
||||
// always upper-case English on this layout, so case is reliable.
|
||||
const insuredName = labelValue(text, /^Insured\s+([A-ZÁÉÍÓÚÑ'][A-ZÁÉÍÓÚÑ '\-.]+)$/m);
|
||||
const additionalInsured = labelValue(text, /^Additional\s+insured\s+([A-ZÁÉÍÓÚÑ '\-.]+)$/m);
|
||||
|
||||
// Legal address is a single long line; the parser keeps it whole.
|
||||
const legalAddress = labelValue(text, /^Legal\s+address\s+(.+)$/m);
|
||||
const zip = labelValue(text, /^ZIP\s+(\d{4,6})\b/m);
|
||||
if (!zip && legalAddress) {
|
||||
// Last resort: zip often appears at the tail of the address run too
|
||||
// ("…C.P. 22550"). Cheap regex, no false-positive cost on this layout.
|
||||
const m = legalAddress.match(/\b(\d{5})\b/);
|
||||
if (m) notes.push(`ZIP leído de la dirección (${m[1]})`);
|
||||
}
|
||||
|
||||
// Broker line on GMX: "(1176) Jorge Humberto Cuadros" — the number is the
|
||||
// agent code, the name is what lands on `Policy.agentName`. The parens
|
||||
// are optional: a future layout or scan drop them.
|
||||
const brokerRaw = labelValue(text, /^Broker\s+(?:\(\d+\)\s*)?(.+)$/m);
|
||||
const agentName = brokerRaw?.trim() ?? null;
|
||||
|
||||
// Term: "12 months" — informational, not a free-standing date. Stored in
|
||||
// notes; the UI can derive `coveragePeriodDays` from From/To anyway.
|
||||
const term = firstMatch(text, [/^Term\s+(\d+\s+months?)$/m]);
|
||||
if (term) notes.push(`vigencia: ${term}`);
|
||||
|
||||
const policyFrom = parseDate(
|
||||
labelValue(text, /^From\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m),
|
||||
);
|
||||
const policyTo = parseDate(
|
||||
firstMatch(text, [/^To\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m]),
|
||||
);
|
||||
|
||||
// "at twelve hours (noon) Mexico City time." — kept in notes only.
|
||||
if (/twelve\s*hours|noon/i.test(text)) notes.push("vencimiento a las 12:00 hora del centro");
|
||||
|
||||
// The Currency / Premium payment cells sit next to each other on one
|
||||
// line; pull them with bounded matches so the trailing label of the
|
||||
// adjacent cell doesn't swallow the wrong value.
|
||||
const currency = currencyCode(labelValue(text, /^Currency\s+(\S+?)(?:\s+Premium\s+payment|$)/m));
|
||||
const premiumPayment = labelValue(text, /Premium\s+payment\s+(\S+)$/m);
|
||||
|
||||
// ----- signature date (page 1) -----------------------------------------
|
||||
// Appears above the signature line on its own: "July 23, 2026".
|
||||
const dateMatch = text.match(
|
||||
/\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s*\d{4}\b/,
|
||||
);
|
||||
const policyDate = dateMatch ? parseDate(dateMatch[0]) : null;
|
||||
if (!policyDate) notes.push("no se pudo leer la fecha de firma");
|
||||
|
||||
// ----- coverages table (page 2) -----------------------------------------
|
||||
const coverages = parseGmxCoverages(text, notes);
|
||||
|
||||
if (!policyNumber) notes.push("no se pudo leer el número de póliza");
|
||||
if (!policyFrom || !policyTo) notes.push("no se pudo leer el período de vigencia");
|
||||
// Premium fields are expected to be missing on the certificate page; flag
|
||||
// it explicitly so the reviewer knows to look for a separate receipt.
|
||||
if (!text.match(/Prima\s*neta|net\s*premium/i)) {
|
||||
notes.push("esta página no trae prima; revisar el recibo de GMX por separado");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "GMX",
|
||||
policyNumber: policyNumber ? policyNumber.replace(/\s+/g, "") : null,
|
||||
insuredName,
|
||||
additionalInsured,
|
||||
agentName,
|
||||
legalAddress,
|
||||
zip,
|
||||
policyFrom,
|
||||
policyTo,
|
||||
policyDate,
|
||||
currency,
|
||||
netPremium: null,
|
||||
policyFee: null,
|
||||
brokerFee: null,
|
||||
total: null,
|
||||
premiumPayment,
|
||||
coverages,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value that follows a `LABEL` on the same line. Used by every
|
||||
* "Label Value" cell on the GMX header table — matches on the line
|
||||
* itself rather than across the page, so a label that also appears in body
|
||||
* text can't accidentally claim a different cell.
|
||||
*/
|
||||
function labelValue(text: string, pattern: RegExp): string | null {
|
||||
const m = text.match(pattern);
|
||||
if (!m?.[1]) return null;
|
||||
return m[1].replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the GMX per-coverage table on page 2.
|
||||
*
|
||||
* Real sample row (single-line representation of the table after pdftotext
|
||||
* flattens it; the real layout uses fixed columns):
|
||||
* "Building $350,000.00 Not applies Not applies"
|
||||
*
|
||||
* The four columns are:
|
||||
* Risk (left), Insured Amount ($ figure OR the word "Covered"),
|
||||
* Deductible (free text — "Not applies", "5%", "2% of the sum insured…"),
|
||||
* Loss Participation (same).
|
||||
*
|
||||
* "Covered" means the coverage is included with no dollar cap. We record
|
||||
* the word so the review queue surfaces it instead of inventing a number.
|
||||
*
|
||||
* Deductible / Loss Participation are kept as printed strings, not
|
||||
* converted to numbers — a "20%" loss participation is a different field
|
||||
* shape from a "$5,000" deductible and the JSON column lets the UI render
|
||||
* either verbatim.
|
||||
*
|
||||
* Multi-line cells (the "Earthquake" row's deductible wraps to three lines
|
||||
* because the column is narrow) are collapsed by joining consecutive
|
||||
* non-table-body lines onto the previous row's deductible cell before
|
||||
* applying the column regex.
|
||||
*/
|
||||
function parseGmxCoverages(text: string, notes: string[]): ParsedCoverage[] {
|
||||
const out: ParsedCoverage[] = [];
|
||||
|
||||
// Stop at "VALUES ADDED" — the trailing prose section (homeowner
|
||||
// services, legal text) is not a coverage table. Re-enter at
|
||||
// "ADDITIONAL RISK" for the second coverage block on page 2.
|
||||
const segments = text.split(/VALUES\s*ADDED/i)[0].split(/ADDITIONAL\s*RISK/i);
|
||||
|
||||
// `[ \t]` (not `\s`) inside a cell: the deductible/loss-participation
|
||||
// columns may wrap onto several lines in the raw `pdftotext` output, and
|
||||
// matching across newlines silently swallows the next row.
|
||||
const re = /^([A-Za-zÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑ /\-.]+?)[ \t]+(\$[\d,.]+|Covered|Not[ \t]+applies)[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]*$/gim;
|
||||
let m: RegExpExecArray | null;
|
||||
for (const seg of segments) {
|
||||
re.lastIndex = 0;
|
||||
while ((m = re.exec(seg)) !== null) {
|
||||
const risk = m[1].trim();
|
||||
const amountCell = m[2].trim();
|
||||
const deductible = m[3].trim();
|
||||
const lossParticipation = m[4].trim();
|
||||
|
||||
// Skip the "Risk / Insured Amount / Deductible / Loss Participation"
|
||||
// header row itself, which matches the same regex.
|
||||
if (/^Risk$/i.test(risk) && /Insured\s*Amount/i.test(amountCell)) continue;
|
||||
|
||||
out.push({
|
||||
risk,
|
||||
insuredAmount:
|
||||
amountCell === "Covered" || amountCell === "Not applies"
|
||||
? null
|
||||
: money(amountCell),
|
||||
deductible,
|
||||
lossParticipation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0) notes.push("no se encontraron coberturas en la tabla");
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||
|
||||
export interface MatchResult {
|
||||
policyId: string | null;
|
||||
customerId: string | null;
|
||||
/** Why it landed here — shown in the review queue verbatim. */
|
||||
note: string;
|
||||
/** True only for an unambiguous hit on `Policy.policyNumber`. */
|
||||
confident: boolean;
|
||||
/**
|
||||
* Every policy that carries the parsed number, with its customer. >1 means
|
||||
* the policy number is shared across customers and a human must pick.
|
||||
*/
|
||||
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
||||
* office already holds.
|
||||
*
|
||||
* **Match on `Policy.policyNumber` alone, never on the printed insured name.**
|
||||
* The certificate's "Insured" line is the account's registrant, which drifts
|
||||
* from the current owner — the same problem the statement matcher cites for
|
||||
* utility bills ("ARNAIZ ROSAS ELSA AURORA" on a CESPT receipt for a
|
||||
* customer this office holds as "CATT, RANDY"). Names are surfaced for the
|
||||
* reviewer to sanity-check and never feed matching.
|
||||
*
|
||||
* A policy number that matches zero rows means the policy is new: the
|
||||
* review screen then offers a customer picker and the confirm step creates
|
||||
* the row. Multiple hits are surfaced rather than auto-picked — duplicate
|
||||
* policy numbers across customers do occur (same group policy bound by two
|
||||
* related parties), and picking one arbitrarily would silently book the
|
||||
* wrong coverage.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PolicyMatcherService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
||||
if (!parsed.policyNumber) {
|
||||
return this.unmatched("no se pudo leer el número de póliza");
|
||||
}
|
||||
|
||||
const rows = await this.prisma.policy.findMany({
|
||||
where: { policyNumber: parsed.policyNumber },
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
customerId: true,
|
||||
customer: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const candidates = rows.map((r) => ({
|
||||
policyId: r.id,
|
||||
customerId: r.customerId,
|
||||
customerName: r.customer.name,
|
||||
policyNumber: r.policyNumber,
|
||||
}));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||
confident: false,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (rows.length > 1) {
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
||||
confident: false,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
policyId: candidates[0].policyId,
|
||||
customerId: candidates[0].customerId,
|
||||
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
||||
confident: true,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
private unmatched(note: string): MatchResult {
|
||||
return {
|
||||
policyId: null,
|
||||
customerId: null,
|
||||
note,
|
||||
confident: false,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
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 { 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 { PolicyOcrService } from "./policy-ocr.service";
|
||||
import {
|
||||
ConfirmPolicyBatchDto,
|
||||
CreatePolicyOcrBatchDto,
|
||||
ReviewPolicyDocumentDto,
|
||||
} from "./policy-ocr.dto";
|
||||
|
||||
/**
|
||||
* Insurance OCR intake (policy_ocr_intake).
|
||||
*
|
||||
* Mirrors StatementsController shape: one batch = one upload session of
|
||||
* policy PDFs from a provider portal (GMX today), one document per page.
|
||||
* Confirming a batch delegates nothing to a separate billing path —
|
||||
* everything goes through `Policy` (and optionally a Transaction for the
|
||||
* premium), the same tables the manual `PolicyForm` writes.
|
||||
*/
|
||||
@Controller("policy-ocr")
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
export class PolicyOcrController {
|
||||
constructor(
|
||||
private readonly policyOcr: PolicyOcrService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string } | undefined)?.id ?? "";
|
||||
}
|
||||
|
||||
@Get("status")
|
||||
async status() {
|
||||
return {
|
||||
ocrAvailable: await this.policyOcr.ocrAvailable(),
|
||||
storageAvailable: this.policyOcr.storageAvailable(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("batches")
|
||||
listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||
return this.policyOcr.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.policyOcr.getBatch(id);
|
||||
}
|
||||
|
||||
@Get("batches/:id/documents")
|
||||
listDocuments(@Param("id") id: string) {
|
||||
return this.policyOcr.listDocuments(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source PDF for a parsed policy document. One PDF = one parsed policy,
|
||||
* so this returns the entire upload (typically multi-page for insurance
|
||||
* certificates). The review screen embeds it in an iframe.
|
||||
*/
|
||||
@Get("documents/:id/page")
|
||||
async pageImage(
|
||||
@Param("id") id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { stream, contentType, contentLength } = await this.policyOcr.pageImage(id);
|
||||
res.set({
|
||||
// The doc row stores the source PDF, not a rendered page image.
|
||||
"Content-Type": contentType ?? "application/pdf",
|
||||
...(contentLength ? { "Content-Length": String(contentLength) } : {}),
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
// --- writes ---------------------------------------------------------------
|
||||
|
||||
@Post("batches")
|
||||
@RequireAbility("policy:ingest")
|
||||
@UseInterceptors(
|
||||
FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||
)
|
||||
async createBatch(
|
||||
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||
@Body() _dto: CreatePolicyOcrBatchDto,
|
||||
@Query("label") label: string | undefined,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const batch = await this.policyOcr.createBatch(
|
||||
files ?? [],
|
||||
this.actingId(req),
|
||||
label ?? _dto.label,
|
||||
);
|
||||
void this.audit.log(this.actingId(req), "policyOcr.batch.create", {
|
||||
batchId: batch.id,
|
||||
fileCount: batch.fileCount,
|
||||
});
|
||||
return batch;
|
||||
}
|
||||
|
||||
@Patch("documents/:id")
|
||||
@RequireAbility("policy:ocr-review")
|
||||
async review(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ReviewPolicyDocumentDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const doc = await this.policyOcr.review(id, dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "policyOcr.document.review", {
|
||||
documentId: id,
|
||||
status: doc.status,
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Post("documents/:id/reject")
|
||||
@RequireAbility("policy:ocr-review")
|
||||
async reject(@Param("id") id: string, @Req() req: Request) {
|
||||
const doc = await this.policyOcr.reject(id, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "policyOcr.document.reject", {
|
||||
documentId: id,
|
||||
});
|
||||
return doc;
|
||||
}
|
||||
|
||||
@Post("batches/:id/confirm")
|
||||
@RequireAbility("policy:ocr-review")
|
||||
async confirm(
|
||||
@Param("id") id: string,
|
||||
@Body() dto: ConfirmPolicyBatchDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
const result = await this.policyOcr.confirmBatch(id, dto, this.actingId(req));
|
||||
void this.audit.log(this.actingId(req), "policyOcr.batch.confirm", {
|
||||
batchId: id,
|
||||
applied: result.applied,
|
||||
postedTransactions: result.postedTransactions,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/** One document's confirmed-after-review state. The service reads these
|
||||
* fields and writes them onto either a matched Policy or a freshly created
|
||||
* one. Anything null here is not written. */
|
||||
export class ConfirmPolicyDocumentDto {
|
||||
@IsString() documentId!: string;
|
||||
|
||||
/** Required when creating a new Policy; ignored if `policyId` is set. */
|
||||
@IsOptional() @IsString() customerId?: string;
|
||||
/** Set when the document matched an existing Policy. */
|
||||
@IsOptional() @IsString() policyId?: string;
|
||||
|
||||
@IsOptional() @IsString() policyNumber?: string;
|
||||
@IsOptional() @IsString() insuredName?: string;
|
||||
@IsOptional() @IsString() additionalInsured?: string;
|
||||
@IsOptional() @IsString() agentName?: string;
|
||||
@IsOptional() @IsString() legalAddress?: string;
|
||||
@IsOptional() @IsString() zip?: string;
|
||||
@IsOptional() @IsDateString() policyFrom?: string;
|
||||
@IsOptional() @IsDateString() policyTo?: string;
|
||||
@IsOptional() @IsDateString() policyDate?: string;
|
||||
@IsOptional() @IsEnum(["MXN", "USD", "EUR"]) currency?: "MXN" | "USD" | "EUR";
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
|
||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||
|
||||
/** When true, write a Transaction(domain=INSURANCE, amount=-netPremium)
|
||||
* in addition to creating/updating the Policy. Skipped if netPremium is
|
||||
* null or zero. */
|
||||
@IsOptional() postPremium?: boolean;
|
||||
}
|
||||
|
||||
export class ConfirmPolicyBatchDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ConfirmPolicyDocumentDto)
|
||||
documents!: ConfirmPolicyDocumentDto[];
|
||||
}
|
||||
|
||||
/** Staff correction of one document's extracted fields or its match. */
|
||||
export class ReviewPolicyDocumentDto {
|
||||
@IsOptional() @IsString() policyNumber?: string;
|
||||
@IsOptional() @IsString() insuredName?: string;
|
||||
@IsOptional() @IsString() additionalInsured?: string;
|
||||
@IsOptional() @IsString() agentName?: string;
|
||||
@IsOptional() @IsString() legalAddress?: string;
|
||||
@IsOptional() @IsString() zip?: string;
|
||||
@IsOptional() @IsDateString() policyFrom?: string;
|
||||
@IsOptional() @IsDateString() policyTo?: string;
|
||||
@IsOptional() @IsDateString() policyDate?: string;
|
||||
@IsOptional() @IsString() currency?: string;
|
||||
@IsOptional() @IsNumber() netPremium?: number;
|
||||
@IsOptional() @IsNumber() policyFee?: number;
|
||||
@IsOptional() @IsNumber() brokerFee?: number;
|
||||
@IsOptional() @IsNumber() total?: number;
|
||||
@IsOptional() @IsString() premiumPayment?: string;
|
||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||
|
||||
/** Set by the reviewer when the document matched an existing Policy. */
|
||||
@IsOptional() @IsString() matchedPolicyId?: string;
|
||||
/** Set by the reviewer when creating a new Policy. */
|
||||
@IsOptional() @IsString() matchedCustomerId?: string;
|
||||
/** Force-confirm a doc even when the matcher left it ambiguous. */
|
||||
@IsOptional() forceConfirm?: boolean;
|
||||
}
|
||||
|
||||
export class CreatePolicyOcrBatchDto {
|
||||
@IsOptional() @IsString() @MinLength(1) label?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { OcrModule } from "../ocr/ocr.module";
|
||||
import { PolicyOcrController } from "./policy-ocr.controller";
|
||||
import { PolicyOcrService } from "./policy-ocr.service";
|
||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||
|
||||
/**
|
||||
* Reuses the OCR seam from OcrModule unchanged: the Tesseract provider is
|
||||
* bound there and `OcrProvider` is the only thing the parsers touch. This
|
||||
* module registers its own controller + service + matcher; nothing about
|
||||
* utility ingestion needs to know about it.
|
||||
*/
|
||||
@Module({
|
||||
imports: [OcrModule],
|
||||
controllers: [PolicyOcrController],
|
||||
providers: [PolicyOcrService, PolicyMatcherService],
|
||||
})
|
||||
export class PolicyOcrModule {}
|
||||
@@ -0,0 +1,720 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Currency, Prisma } from "@jorgecuadros/database";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import type { UploadedFileLike } from "../storage/upload-file";
|
||||
import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider";
|
||||
import { parsePolicy } from "./parsers/policy-parser";
|
||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||
import type {
|
||||
ConfirmPolicyBatchDto,
|
||||
ConfirmPolicyDocumentDto,
|
||||
ReviewPolicyDocumentDto,
|
||||
} from "./policy-ocr.dto";
|
||||
|
||||
/**
|
||||
* Insurance OCR intake — mirrors the statement pipeline at
|
||||
* `apps/api/src/statements/statements.service.ts`. Reuses the OCR seam and
|
||||
* Tesseract binding unchanged; the parsers and matcher are policy-specific.
|
||||
*
|
||||
* Why a parallel pipeline rather than a column on StatementDocument: the
|
||||
* matcher keys on `Policy.policyNumber`, the confirm step writes to a
|
||||
* different table (`Policy`, not `Transaction`), and the review UI shows
|
||||
* different fields. Sharing one queue would either bloat the row with null
|
||||
* columns or force the review screen to branch on a discriminator — both
|
||||
* worse than a thin second table.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PolicyOcrService {
|
||||
private readonly logger = new Logger(PolicyOcrService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storage: StorageService,
|
||||
private readonly matcher: PolicyMatcherService,
|
||||
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
|
||||
) {}
|
||||
|
||||
ocrAvailable(): Promise<boolean> {
|
||||
return this.ocr.available();
|
||||
}
|
||||
|
||||
storageAvailable(): boolean {
|
||||
return this.storage.available;
|
||||
}
|
||||
|
||||
// --- ingest ---------------------------------------------------------------
|
||||
|
||||
async createBatch(
|
||||
files: UploadedFileLike[],
|
||||
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 leer PDFs de pólizas.",
|
||||
);
|
||||
}
|
||||
if (!this.storage.available) {
|
||||
throw new BadRequestException(
|
||||
"El almacenamiento de documentos no está configurado; no se pueden " +
|
||||
"guardar los PDFs escaneados.",
|
||||
);
|
||||
}
|
||||
|
||||
const batch = await this.prisma.policyOcrBatch.create({
|
||||
data: { provider: "GMX", uploadedById, label, fileCount: files.length },
|
||||
});
|
||||
|
||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||
void this.process(batch.id, copies).catch(async (err) => {
|
||||
this.logger.error(`Policy OCR batch ${batch.id} failed: ${(err as Error).message}`);
|
||||
await this.prisma.policyOcrBatch.update({
|
||||
where: { id: batch.id },
|
||||
data: { status: "FAILED", error: (err as Error).message },
|
||||
});
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render → text → parse → match, **one PolicyOcrDocument row per uploaded
|
||||
* file**. The GMX certificate is a 2-page PDF where page 1 carries the
|
||||
* contract header and page 2 carries the per-coverage table — both pages
|
||||
* describe the SAME policy, so the parser concatenates them and the
|
||||
* matcher runs once. `pageNumber` on the row is repurposed as the file
|
||||
* ordinal within the batch (1, 2, 3…) — the unique constraint
|
||||
* `(batchId, pageNumber)` still holds and lets a single batch carry many
|
||||
* policies.
|
||||
*
|
||||
* The doc's `storageKey` is the SOURCE PDF (`policy-ocr/{batchId}/source-N.pdf`)
|
||||
* rather than a rendered page image, so the review screen can embed the
|
||||
* exact artifact the office received. The rendered page PNGs are still
|
||||
* stored under `policy-ocr/{batchId}/page-M.png` for any future re-OCR or
|
||||
* image-based audit, but they aren't used as `storageKey` for the document.
|
||||
*/
|
||||
private async process(
|
||||
batchId: string,
|
||||
files: { buffer: Buffer; name?: string }[],
|
||||
) {
|
||||
await this.prisma.policyOcrBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "PROCESSING" },
|
||||
});
|
||||
|
||||
let fileOrdinal = 0;
|
||||
let globalPageOrdinal = 0;
|
||||
for (const file of files) {
|
||||
fileOrdinal += 1;
|
||||
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
|
||||
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
||||
|
||||
const pages = await this.ocr.renderPages(file.buffer);
|
||||
const textLayer = await this.ocr.textPages(file.buffer).catch(() => []);
|
||||
|
||||
// One OcrPage per rendered page: text-layer wins when present (cheap,
|
||||
// exact), OCR the rendered image when it isn't. Same precedence rule
|
||||
// as the statement OCR pipeline.
|
||||
const perPageOcr: OcrPage[] = [];
|
||||
for (const [index, image] of pages.entries()) {
|
||||
globalPageOrdinal += 1;
|
||||
const pageStorageKey = `policy-ocr/${batchId}/page-${globalPageOrdinal}.png`;
|
||||
await this.storage.put(pageStorageKey, image, "image/png");
|
||||
|
||||
const embedded = textLayer[index] ?? null;
|
||||
const pageOcr = embedded ?? (await this.ocr.recognize(image));
|
||||
perPageOcr.push(pageOcr);
|
||||
}
|
||||
|
||||
// Concatenate every page's text with a blank line between pages so the
|
||||
// parser's anchored regexes (^From$, ^Currency\s+...) still work
|
||||
// across page boundaries — pdftotext -bbox-layout produces newline-
|
||||
// separated text per page already, the `\n\n` just preserves a clear
|
||||
// boundary in ocrRawText for debugging.
|
||||
const mergedText = perPageOcr.map((p) => p.text).join("\n\n");
|
||||
const avgConfidence =
|
||||
perPageOcr.length === 0
|
||||
? 0
|
||||
: perPageOcr.reduce((s, p) => s + p.confidence, 0) / perPageOcr.length;
|
||||
const synthetic: OcrPage = {
|
||||
text: mergedText,
|
||||
words: [],
|
||||
confidence: avgConfidence,
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = parsePolicy(synthetic);
|
||||
if (parsed.provider === "") {
|
||||
throw new Error("no se reconoció el proveedor");
|
||||
}
|
||||
const match = await this.matcher.match(parsed);
|
||||
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||
// Confident when exactly one Policy carries the printed number —
|
||||
// the only unambiguous hit we trust. A new policy (no match) still
|
||||
// needs a customer pick, so it stays in review.
|
||||
const trusted = match.confident && parsed.policyNumber != null;
|
||||
|
||||
await this.prisma.policyOcrDocument.create({
|
||||
data: {
|
||||
batchId,
|
||||
pageNumber: fileOrdinal,
|
||||
storageKey: sourceKey,
|
||||
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
|
||||
ocrRawText: mergedText,
|
||||
ocrConfidence: new Prisma.Decimal(avgConfidence.toFixed(3)),
|
||||
provider: parsed.provider,
|
||||
extractedPolicyNumber: parsed.policyNumber,
|
||||
extractedInsuredName: parsed.insuredName,
|
||||
extractedAdditionalInsured: parsed.additionalInsured,
|
||||
extractedAgentName: parsed.agentName,
|
||||
extractedLegalAddress: parsed.legalAddress,
|
||||
extractedZip: parsed.zip,
|
||||
extractedPolicyFrom: parsed.policyFrom,
|
||||
extractedPolicyTo: parsed.policyTo,
|
||||
extractedPolicyDate: parsed.policyDate,
|
||||
extractedCurrency: parsed.currency,
|
||||
extractedNetPremium:
|
||||
parsed.netPremium != null ? new Prisma.Decimal(parsed.netPremium) : null,
|
||||
extractedPolicyFee:
|
||||
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
|
||||
extractedBrokerFee:
|
||||
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
|
||||
extractedTotal:
|
||||
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
|
||||
extractedCoveragesJson: parsed.coverages.length
|
||||
? (parsed.coverages as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
extractedPremiumPayment: parsed.premiumPayment,
|
||||
matchedPolicyId: match.policyId,
|
||||
matchedCustomerId: match.customerId,
|
||||
matchCandidates: match.candidates.length
|
||||
? (match.candidates as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
matchNote: notes.join("; ").slice(0, 190),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// The file as a whole failed to parse (no provider, parse exception).
|
||||
// One OCR_FAILED row per file is the right granularity — the page
|
||||
// images are still on disk for a re-run after a parser fix.
|
||||
await this.prisma.policyOcrDocument.create({
|
||||
data: {
|
||||
batchId,
|
||||
pageNumber: fileOrdinal,
|
||||
storageKey: sourceKey,
|
||||
status: "OCR_FAILED",
|
||||
matchNote: (err as Error).message.slice(0, 190),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.policyOcrBatch.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.policyOcrBatch.count(),
|
||||
this.prisma.policyOcrBatch.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.policyOcrBatch.findUnique({
|
||||
where: { id },
|
||||
include: { uploadedBy: { select: { name: true } } },
|
||||
});
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
|
||||
const counts = await this.prisma.policyOcrDocument.groupBy({
|
||||
by: ["status"],
|
||||
where: { batchId: id },
|
||||
_count: { _all: true },
|
||||
});
|
||||
return {
|
||||
...batch,
|
||||
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||
};
|
||||
}
|
||||
|
||||
async listDocuments(batchId: string) {
|
||||
return this.prisma.policyOcrDocument.findMany({
|
||||
where: { batchId },
|
||||
orderBy: { pageNumber: "asc" },
|
||||
include: {
|
||||
matchedCustomer: { select: { id: true, name: true } },
|
||||
matchedPolicy: {
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
customerId: true,
|
||||
customer: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The source PDF for the document, so the review screen can show the
|
||||
* exact artifact the office uploaded (the browser's PDF viewer handles
|
||||
* scrolling, zoom, and selection natively). The rendered page PNGs
|
||||
* remain on disk under `policy-ocr/{batchId}/page-N.png` for any
|
||||
* future re-OCR, but the doc row points here at the source.
|
||||
*/
|
||||
async pageImage(documentId: string) {
|
||||
const doc = await this.prisma.policyOcrDocument.findUnique({
|
||||
where: { id: documentId },
|
||||
select: { storageKey: true },
|
||||
});
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
return this.storage.getStream(doc.storageKey);
|
||||
}
|
||||
|
||||
// --- review ---------------------------------------------------------------
|
||||
|
||||
async review(id: string, dto: ReviewPolicyDocumentDto, reviewedById: string) {
|
||||
const doc = await this.prisma.policyOcrDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue aplicado.");
|
||||
}
|
||||
|
||||
// Trusting a customer-supplied pair (policyId, customerId) without
|
||||
// cross-check is how a document lands on the wrong customer's ledger;
|
||||
// pin them here from the DB.
|
||||
let matchedPolicyId = dto.matchedPolicyId ?? doc.matchedPolicyId;
|
||||
let matchedCustomerId = doc.matchedCustomerId;
|
||||
|
||||
if (matchedPolicyId) {
|
||||
const p = await this.prisma.policy.findUnique({
|
||||
where: { id: matchedPolicyId },
|
||||
select: { customerId: true },
|
||||
});
|
||||
if (!p) throw new BadRequestException("Póliza no encontrada.");
|
||||
matchedCustomerId = p.customerId;
|
||||
} else if (dto.matchedCustomerId) {
|
||||
const c = await this.prisma.customer.findUnique({
|
||||
where: { id: dto.matchedCustomerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!c) throw new BadRequestException("Cliente no encontrado.");
|
||||
matchedCustomerId = c.id;
|
||||
}
|
||||
|
||||
return this.prisma.policyOcrDocument.update({
|
||||
where: { id },
|
||||
data: {
|
||||
extractedPolicyNumber: dto.policyNumber ?? undefined,
|
||||
extractedInsuredName: dto.insuredName ?? undefined,
|
||||
extractedAdditionalInsured: dto.additionalInsured ?? undefined,
|
||||
extractedAgentName: dto.agentName ?? undefined,
|
||||
extractedLegalAddress: dto.legalAddress ?? undefined,
|
||||
extractedZip: dto.zip ?? undefined,
|
||||
extractedPolicyFrom: dto.policyFrom ? new Date(dto.policyFrom) : undefined,
|
||||
extractedPolicyTo: dto.policyTo ? new Date(dto.policyTo) : undefined,
|
||||
extractedPolicyDate: dto.policyDate ? new Date(dto.policyDate) : undefined,
|
||||
extractedCurrency: dto.currency ?? undefined,
|
||||
extractedNetPremium:
|
||||
dto.netPremium != null ? new Prisma.Decimal(dto.netPremium) : undefined,
|
||||
extractedPolicyFee:
|
||||
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
|
||||
extractedBrokerFee:
|
||||
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
|
||||
extractedTotal:
|
||||
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
|
||||
extractedCoveragesJson: dto.coveragesJson
|
||||
? (dto.coveragesJson as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
extractedPremiumPayment: dto.premiumPayment ?? undefined,
|
||||
matchedPolicyId,
|
||||
matchedCustomerId,
|
||||
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async reject(id: string, reviewedById: string) {
|
||||
const doc = await this.prisma.policyOcrDocument.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException("Este documento ya fue aplicado.");
|
||||
}
|
||||
return this.prisma.policyOcrDocument.update({
|
||||
where: { id },
|
||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
// --- confirm --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply every confirmed document: create or update the Policy, attach the
|
||||
* source PDF as a PolicyDocument, and (when staff asked + premium parses)
|
||||
* write a Transaction row. Each step is guarded by status checks so a
|
||||
* double-confirm cannot re-apply a document.
|
||||
*/
|
||||
async confirmBatch(batchId: string, dto: ConfirmPolicyBatchDto, reviewedById: string) {
|
||||
const batch = await this.prisma.policyOcrBatch.findUnique({ where: { id: batchId } });
|
||||
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||
|
||||
const results: { documentId: string; policyId: string; postedTransactionId: string | null }[] = [];
|
||||
|
||||
for (const item of dto.documents) {
|
||||
const doc = await this.prisma.policyOcrDocument.findUnique({
|
||||
where: { id: item.documentId },
|
||||
});
|
||||
if (!doc) {
|
||||
throw new BadRequestException(`Documento ${item.documentId} no encontrado.`);
|
||||
}
|
||||
if (doc.status === "POSTED") {
|
||||
throw new BadRequestException(
|
||||
`El documento página ${doc.pageNumber} ya fue aplicado.`,
|
||||
);
|
||||
}
|
||||
if (!item.policyId && !item.customerId) {
|
||||
throw new BadRequestException(
|
||||
`Documento página ${doc.pageNumber}: falta póliza destino o cliente.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Resolve target Policy (create or update). Field selection: every
|
||||
// non-null `extracted*` on the doc (post-review) is written. Null is
|
||||
// preserved — never overwrite an existing Policy's `netPremium` with
|
||||
// null because the certificate page didn't carry one.
|
||||
let policyId = item.policyId ?? null;
|
||||
|
||||
if (policyId) {
|
||||
const updateData = buildPolicyUpdateFromDoc(item, doc);
|
||||
await this.prisma.policy.update({
|
||||
where: { id: policyId },
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
// Create under the picked customer. `policyNumber` is the only field
|
||||
// that must be present.
|
||||
if (!item.policyNumber && !doc.extractedPolicyNumber) {
|
||||
throw new BadRequestException(
|
||||
`Documento página ${doc.pageNumber}: falta número de póliza.`,
|
||||
);
|
||||
}
|
||||
const createData = buildPolicyCreateFromDoc(item, doc, item.customerId!);
|
||||
const created = await this.prisma.policy.create({
|
||||
data: createData,
|
||||
});
|
||||
policyId = created.id;
|
||||
}
|
||||
|
||||
// 2. Attach the source PDF as a PolicyDocument. `doc.storageKey`
|
||||
// already points at the exact upload (`policy-ocr/{batchId}/source-N.pdf`)
|
||||
// so the attach is just a stream copy into the policy's namespace —
|
||||
// the previous per-page "which file did this page come from" walk is
|
||||
// gone because one PDF = one doc now.
|
||||
await this.attachSourcePdf(doc.storageKey, policyId);
|
||||
|
||||
// 3. Optionally post the premium to the ledger. Only when staff
|
||||
// explicitly asked (`postPremium` true) and netPremium parses — without
|
||||
// that gate a missing premium would silently book $0.
|
||||
let postedTransactionId: string | null = null;
|
||||
const premium =
|
||||
item.netPremium != null
|
||||
? item.netPremium
|
||||
: doc.extractedNetPremium != null
|
||||
? Number(doc.extractedNetPremium)
|
||||
: null;
|
||||
if (item.postPremium && premium && premium > 0) {
|
||||
const tx = await this.prisma.transaction.create({
|
||||
data: {
|
||||
customerId: (await this.policyCustomerId(policyId))!,
|
||||
domain: "INSURANCE",
|
||||
amount: new Prisma.Decimal(-Math.abs(premium)),
|
||||
transactionDate: doc.extractedPolicyDate ?? doc.extractedPolicyFrom ?? new Date(),
|
||||
currency: (item.currency ??
|
||||
doc.extractedCurrency ??
|
||||
"MXN") as Currency,
|
||||
reference: item.policyNumber ?? doc.extractedPolicyNumber ?? null,
|
||||
period: null,
|
||||
captureSource: "OCR",
|
||||
captureRef: doc.id,
|
||||
message: `Prima de póliza ${item.policyNumber ?? doc.extractedPolicyNumber ?? ""}`,
|
||||
},
|
||||
});
|
||||
postedTransactionId = tx.id;
|
||||
}
|
||||
|
||||
await this.prisma.policyOcrDocument.update({
|
||||
where: { id: doc.id },
|
||||
data: {
|
||||
status: "POSTED",
|
||||
matchedPolicyId: policyId,
|
||||
reviewedById,
|
||||
reviewedAt: new Date(),
|
||||
createdPolicyId: item.policyId ? null : policyId,
|
||||
postedTransactionId,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
documentId: doc.id,
|
||||
policyId,
|
||||
postedTransactionId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.closeIfDone(batchId);
|
||||
|
||||
return {
|
||||
applied: results.length,
|
||||
policies: results.map((r) => r.policyId),
|
||||
postedTransactions: results.filter((r) => r.postedTransactionId).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream the source PDF (`sourceKey`, set by `process` on the doc row)
|
||||
* into the policy's storage namespace and create a `PolicyDocument`
|
||||
* pointer. Trivial now that the doc row holds the exact source key —
|
||||
* the old per-page "which file did this page come from" walk is gone.
|
||||
*/
|
||||
private async attachSourcePdf(sourceKey: string, policyId: string): Promise<void> {
|
||||
const got = await this.storage.getStream(sourceKey);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of got.stream) chunks.push(c as Buffer);
|
||||
const buf = Buffer.concat(chunks);
|
||||
|
||||
const newKey = `policy/${policyId}/${Date.now()}-${crypto.randomUUID()}.pdf`;
|
||||
await this.storage.put(newKey, buf, "application/pdf");
|
||||
await this.prisma.policyDocument.create({
|
||||
data: {
|
||||
policyId,
|
||||
documentType: "GMX_POLICY",
|
||||
storageKey: newKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async policyCustomerId(policyId: string): Promise<string | null> {
|
||||
const p = await this.prisma.policy.findUnique({
|
||||
where: { id: policyId },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return p?.customerId ?? null;
|
||||
}
|
||||
|
||||
private async closeIfDone(batchId: string) {
|
||||
const open = await this.prisma.policyOcrDocument.count({
|
||||
where: {
|
||||
batchId,
|
||||
status: { in: ["PENDING_OCR", "NEEDS_REVIEW", "MATCHED", "CONFIRMED"] },
|
||||
},
|
||||
});
|
||||
if (open === 0) {
|
||||
await this.prisma.policyOcrBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: "COMPLETED", completedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a (post-review) doc + final confirmed fields onto a `Policy.update`
|
||||
* payload. Every field that is null in both inputs is omitted so we never
|
||||
* write null over a value the Policy already carries (the GMX certificate
|
||||
* has no premium — we must not blank the existing Policy.netPremium). */
|
||||
function buildPolicyUpdateFromDoc(
|
||||
item: ConfirmPolicyDocumentDto,
|
||||
doc: {
|
||||
extractedPolicyNumber: string | null;
|
||||
extractedInsuredName: string | null;
|
||||
extractedAdditionalInsured: string | null;
|
||||
extractedAgentName: string | null;
|
||||
extractedLegalAddress: string | null;
|
||||
extractedZip: string | null;
|
||||
extractedPolicyFrom: Date | null;
|
||||
extractedPolicyTo: Date | null;
|
||||
extractedPolicyDate: Date | null;
|
||||
extractedCurrency: string | null;
|
||||
extractedNetPremium: Prisma.Decimal | null;
|
||||
extractedPolicyFee: Prisma.Decimal | null;
|
||||
extractedBrokerFee: Prisma.Decimal | null;
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
},
|
||||
): Prisma.PolicyUpdateInput {
|
||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||
if (a != null) return new Prisma.Decimal(a);
|
||||
if (b != null) return b;
|
||||
return undefined;
|
||||
};
|
||||
const dateOrUndef = (a: string | undefined, b: Date | null): Date | undefined => {
|
||||
if (a) return new Date(a);
|
||||
if (b) return b;
|
||||
return undefined;
|
||||
};
|
||||
const strOrUndef = (a: string | undefined, b: string | null): string | undefined => {
|
||||
if (a != null && a !== "") return a;
|
||||
if (b != null && b !== "") return b;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
policyNumber: strOrUndef(item.policyNumber, doc.extractedPolicyNumber),
|
||||
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
||||
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||
total: numOrUndef(item.total, doc.extractedTotal),
|
||||
// coveragesJson / observations: freeform, keep the GMX data when present.
|
||||
coveragesJson:
|
||||
item.coveragesJson !== undefined
|
||||
? (item.coveragesJson as Prisma.InputJsonValue)
|
||||
: doc.extractedCoveragesJson != null
|
||||
? (doc.extractedCoveragesJson as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
// Premium payment cadence ("CONTADO") and insured-name fields land in
|
||||
// `observations` so the PolicyForm's edits stay the source of truth for
|
||||
// structured fields. The reviewer can move them by hand if needed.
|
||||
observations: joinObservations(
|
||||
doc.extractedInsuredName,
|
||||
doc.extractedAdditionalInsured,
|
||||
doc.extractedLegalAddress,
|
||||
doc.extractedZip,
|
||||
doc.extractedPremiumPayment,
|
||||
item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Same shape as `buildPolicyUpdateFromDoc`, but for `Policy.create`. The
|
||||
* `customerId` is supplied separately and `policyNumber` is required (a
|
||||
* Policy without a number can't be re-matched by the OCR pipeline). */
|
||||
function buildPolicyCreateFromDoc(
|
||||
item: ConfirmPolicyDocumentDto,
|
||||
doc: {
|
||||
extractedPolicyNumber: string | null;
|
||||
extractedInsuredName: string | null;
|
||||
extractedAdditionalInsured: string | null;
|
||||
extractedAgentName: string | null;
|
||||
extractedLegalAddress: string | null;
|
||||
extractedZip: string | null;
|
||||
extractedPolicyFrom: Date | null;
|
||||
extractedPolicyTo: Date | null;
|
||||
extractedPolicyDate: Date | null;
|
||||
extractedCurrency: string | null;
|
||||
extractedNetPremium: Prisma.Decimal | null;
|
||||
extractedPolicyFee: Prisma.Decimal | null;
|
||||
extractedBrokerFee: Prisma.Decimal | null;
|
||||
extractedTotal: Prisma.Decimal | null;
|
||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
},
|
||||
customerId: string,
|
||||
): Prisma.PolicyUncheckedCreateInput {
|
||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||
if (a != null) return new Prisma.Decimal(a);
|
||||
if (b != null) return b;
|
||||
return undefined;
|
||||
};
|
||||
const dateOrUndef = (a: string | undefined, b: Date | null): Date | undefined => {
|
||||
if (a) return new Date(a);
|
||||
if (b) return b;
|
||||
return undefined;
|
||||
};
|
||||
const strOrUndef = (a: string | undefined, b: string | null): string | undefined => {
|
||||
if (a != null && a !== "") return a;
|
||||
if (b != null && b !== "") return b;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const policyNumber =
|
||||
strOrUndef(item.policyNumber, doc.extractedPolicyNumber);
|
||||
if (!policyNumber) {
|
||||
// Caller already guards this; the throw is a type-narrowing aid.
|
||||
throw new Error("policyNumber required for create");
|
||||
}
|
||||
|
||||
return {
|
||||
policyNumber,
|
||||
customerId,
|
||||
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
||||
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||
total: numOrUndef(item.total, doc.extractedTotal),
|
||||
coveragesJson:
|
||||
item.coveragesJson !== undefined
|
||||
? (item.coveragesJson as Prisma.InputJsonValue)
|
||||
: doc.extractedCoveragesJson != null
|
||||
? (doc.extractedCoveragesJson as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
observations: joinObservations(
|
||||
doc.extractedInsuredName,
|
||||
doc.extractedAdditionalInsured,
|
||||
doc.extractedLegalAddress,
|
||||
doc.extractedZip,
|
||||
doc.extractedPremiumPayment,
|
||||
item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function joinObservations(
|
||||
insured: string | null,
|
||||
additional: string | null,
|
||||
address: string | null,
|
||||
zip: string | null,
|
||||
premiumPayment: string | null,
|
||||
item: ConfirmPolicyDocumentDto,
|
||||
): string | undefined {
|
||||
const lines: string[] = [];
|
||||
const insuredName = strOrUndefDb(item.insuredName, insured);
|
||||
if (insuredName) lines.push(`Asegurado: ${insuredName}`);
|
||||
const additionalInsured = strOrUndefDb(item.additionalInsured, additional);
|
||||
if (additionalInsured) lines.push(`Asegurado adicional: ${additionalInsured}`);
|
||||
const legalAddress = strOrUndefDb(item.legalAddress, address);
|
||||
if (legalAddress) lines.push(`Dirección: ${legalAddress}`);
|
||||
const zipVal = strOrUndefDb(item.zip, zip);
|
||||
if (zipVal) lines.push(`C.P.: ${zipVal}`);
|
||||
const cadence = strOrUndefDb(item.premiumPayment, premiumPayment);
|
||||
if (cadence) lines.push(`Pago de prima: ${cadence}`);
|
||||
return lines.length ? lines.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function strOrUndefDb(a: string | undefined, b: string | null): string | undefined {
|
||||
if (a != null && a !== "") return a;
|
||||
if (b != null && b !== "") return b;
|
||||
return undefined;
|
||||
}
|
||||
@@ -949,6 +949,111 @@ const edoCuentaDatos: ReportDef = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* REPORTE CHEQUE COUNT — everything captured against one check.
|
||||
*
|
||||
* The reconciliation half of the batch-capture flow (docs/RECEIPT_CAPTURE_SPEC
|
||||
* §1.3): staff key many customers' receipts against one physical check, then
|
||||
* check that what was captured adds up to what the check was cut for. Replaces
|
||||
* `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE` and
|
||||
* `REPORTE POR CHEQUE PARA ALFA` — four legacy objects, one parameterized
|
||||
* report.
|
||||
*
|
||||
* Deliberately mirrors `BillingService.byCheck`'s rules rather than inventing
|
||||
* its own: voided rows are dropped entirely, and outstanding (NOPAGO) rows are
|
||||
* listed but excluded from the total, because the check never funded them.
|
||||
*/
|
||||
const chequeCount: ReportDef = {
|
||||
slug: "cheque-count",
|
||||
title: "Reporte por cheque",
|
||||
description:
|
||||
"Todos los movimientos capturados contra un mismo cheque, con el total " +
|
||||
"para conciliar contra el importe físico del cheque. Los movimientos " +
|
||||
"pendientes de pago (sin fondos) se listan pero no suman al total.",
|
||||
domain: "estado-cuenta",
|
||||
legacyName: "REPORTE CHEQUE COUNT / REPORTE POR CHEQUE / EDITA CHEQUE COUNT",
|
||||
format: "tabular",
|
||||
params: [
|
||||
{
|
||||
key: "checkNumber",
|
||||
label: "Número de cheque",
|
||||
kind: "text",
|
||||
placeholder: "Ej. 10432",
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ key: "customerName", label: "Cliente", type: "text" },
|
||||
{ key: "reference", label: "Referencia", type: "text" },
|
||||
{ key: "period", label: "Periodo", type: "text" },
|
||||
{ key: "concept", label: "Concepto", type: "text" },
|
||||
{ key: "transactionDate", label: "Fecha", type: "date" },
|
||||
{ key: "status", label: "Estado", type: "text" },
|
||||
{ key: "amount", label: "Importe", type: "money", align: "right" },
|
||||
],
|
||||
async run(prisma, p) {
|
||||
const checkNumber = p.checkNumber?.trim();
|
||||
if (!checkNumber) {
|
||||
return {
|
||||
rows: [],
|
||||
totals: { movimientos: 0 },
|
||||
subtitle: "Indique un número de cheque",
|
||||
};
|
||||
}
|
||||
|
||||
const rows = await prisma.transaction.findMany({
|
||||
where: { checkNumber, ...NOT_VOIDED },
|
||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||
select: {
|
||||
transactionDate: true,
|
||||
amount: true,
|
||||
currency: true,
|
||||
reference: true,
|
||||
period: true,
|
||||
outstanding: true,
|
||||
type: { select: { nameEn: true, nameEs: true } },
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Per currency, and never collapsed — same rule as the rest of the ledger.
|
||||
const totals = new Map<string, Prisma.Decimal>();
|
||||
let outstandingCount = 0;
|
||||
for (const r of rows) {
|
||||
if (r.outstanding) {
|
||||
outstandingCount++;
|
||||
continue;
|
||||
}
|
||||
totals.set(
|
||||
r.currency,
|
||||
(totals.get(r.currency) ?? new Prisma.Decimal(0)).plus(r.amount),
|
||||
);
|
||||
}
|
||||
|
||||
const totalsOut: Record<string, string | number> = {
|
||||
movimientos: rows.length,
|
||||
};
|
||||
for (const [currency, sum] of totals) {
|
||||
totalsOut[`total ${currency}`] = sum.toFixed(2);
|
||||
}
|
||||
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
||||
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
customerName: nameOf(r.customer),
|
||||
reference: r.reference ?? "—",
|
||||
period: r.period ?? "—",
|
||||
concept: r.type?.nameEs || r.type?.nameEn || "Sin clasificar",
|
||||
transactionDate: r.transactionDate.toISOString().slice(0, 10),
|
||||
status: r.outstanding ? "Sin fondos" : "Pagado",
|
||||
amount: r.amount.toFixed(2),
|
||||
currency: r.currency,
|
||||
})),
|
||||
totals: totalsOut,
|
||||
subtitle: `Cheque ${checkNumber} · ${rows.length} movimientos`,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ export */
|
||||
|
||||
export const REPORTS: ReportDef[] = [
|
||||
@@ -959,6 +1064,7 @@ export const REPORTS: ReportDef[] = [
|
||||
vigente,
|
||||
avisoRenovacion,
|
||||
edoCuentaDatos,
|
||||
chequeCount,
|
||||
];
|
||||
|
||||
export function findReport(slug: string): ReportDef | undefined {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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, and on a later
|
||||
* corpus of 19 scanned municipal predial receipts it read the provider on
|
||||
* 19/19 and an identifier on 18/19 — 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 several of the 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>;
|
||||
/**
|
||||
* Read a PDF's own text layer, one entry per page, `null` where the page has
|
||||
* none worth using.
|
||||
*
|
||||
* Not every statement is a scan. The gas company e-mails born-digital CFDI
|
||||
* invoices whose text is already exact and already positioned — running those
|
||||
* through a rasteriser and a character recogniser can only lose information
|
||||
* (one sample turned `MEDIDOR: VM01014426` into `ar (LTR): 014420`) while
|
||||
* costing about a minute of CPU per page for the privilege. Where the layer
|
||||
* exists it is strictly better input for the same parsers, so it is tried
|
||||
* first and OCR remains the fallback for genuine scans.
|
||||
*
|
||||
* Positions are reported in the same pixel space `recognize` uses, so the
|
||||
* geometric helpers in the parsers work unchanged on either source.
|
||||
*/
|
||||
textPages(pdf: Buffer): Promise<(OcrPage | null)[]>;
|
||||
}
|
||||
|
||||
export const OCR_PROVIDER = Symbol("OCR_PROVIDER");
|
||||
@@ -0,0 +1,67 @@
|
||||
import { parseBboxLayout } from "./tesseract.provider";
|
||||
|
||||
/**
|
||||
* Shaped like real `pdftotext -bbox-layout` output: the gas invoice lays its
|
||||
* header out as two columns of independent text flows, so poppler puts a label
|
||||
* and the value printed beside it in *different* `<line>` elements. Trusting
|
||||
* that grouping is what left `PERIODO FACTURADO` with no value next to it and
|
||||
* every period field empty on a batch whose text was perfectly readable.
|
||||
*/
|
||||
function word(x: number, y: number, text: string): string {
|
||||
return `<word xMin="${x}" yMin="${y}" xMax="${x + 20}" yMax="${y + 8}">${text}</word>`;
|
||||
}
|
||||
|
||||
function doc(...lines: string[]): string {
|
||||
return `<doc><page width="612" height="792">${lines
|
||||
.map((l) => `<flow><block><line>${l}</line></block></flow>`)
|
||||
.join("")}</page></doc>`;
|
||||
}
|
||||
|
||||
/** Enough words on the page to clear the "is this a real text layer" floor. */
|
||||
function padding(): string {
|
||||
return Array.from({ length: 50 }, (_, i) => word(10, 400 + i * 10, `w${i}`)).join("");
|
||||
}
|
||||
|
||||
describe("parseBboxLayout", () => {
|
||||
it("rejoins a label with the value printed beside it in another flow", () => {
|
||||
const [page] = parseBboxLayout(
|
||||
doc(
|
||||
word(20, 100, "PERIODO") + word(45, 100, "FACTURADO:"),
|
||||
word(300, 100.4, "20260630-20260630"),
|
||||
padding(),
|
||||
),
|
||||
1,
|
||||
);
|
||||
expect(page).not.toBeNull();
|
||||
expect(page!.text).toContain("PERIODO FACTURADO: 20260630-20260630");
|
||||
});
|
||||
|
||||
it("keeps genuinely separate lines apart", () => {
|
||||
const [page] = parseBboxLayout(
|
||||
doc(word(20, 100, "Cuenta:") + word(80, 100, "0900003463"), word(20, 130, "Nombre:"), padding()),
|
||||
1,
|
||||
);
|
||||
expect(page!.text.split("\n")).toContain("Cuenta: 0900003463");
|
||||
expect(page!.text.split("\n")).toContain("Nombre:");
|
||||
});
|
||||
|
||||
it("scales point coordinates into the render's pixel space", () => {
|
||||
// Word boxes have to land in the same coordinate space tesseract reports,
|
||||
// or the geometric helpers the parsers share silently stop finding values.
|
||||
const [page] = parseBboxLayout(doc(word(72, 144, "X") + padding()), 300 / 72);
|
||||
const x = page!.words.find((w) => w.text === "X")!;
|
||||
expect(x.left).toBeCloseTo(300);
|
||||
expect(x.top).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("reports no text layer for a scan carrying a few stray glyphs", () => {
|
||||
expect(parseBboxLayout(doc(word(10, 10, "3") + word(40, 10, "of") + word(60, 10, "5")), 1)).toEqual([
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it("decodes the entities poppler escapes", () => {
|
||||
const [page] = parseBboxLayout(doc(word(10, 10, "A&B") + padding()), 1);
|
||||
expect(page!.text).toContain("A&B");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
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))));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `pdftotext -bbox-layout` — the same poppler package `pdftoppm` comes from,
|
||||
* so this costs no extra dependency in the runtime image.
|
||||
*
|
||||
* A page is only accepted when it carries a real text layer. Scanned PDFs
|
||||
* frequently contain a handful of stray glyphs (a scanner watermark, a page
|
||||
* number stamped by the MFP), and treating those as the page's text would
|
||||
* hand every parser an almost-empty string and silently take OCR out of the
|
||||
* loop — so a floor of MIN_TEXT_WORDS words has to be present before the
|
||||
* layer is believed.
|
||||
*/
|
||||
async textPages(pdf: Buffer): Promise<(OcrPage | null)[]> {
|
||||
await this.require();
|
||||
return this.scratch(async (dir) => {
|
||||
const src = join(dir, "in.pdf");
|
||||
await writeFile(src, pdf);
|
||||
const out = join(dir, "out.html");
|
||||
try {
|
||||
await run("pdftotext", ["-bbox-layout", src, out]);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`pdftotext failed; falling back to OCR for this file: ${(err as Error).message}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
// Points to pixels at the render DPI, so word boxes from either source
|
||||
// land in one coordinate space and `valueUnder`'s thresholds hold.
|
||||
return parseBboxLayout(await readFile(out, "utf8"), this.dpi / 72);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Below this many words a "text layer" is scanner debris, not a document.
|
||||
* The real born-digital samples carry 400+ words a page; the scanned ones
|
||||
* carry none at all, so the exact threshold is not delicate.
|
||||
*/
|
||||
const MIN_TEXT_WORDS = 40;
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
amp: "&",
|
||||
lt: "<",
|
||||
gt: ">",
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
};
|
||||
|
||||
function decodeEntities(s: string): string {
|
||||
return s.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, body: string) => {
|
||||
if (body[0] === "#") {
|
||||
const code =
|
||||
body[1] === "x" || body[1] === "X"
|
||||
? parseInt(body.slice(2), 16)
|
||||
: parseInt(body.slice(1), 10);
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
||||
}
|
||||
return ENTITIES[body] ?? whole;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn `pdftotext -bbox-layout`'s XHTML into one OcrPage per PDF page.
|
||||
*
|
||||
* Parsed with regexes rather than an XML library on purpose: the output is
|
||||
* machine-generated by poppler with a fixed element shape (`page` > `flow` >
|
||||
* `block` > `line` > `word`), and the alternative is a parser dependency in
|
||||
* the API for one file format read in one place. Only `page` and `word` are
|
||||
* consulted — see below for why poppler's own `line` grouping is discarded.
|
||||
*
|
||||
* `confidence` is 1 for every word: these are the document's own characters,
|
||||
* not a recognition guess.
|
||||
*/
|
||||
export function parseBboxLayout(xhtml: string, scale: number): (OcrPage | null)[] {
|
||||
const pages: (OcrPage | null)[] = [];
|
||||
|
||||
for (const pageMatch of xhtml.matchAll(/<page\b[^>]*>([\s\S]*?)<\/page>/g)) {
|
||||
const words: OcrWord[] = [];
|
||||
|
||||
for (const w of pageMatch[1].matchAll(
|
||||
/<word\s+xMin="([\d.eE+-]+)"\s+yMin="([\d.eE+-]+)"\s+xMax="([\d.eE+-]+)"\s+yMax="([\d.eE+-]+)"\s*>([\s\S]*?)<\/word>/g,
|
||||
)) {
|
||||
const text = decodeEntities(w[5]).trim();
|
||||
if (!text) continue;
|
||||
const left = Number(w[1]) * scale;
|
||||
const top = Number(w[2]) * scale;
|
||||
words.push({
|
||||
text,
|
||||
left,
|
||||
top,
|
||||
width: Number(w[3]) * scale - left,
|
||||
height: Number(w[4]) * scale - top,
|
||||
confidence: 1,
|
||||
});
|
||||
}
|
||||
|
||||
pages.push(
|
||||
words.length >= MIN_TEXT_WORDS
|
||||
? { text: toVisualRows(words), words, confidence: 1 }
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassemble words into the rows a reader sees, left to right.
|
||||
*
|
||||
* Poppler's own `<line>` grouping cannot be used for this. It groups by text
|
||||
* flow, and these invoices lay their fields out as two columns of independent
|
||||
* flows — so `PERIODO FACTURADO:` and the `20260630-20260630` printed beside
|
||||
* it end up in different `<line>` elements, and every label-then-value pattern
|
||||
* in the parsers misses a value that is plainly there on the page. Regrouping
|
||||
* by vertical position restores the adjacency, and matches what tesseract
|
||||
* hands back for the scanned version of the same layout.
|
||||
*
|
||||
* Rows are cut when a word's vertical centre leaves the band established by
|
||||
* the row's first word, which tolerates the sub-pixel baseline differences
|
||||
* between fonts on one line without merging two genuinely separate lines.
|
||||
*/
|
||||
function toVisualRows(words: OcrWord[]): string {
|
||||
const centre = (w: OcrWord) => w.top + w.height / 2;
|
||||
const sorted = [...words].sort((a, b) => centre(a) - centre(b) || a.left - b.left);
|
||||
|
||||
const rows: OcrWord[][] = [];
|
||||
let current: OcrWord[] = [];
|
||||
let band = 0;
|
||||
|
||||
for (const w of sorted) {
|
||||
if (!current.length) {
|
||||
current = [w];
|
||||
band = centre(w);
|
||||
continue;
|
||||
}
|
||||
// Half the word's own height: tall headings and body text both sit within
|
||||
// their own line's band, and neither reaches into the next one.
|
||||
if (Math.abs(centre(w) - band) <= Math.max(w.height, current[0].height) / 2) {
|
||||
current.push(w);
|
||||
} else {
|
||||
rows.push(current);
|
||||
current = [w];
|
||||
band = centre(w);
|
||||
}
|
||||
}
|
||||
if (current.length) rows.push(current);
|
||||
|
||||
return rows
|
||||
.map((r) =>
|
||||
[...r]
|
||||
.sort((a, b) => a.left - b.left)
|
||||
.map((w) => w.text)
|
||||
.join(" "),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,270 @@
|
||||
import type { OcrPage } from "../ocr/ocr.provider";
|
||||
import {
|
||||
detectProvider,
|
||||
normalizeCadastralKey,
|
||||
normalizeZofematKey,
|
||||
parseStatement,
|
||||
} from "./statement-parser";
|
||||
|
||||
/**
|
||||
* Every string in this file is a verbatim excerpt of what the OCR engine
|
||||
* actually returned for a real receipt — misreads, dropped spaces, mangled
|
||||
* accents and all. That is the point: these are the specific ways these five
|
||||
* layouts have been observed to fail, and the assertions pin down what the
|
||||
* parser is supposed to do about each one. Inventing clean input here would
|
||||
* test nothing, because clean input was never the problem.
|
||||
*/
|
||||
function page(text: string): OcrPage {
|
||||
return { text, words: [], confidence: 0.9 };
|
||||
}
|
||||
|
||||
describe("detectProvider", () => {
|
||||
it("reads a Rosarito predial receipt as predial, not as a water bill", () => {
|
||||
// "Clave Catastral" is also a CESPT structural marker, so a predial page
|
||||
// whose header OCR'd badly must still not be claimed by the CESPT rule.
|
||||
expect(
|
||||
detectProvider(
|
||||
"e | Clave Catastral. KP-128-105 IMPUESTO PREDIAL ea rita\n" +
|
||||
"TASA | VALOR FISCAL | BIMESTRES | INCISO. | IMPUESTO",
|
||||
),
|
||||
).toBe("PREDIAL ROSARITO");
|
||||
});
|
||||
|
||||
it("keeps telling the three municipalities apart by their RFC", () => {
|
||||
expect(detectProvider("R.F.C. ATB-541201-KK2")).toBe("PREDIAL TIJUANA");
|
||||
expect(detectProvider("R.F.C. AMP-981201-HJ4")).toBe("PREDIAL ROSARITO");
|
||||
expect(detectProvider("MEN-540301-9J5")).toBe("PREDIAL ENSENADA");
|
||||
});
|
||||
|
||||
it("does not let the CFE rule claim a gas bill over 'PERIODO FACTURADO'", () => {
|
||||
expect(
|
||||
detectProvider("Orden de Facturación: 000009801640\nPERIODO FACTURADO: 20260630-20260630"),
|
||||
).toBe("GAS TIJUANA");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCadastralKey", () => {
|
||||
it("keeps a letter in the third position instead of digitising it", () => {
|
||||
// `MMB01041` is a real key on file; mapping its B to 8 produced a key that
|
||||
// matches no property at all.
|
||||
expect(normalizeCadastralKey("MM-B01-041", [])).toBe("MMB01041");
|
||||
});
|
||||
|
||||
it("repairs the spurious I tesseract inserts into the prefix", () => {
|
||||
expect(normalizeCadastralKey("MIM-200-010", [])).toBe("MM200010");
|
||||
});
|
||||
|
||||
it("digitises confusable glyphs from position four onward", () => {
|
||||
expect(normalizeCadastralKey("KP-1O8-O45", [])).toBe("KP108045");
|
||||
});
|
||||
|
||||
it("flags a prefix it had to truncate", () => {
|
||||
const notes: string[] = [];
|
||||
expect(normalizeCadastralKey("KPX-128-106", notes)).toBe("KP128106");
|
||||
expect(notes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePredialTijuana", () => {
|
||||
const TIJUANA = page(
|
||||
"Hats | AYUNTAMIENTO DE TIJUANA, BC $2,613.00 23/01/2026\n" +
|
||||
"y) TELEFONO: 973-7000 R.F.C. ATB-541201-KK2\n" +
|
||||
"ER AÑO VALOR FISCAL TASA IMPUESTO |CONCEPTO IMPORTE\n" +
|
||||
"ED ca 2026 1,207,15778 246 2,969.61 1102 - IMPUESTO PREDIAL 2,969.61\n" +
|
||||
"55164964310126000002613000054192\n" +
|
||||
"se 0 O (54427 [a] | TOTALAPAGAR: 2,613.00\n" +
|
||||
"Dc 1097 : FECHA VENCE : 31/ENE/2026",
|
||||
);
|
||||
|
||||
it("splits the payment barcode into account, deadline and amount", () => {
|
||||
const p = parseStatement(TIJUANA);
|
||||
expect(p.provider).toBe("PREDIAL TIJUANA");
|
||||
expect(p.serviceKind).toBe("PROPERTY_TAX");
|
||||
expect(p.accountRef).toBe("55164964");
|
||||
expect(p.amount).toBe(2613);
|
||||
expect(p.dueDate?.toISOString().slice(0, 10)).toBe("2026-01-31");
|
||||
expect(p.period).toBe("2026");
|
||||
});
|
||||
|
||||
it("reads the printed total even when the space in the label is lost", () => {
|
||||
// The real page OCR'd the label as "TOTALAPAGAR:", and it is that reading
|
||||
// that cross-checks the barcode's amount.
|
||||
expect(parseStatement(TIJUANA).crossChecked).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to trust a barcode the printed total contradicts", () => {
|
||||
const p = parseStatement(
|
||||
page(
|
||||
"R.F.C. ATB-541201-KK2\n" +
|
||||
"55164964310126000002613000054192\n" +
|
||||
"TOTAL A PAGAR: 9,613.00\nFECHA VENCE : 31/ENE/2026",
|
||||
),
|
||||
);
|
||||
expect(p.crossChecked).toBe(false);
|
||||
expect(p.notes.join(" ")).toContain("no coincide");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePredialRosarito", () => {
|
||||
it("takes the rounded Total, not the Sub Total printed above it", () => {
|
||||
const p = parseStatement(
|
||||
page(
|
||||
"AYUNTAMIENTO MUNICIPAL DE PLAYAS DE ROSARITO, B.C.\n" +
|
||||
"Ce Clave Catastral: + JR-400-008 7 | IMPUESTO PREDIAL\n" +
|
||||
"SUPERFICIE: 228.31 ZONA 30025 “Redondeo IT049 -$0.39 Sub Total $5,409.39\n" +
|
||||
"¿XTEMPORANEO DESPUES DE: 31/01/2026 Elaboro: MGLG\n" +
|
||||
"Total | $5,409.00\n" +
|
||||
"| Periodo por Pagar: 2026/1 2026/6",
|
||||
),
|
||||
);
|
||||
expect(p.cadastralKey).toBe("JR400008");
|
||||
expect(p.amount).toBe(5409);
|
||||
expect(p.dueDate?.toISOString().slice(0, 10)).toBe("2026-01-31");
|
||||
expect(p.period).toBe("2026");
|
||||
});
|
||||
|
||||
it("is not fooled by the unspaced 'SubTotal' spelling", () => {
|
||||
// This exact page read $9,624.85 off a receipt for $9,625.00 while the
|
||||
// lookbehind still assumed a space.
|
||||
const p = parseStatement(
|
||||
page(
|
||||
"AMP-981201-HJ4 IMPUESTO PREDIAL\n" +
|
||||
"SUPERFICIE. 367.62 ZONA:30151 | Redondco 17049 $0.15 SubTotal $9,624.85\n" +
|
||||
": Total | $9,625.00",
|
||||
),
|
||||
);
|
||||
expect(p.amount).toBe(9625);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePredialEnsenada", () => {
|
||||
const totals = (tail: string) =>
|
||||
page(
|
||||
"IMPRESION MAQUINA REGISTRADORA ez | MUNICIPIO DE ENSENADA\n" +
|
||||
"+7] DATOS. DEL.CAUSANTE alta A pe CLAVE MM-200-010 2 CUENTA\n" +
|
||||
`ES g € S| TOTALES 12,744.47 0.00 0.00 324.56 0.00 13,069.03 ${tail} |`,
|
||||
);
|
||||
|
||||
it("reads the paid total off the TOTALES row however the label OCR'd", () => {
|
||||
expect(parseStatement(totals("TOTA LA A $5,797.00")).amount).toBe(5797);
|
||||
expect(parseStatement(totals("orAL: M7 z] $14,414.00")).amount).toBe(14414);
|
||||
expect(parseStatement(totals("| TOTAL: = $6 246.00")).amount).toBe(6246);
|
||||
});
|
||||
|
||||
it("reports no amount rather than one whose $ was misread as an 8", () => {
|
||||
// `TOTAL: A 82,203.00` is a $2,203.00 receipt. Posting $82,203 would look
|
||||
// entirely ordinary in the ledger, so this page must go to review instead.
|
||||
const p = parseStatement(totals("TOTAL: A 82,203.00"));
|
||||
expect(p.amount).toBeNull();
|
||||
expect(p.notes.join(" ")).toContain("capturarlo a mano");
|
||||
});
|
||||
|
||||
it("never falls back to the assessed total on the same row", () => {
|
||||
expect(parseStatement(totals("yo: se TE= 58/4690]")).amount).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseGas", () => {
|
||||
const gas = (...cuentas: string[]) =>
|
||||
page(
|
||||
"GTI4608032K2 COMPAÑIA DE GAS DE TIJUANA\n" +
|
||||
"Fecha de Vencimiento: 2026/08/08\n" +
|
||||
cuentas.map((c) => `Cuenta: ${c}`).join("\n") +
|
||||
"\nPERIODO FACTURADO: 20260630-20260630\nTOTAL A PAGAR: $275.82",
|
||||
);
|
||||
|
||||
it("strips the printed leading zero to the stored account number", () => {
|
||||
const p = parseStatement(gas("0900003463", "0900003463", "0900003463"));
|
||||
expect(p.serviceKind).toBe("GAS");
|
||||
expect(p.accountRef).toBe("900003463");
|
||||
expect(p.amount).toBe(275.82);
|
||||
expect(p.dueDate?.toISOString().slice(0, 10)).toBe("2026-08-08");
|
||||
expect(p.period).toBe("2026-06");
|
||||
expect(p.crossChecked).toBe(true);
|
||||
});
|
||||
|
||||
it("takes the majority reading but still sends a disagreement to review", () => {
|
||||
const p = parseStatement(gas("0900003463", "0900003463", "0900003468"));
|
||||
expect(p.accountRef).toBe("900003463");
|
||||
expect(p.crossChecked).toBe(false);
|
||||
});
|
||||
|
||||
it("claims no cross-check from a single printing", () => {
|
||||
expect(parseStatement(gas("0900003463")).crossChecked).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseZonaFederal", () => {
|
||||
/**
|
||||
* The Tijuana zona federal receipt, trimmed to the rows the parser reads.
|
||||
* Verbatim from page 7 of the August 2026 batch, including the two ways the
|
||||
* heading OCR'd: the clave line is struck through by the office's own
|
||||
* highlighter, which is what cost two of eight pages their concession clave.
|
||||
*/
|
||||
const zf = (clave: string, body = "") =>
|
||||
page(
|
||||
"ESIZ <pYl Av. Independencia y Esq. Paseo del CentenaxiaiiArlhnto de Tijuana, B.C.\n" +
|
||||
"Teléfono: 9737000 R.F.C. ATB-541201-BK2 0070000146 12:54 PM\n" +
|
||||
"Zona Federal Marítimo Terrestre\n" +
|
||||
`${clave} Nombre: DENNIS JOHN SEIN Concesión:\n` +
|
||||
"Periodo Construcción Tasa Ornato Tasa Impuesto Actualiza. Recargo Multa Importe\n" +
|
||||
"2026-2 / 2026-2 316.40 35.00 0.00 12.11 1,845.66 0.00 27.13 1,000.00 2,872.79\n" +
|
||||
"SubTotal 1,845.66 0.00 27.13 1,000.00 2,872.79\n" +
|
||||
"Concepto: Derechos de ocupación de Zona Federal Marítimo Terrestre\n" +
|
||||
body,
|
||||
);
|
||||
|
||||
it("is not claimed by the predial parser that shares its RFC and header", () => {
|
||||
// Tijuana bills predial and zona federal from the same treasury, so
|
||||
// "Ayuntamiento de Tijuana" and ATB-541201 identify neither on their own.
|
||||
expect(detectProvider("R.F.C. ATB-541201-BK2\nZona Federal Marítimo Terrestre")).toBe(
|
||||
"ZONA FEDERAL TIJUANA",
|
||||
);
|
||||
expect(parseStatement(zf("Clave: 14-D -014")).serviceKind).toBe("FEDERAL_ZONE");
|
||||
});
|
||||
|
||||
it("still recognises the layout when the heading itself did not survive OCR", () => {
|
||||
// Real: page 1 came back as "Zona Ledera) Maritimo Terrestre".
|
||||
expect(
|
||||
detectProvider("Zona Ledera) Maritimo Terrestre\nClave EJ -012% Nombre: STEFAN"),
|
||||
).toBe("ZONA FEDERAL TIJUANA");
|
||||
});
|
||||
|
||||
it("reads the clave through the loose spacing the receipt prints", () => {
|
||||
expect(parseStatement(zf("Clave: 14-D -014")).accountRef).toBe("14D014");
|
||||
expect(parseStatement(zf("Clave: 14-A-119")).accountRef).toBe("14A119");
|
||||
});
|
||||
|
||||
it("keeps the letter instead of digitising it", () => {
|
||||
// toDigits maps D to 0 and B to 8; a real 14-D -014 must not become 140014.
|
||||
expect(normalizeZofematKey("14-D -014")).toBe("14D014");
|
||||
expect(normalizeZofematKey("12-B -013")).toBe("12B013");
|
||||
});
|
||||
|
||||
it("takes the payable amount from the SubTotal row, rounded to whole pesos", () => {
|
||||
// The municipality rounds and prints the difference as "Ajuste Ley Hacienda
|
||||
// Mpal"; 2,872.79 is charged as $2,873.00.
|
||||
expect(parseStatement(zf("Clave: 14-D -014")).amount).toBe(2873);
|
||||
});
|
||||
|
||||
it("prefers the printed total and cross-checks it against the subtotal", () => {
|
||||
const p = parseStatement(zf("Clave: 14-D -014", "Total a pagar $2,873.00"));
|
||||
expect(p.amount).toBe(2873);
|
||||
expect(p.crossChecked).toBe(true);
|
||||
});
|
||||
|
||||
it("sends a printed total that contradicts the subtotal to review", () => {
|
||||
const p = parseStatement(zf("Clave: 14-D -014", "Total a pagar $2,973.00"));
|
||||
expect(p.crossChecked).toBe(false);
|
||||
});
|
||||
|
||||
it("translates the printed bimester into the ledger's own vocabulary", () => {
|
||||
expect(parseStatement(zf("Clave: 14-D -014")).period).toBe("MAR/APR");
|
||||
});
|
||||
|
||||
it("leaves the clave blank rather than guessing when the marker ate it", () => {
|
||||
const p = parseStatement(zf("Clave EJ -012%"));
|
||||
expect(p.accountRef).toBeNull();
|
||||
expect(p.notes.join(" ")).toContain("clave");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,872 @@
|
||||
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" | "GAS TIJUANA" | "PREDIAL TIJUANA" |
|
||||
* "PREDIAL ROSARITO" | "PREDIAL ENSENADA" | "ZONA FEDERAL TIJUANA", 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;
|
||||
}
|
||||
|
||||
/** Every capture of `pattern` across the page, in order. */
|
||||
function allMatches(text: string, pattern: RegExp): string[] {
|
||||
const out: string[] = [];
|
||||
const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
||||
for (const m of text.matchAll(re)) {
|
||||
if (m[1]) out.push(m[1].trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 / 31/ENE/2026 (Tijuana predial)
|
||||
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) and the
|
||||
// 2026/08/08 the gas bill prints — same field order, different separator.
|
||||
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],
|
||||
["GAS TIJUANA", /COMPA[ÑN][IÍ]?A\s*DE\s*GAS\s*DE\s*TIJUANA|bajagas/i],
|
||||
// Ahead of the predial rules on purpose. Tijuana's zona federal receipt is
|
||||
// issued by the same treasury and carries the same header — "Ayuntamiento de
|
||||
// Tijuana", the same address, the same `ATB-541201` RFC — so every predial
|
||||
// discriminator matches it too, and whichever rule is asked first wins the
|
||||
// page. What only the zona federal layout says is "Marítimo Terrestre", which
|
||||
// survived OCR on all eight sample pages even where the heading above it came
|
||||
// back as "Zona Ledera) Maritimo Terrestre" and the printed concession clave
|
||||
// was lost under a highlighter mark.
|
||||
["ZONA FEDERAL TIJUANA", /ZOFEMAT|Mar[ií]timo\s*Terrestre|ocupaci[oó]n\s*de\s*Zona\s*Federal/i],
|
||||
// The municipal RFCs are the single most reliable discriminator on a predial
|
||||
// receipt: they are printed in a clean monospaced run on every layout, they
|
||||
// never change, and they say which of the three city treasuries issued the
|
||||
// page — which the wordmarks alone do not, since a Tijuana receipt also
|
||||
// carries "PLAYAS DE TIJUANA" and a Rosarito one "TIJUANA ENSENADA".
|
||||
["PREDIAL TIJUANA", /AYUNTAMIENTO\s*DE\s*TIJUANA|ATB.?541201/i],
|
||||
["PREDIAL ROSARITO", /AYUNTAMIENTO\s*MUNICIPAL\s*DE\s*PLAYAS\s*DE\s*ROSARITO|AMP.?981201|rosarito\.gob/i],
|
||||
["PREDIAL ENSENADA", /MUNICIPIO\s*DE\s*ENSENADA|MEN.?540301/i],
|
||||
];
|
||||
|
||||
/**
|
||||
* The predial rules come first because a Rosarito receipt prints "Clave
|
||||
* Catastral" as a boxed label — the very string the CESPT structural rule
|
||||
* looks for — so a page whose municipal header failed to OCR would otherwise
|
||||
* be claimed as a water bill and matched against the wrong column entirely.
|
||||
* "IMPUESTO PREDIAL" appears on all three municipal layouts and on none of the
|
||||
* utility ones, so it is the safe first question to ask.
|
||||
*/
|
||||
const LAYOUT: [string, RegExp][] = [
|
||||
// Same reasoning as the brand pass, one rule earlier: the concept line
|
||||
// "Derechos de ocupación de Zona Federal Marítimo Terrestre" is printed on
|
||||
// the stub of every zona federal page and on no other layout, and it read
|
||||
// cleanly on 8 of 8 samples — including the two whose heading did not.
|
||||
["ZONA FEDERAL TIJUANA", /Derechos\s*de\s*ocupaci[oó]n/i],
|
||||
["PREDIAL TIJUANA", /IMPUESTO\s*PREDIAL[\s\S]*?(?:CERTIFICACION\s*DE\s*CAJA|PASEO\s*DEL\s*CENTENARIO|PAGA\s*TU\s*PREDIAL)/i],
|
||||
["PREDIAL ENSENADA", /(?:IMPUESTO\s*PREDIAL[\s\S]*?TRANSPENINSULAR)|(?:IMPRESION\s*MAQUINA\s*REGISTRADORA)/i],
|
||||
["PREDIAL ROSARITO", /IMPUESTO\s*PREDIAL/i],
|
||||
["GAS TIJUANA", /Orden\s*de\s*Facturaci[oó]n|FACTOR\s*DE\s*PRESI[OÓ]N|GAS\s*LP/i],
|
||||
["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,
|
||||
};
|
||||
}
|
||||
|
||||
// --- GAS (Compañía de Gas de Tijuana / bajagas) ------------------------------
|
||||
|
||||
/**
|
||||
* These arrive as born-digital CFDI PDFs rather than scans, so the text layer
|
||||
* (see `TesseractOcrProvider.textPages`) usually reads them exactly and the
|
||||
* patterns below only have to be tolerant enough for the scanned case.
|
||||
*
|
||||
* The account number is printed three times — supply address, fiscal data, and
|
||||
* the payment stub at the foot — which is a free cross-check: three readings
|
||||
* that agree are near-certainly right, and any disagreement means one of them
|
||||
* was misread and the page deserves a human glance.
|
||||
*
|
||||
* `Cuenta` is what the matcher compares, not `Contrato`. The migration
|
||||
* recovered gas references out of `PropertyService.notes` into `meterNumber`
|
||||
* and what sat there is the 9-digit account (`900003463`), printed here with a
|
||||
* leading zero as `0900003463`.
|
||||
*/
|
||||
function parseGas(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
const seen = allMatches(text, /Cuenta\s*[:;.]?\s*([0-9OIlSBD]{6,12})/i).map((s) =>
|
||||
toDigits(s).replace(/^0+/, ""),
|
||||
);
|
||||
const distinct = [...new Set(seen.filter(Boolean))];
|
||||
|
||||
let accountRef: string | null = null;
|
||||
let crossChecked: boolean | null = null;
|
||||
if (distinct.length === 1) {
|
||||
accountRef = distinct[0];
|
||||
if (seen.length > 1) crossChecked = true;
|
||||
} else if (distinct.length > 1) {
|
||||
// Majority wins — the stub and the two address blocks print the same
|
||||
// number, so a single divergent reading is the misread one. It still goes
|
||||
// to review: `crossChecked: false` is what keeps the batch from
|
||||
// auto-matching a number one of three readings disagreed with.
|
||||
const tally = new Map<string, number>();
|
||||
for (const s of seen) tally.set(s, (tally.get(s) ?? 0) + 1);
|
||||
accountRef = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
||||
crossChecked = false;
|
||||
notes.push(`el número de cuenta se leyó de ${distinct.length} formas distintas (${distinct.join(", ")})`);
|
||||
}
|
||||
|
||||
const amount = money(
|
||||
firstMatch(text, [
|
||||
/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$\s*([\d,]+\.\d{2})/i,
|
||||
/Total\s*a\s*pagar\s*[:;.]?\s*\$\s*([\d,]+\.\d{2})/i,
|
||||
]),
|
||||
);
|
||||
|
||||
// `20260630-20260630` — the range the bill was cut for. Both ends are the
|
||||
// same reading date on every sample, so the period is reported as the ISO
|
||||
// month rather than a range no ledger row would ever be searched by.
|
||||
const facturado = firstMatch(text, [/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{8})\s*-\s*\d{8}/i]);
|
||||
const period = facturado ? `${facturado.slice(0, 4)}-${facturado.slice(4, 6)}` : null;
|
||||
|
||||
return {
|
||||
provider: "GAS TIJUANA",
|
||||
serviceKind: "GAS",
|
||||
accountRef: accountRef || null,
|
||||
cadastralKey: null,
|
||||
amount,
|
||||
dueDate: parseDate(
|
||||
firstMatch(text, [/Fecha\s*de\s*Vencimiento\s*[:;.]?\s*(\d{4}\s*\/\s*\d{2}\s*\/\s*\d{2})/i])?.replace(
|
||||
/\s/g,
|
||||
"",
|
||||
),
|
||||
),
|
||||
period,
|
||||
crossChecked,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// --- PREDIAL (municipal property tax) ---------------------------------------
|
||||
|
||||
/**
|
||||
* Normalise a printed clave catastral to the eight-character form
|
||||
* `Property.cadastralKey` holds. The municipalities print it grouped
|
||||
* (`KP-128-106`, `MM-B01-041`); the stored value drops the separators
|
||||
* (`KP128106`, `MMB01041`).
|
||||
*
|
||||
* The shape is *not* two letters and six digits, which is the assumption that
|
||||
* has to be resisted here. Across the 932 distinct claves on file, characters
|
||||
* four through eight are digits without exception, but the third is a digit in
|
||||
* 917 of them and one of `A`, `B`, `H`, `T` in the other fifteen. Running the
|
||||
* whole tail through `toDigits` — which maps `B` to `8` — is what turned a real
|
||||
* `MMB01041` into a nonexistent `MM801041`, so only positions four onward get
|
||||
* that treatment and a letter in the third position is kept as printed.
|
||||
*
|
||||
* That leaves a genuine ambiguity at that one position: a `B` there might be a
|
||||
* misread `8`, and 34 stored claves do carry an `8` there against six with a
|
||||
* `B`. It is left as read rather than guessed, because a page that fails to
|
||||
* match lands in the review queue where a human fixes it in seconds, while a
|
||||
* page that matches the wrong property posts a charge to the wrong customer.
|
||||
*
|
||||
* The two-letter prefix is the other fragile part. Tesseract inserts a spurious
|
||||
* `I` into letter pairs with some regularity — a real `MM-200-010` came back as
|
||||
* `MIM-200-010` — so a run longer than two letters has its `I`/`L` dropped
|
||||
* first, which recovers exactly that case. Anything still not two letters is
|
||||
* truncated and flagged, because a wrong prefix silently matches the wrong
|
||||
* property or, more often, nothing at all.
|
||||
*/
|
||||
export function normalizeCadastralKey(
|
||||
raw: string,
|
||||
notes: string[],
|
||||
): string | null {
|
||||
const m = raw.match(/^([A-Za-z|]{2,5})[-\s]?([A-Za-z0-9|]{3})[-\s]?([0-9OIlSBD]{3})$/);
|
||||
if (!m) return null;
|
||||
|
||||
let letters = m[1].toUpperCase().replace(/[^A-Z]/g, "");
|
||||
if (letters.length > 2) {
|
||||
const stripped = letters.replace(/[IL]/g, "");
|
||||
if (stripped.length === 2) {
|
||||
letters = stripped;
|
||||
} else {
|
||||
letters = letters.slice(0, 2);
|
||||
notes.push(`la clave catastral se leyó como "${m[1]}"; se tomó "${letters}"`);
|
||||
}
|
||||
}
|
||||
if (letters.length !== 2) return null;
|
||||
|
||||
const third = m[2][0].toUpperCase();
|
||||
const tail =
|
||||
(/[A-Z]/.test(third) ? third : toDigits(third)) +
|
||||
toDigits(m[2].slice(1)) +
|
||||
toDigits(m[3]);
|
||||
|
||||
return tail.length === 6 ? letters + tail : null;
|
||||
}
|
||||
|
||||
/** The grouped clave as printed, anchored to its label when one survived OCR. */
|
||||
const GROUPED_CLAVE = "[A-Z|]{2,5}-[A-Z0-9OIlSBD]{3}-[0-9OIlSBD]{3}";
|
||||
|
||||
function findCadastralKey(text: string, notes: string[]): string | null {
|
||||
const labelled = firstMatch(text, [
|
||||
new RegExp(`Clave\\s*Catastral\\s*[^A-Z0-9]{0,8}(${GROUPED_CLAVE})`, "i"),
|
||||
new RegExp(`CLAVE\\s*[^A-Z0-9]{0,8}(${GROUPED_CLAVE})`, "i"),
|
||||
]);
|
||||
if (labelled) return normalizeCadastralKey(labelled, notes);
|
||||
|
||||
// Ensenada's label ("CLAVE") lands inside a table header that OCRs into
|
||||
// noise more often than not, so the bare grouped shape is accepted as a
|
||||
// fallback. It is distinctive enough — two letters and two three-character
|
||||
// groups joined by hyphens appears nowhere else on these pages.
|
||||
const bare = firstMatch(text, [new RegExp(`\\b(${GROUPED_CLAVE})\\b`)]);
|
||||
return bare ? normalizeCadastralKey(bare, notes) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tijuana: a "CERTIFICACIÓN DE CAJA" whose payment barcode is one 32-digit run
|
||||
* of `account(8) + due date(DDMMYY) + amount(9) + folio(9)`, verified against
|
||||
* all five sample pages. Municipal totals are whole pesos (the receipt itself
|
||||
* carries a "Redondeo" line), so the barcode amount needs no decimal point.
|
||||
*
|
||||
* No clave catastral is printed anywhere on this layout — the 8-digit
|
||||
* municipal account is the only identifier, and it is not a number the legacy
|
||||
* database ever held. Until a reviewer confirms one, every Tijuana page lands
|
||||
* in review; confirming teaches the matcher (see `learnAccountRefs`) so the
|
||||
* same property matches itself next year.
|
||||
*/
|
||||
function parsePredialTijuana(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
const barcode = text.match(/(?<![0-9OIlSBD])([0-9OIlSBD]{32})(?![0-9OIlSBD])/);
|
||||
const printedTotal = money(
|
||||
firstMatch(text, [/TOTAL\s*A?\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]),
|
||||
);
|
||||
|
||||
let accountRef: string | null = null;
|
||||
let amount: number | null = printedTotal;
|
||||
let dueDate: Date | null = null;
|
||||
let crossChecked: boolean | null = null;
|
||||
|
||||
if (barcode) {
|
||||
const run = toDigits(barcode[1]);
|
||||
const d = run.slice(8, 14);
|
||||
const fromBarcode = Number(run.slice(14, 23));
|
||||
accountRef = run.slice(0, 8);
|
||||
dueDate = parseDate(`20${d.slice(4, 6)}-${d.slice(2, 4)}-${d.slice(0, 2)}`);
|
||||
notes.push("cuenta, importe y vencimiento leídos del código de barras");
|
||||
|
||||
if (printedTotal != null) {
|
||||
// Guarding the money, not the account number: the printed total is the
|
||||
// figure a human would key, so when the two disagree one of them is a
|
||||
// misread peso amount and nothing should post unreviewed.
|
||||
crossChecked = Math.abs(printedTotal - fromBarcode) < 0.5;
|
||||
if (!crossChecked) {
|
||||
notes.push(
|
||||
`el total impreso (${printedTotal}) no coincide con el código de barras (${fromBarcode})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (amount == null) amount = fromBarcode;
|
||||
}
|
||||
|
||||
if (!dueDate) {
|
||||
dueDate = parseDate(
|
||||
firstMatch(text, [/FECHA\s*VENCE\s*[:;.]?\s*(\d{1,2}\/\w{3}\/\d{4})/i]),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "PREDIAL TIJUANA",
|
||||
serviceKind: "PROPERTY_TAX",
|
||||
accountRef: accountRef || null,
|
||||
cadastralKey: null,
|
||||
amount,
|
||||
dueDate,
|
||||
// The fiscal year, which is what the legacy ledger's `period` holds for
|
||||
// predial ("2026" is its single most common value). It is read from the
|
||||
// assessment table's year column, and failing that from the deadline: a
|
||||
// predial bill for year N falls due on 31 January of year N.
|
||||
period:
|
||||
firstMatch(text, [/VALOR\s*FISCAL[\s\S]{0,160}?\b(20\d{2})\b/i]) ??
|
||||
(dueDate ? String(dueDate.getUTCFullYear()) : null),
|
||||
crossChecked,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosarito: a wide "CERTIFICACIÓN DE CAJA" keyed by clave catastral, with no
|
||||
* account number of its own — the clave is the identifier, which is exactly
|
||||
* what `Property.cadastralKey` holds, so these match on the first pass.
|
||||
*
|
||||
* The total is read with a negative lookbehind on "Sub": the receipt prints
|
||||
* `Sub Total $5,409.39` (before the peso rounding) directly above
|
||||
* `Total $5,409.00`, and taking the first "Total" on the page books 39 cents
|
||||
* that the municipality did not charge. The lookbehind allows zero spaces
|
||||
* because the label prints both ways — `Sub Total` on one sample and
|
||||
* `SubTotal` on the next, and the tight one is what slipped past a fixed
|
||||
* `Sub\s` and read $9,624.85 off a receipt for $9,625.00.
|
||||
*/
|
||||
function parsePredialRosarito(page: OcrPage): ParsedStatement {
|
||||
const notes: string[] = [];
|
||||
const text = page.text;
|
||||
|
||||
return {
|
||||
provider: "PREDIAL ROSARITO",
|
||||
serviceKind: "PROPERTY_TAX",
|
||||
accountRef: null,
|
||||
cadastralKey: findCadastralKey(text, notes),
|
||||
amount: money(firstMatch(text, [/(?<!Sub\s{0,3})Total\s*[|:;.]?\s*\$\s*([\d,]+\.\d{2})/i])),
|
||||
// "EXTEMPORANEO DESPUES DE: 31/01/2026" — the leading E is regularly eaten
|
||||
// by the box rule printed over it, so the anchor starts at "XTEMPORANEO".
|
||||
dueDate: parseDate(
|
||||
firstMatch(text, [/XTEMPOR[AÁ]NEO\s*DESPU[EÉ]S\s*DE\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]),
|
||||
),
|
||||
period: firstMatch(text, [/Periodo\s*por\s*Pagar\s*[:;.]?\s*(20\d{2})/i]),
|
||||
crossChecked: null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensenada: a dot-matrix "IMPRESION MAQUINA REGISTRADORA" statement, by some
|
||||
* distance the worst-scanning of the three. Matching is by clave catastral.
|
||||
*
|
||||
* The amount is read positionally rather than by label, because the label does
|
||||
* not survive: across five real pages the same word came back as `TOTAL:`,
|
||||
* `TOTA LA A` and `orAL:`. What is stable is the row — the summary line that
|
||||
* starts `TOTALES` carries the assessed figures across it and the amount
|
||||
* actually paid last, at the right margin.
|
||||
*
|
||||
* That last figure must carry a literal `$`. On a real sample the paid total
|
||||
* printed as `TOTAL: A $2,203.00` and OCR'd as `TOTAL: A 82,203.00` — the
|
||||
* dollar sign read as an 8, a mistake that would post a $2,203 charge as
|
||||
* $82,203 and look entirely ordinary in the ledger. Requiring the `$` costs
|
||||
* that page its amount and sends it to review, which is the only acceptable
|
||||
* failure here. The unprefixed figures earlier on the row are deliberately not
|
||||
* a fallback: they are the tax assessed before the early-payment discount, not
|
||||
* what was paid.
|
||||
*/
|
||||
function parsePredialEnsenada(page: OcrPage): ParsedStatement {
|
||||
const notes: string[] = [];
|
||||
const text = page.text;
|
||||
|
||||
const totalsRow = text.split("\n").find((l) => /TOTALES/i.test(l)) ?? "";
|
||||
const figures = allMatches(totalsRow, /\$\s*(\d[\d,.\s]*\.\d{2})/);
|
||||
const amount = figures.length ? money(figures[figures.length - 1]) : null;
|
||||
if (amount == null) {
|
||||
notes.push("no se pudo leer el importe con certeza; capturarlo a mano");
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "PREDIAL ENSENADA",
|
||||
serviceKind: "PROPERTY_TAX",
|
||||
accountRef: null,
|
||||
cadastralKey: findCadastralKey(text, notes),
|
||||
amount,
|
||||
// This layout prints no payment deadline at all — it is a receipt for a
|
||||
// payment already made at the municipal window.
|
||||
dueDate: null,
|
||||
period: firstMatch(text, [/A[ÑN]O\s*[\s\S]{0,60}?\b(20\d{2})\b/i]),
|
||||
crossChecked: null,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
// --- ZONA FEDERAL (ZOFEMAT, Tijuana) ----------------------------------------
|
||||
|
||||
/**
|
||||
* Normalise the concession clave the zona federal receipt is keyed by.
|
||||
*
|
||||
* It is printed grouped and loosely spaced — `12-T -012`, `14-A-119`,
|
||||
* `14-K -031` — and is a different shape from the cadastral key entirely: two
|
||||
* digits, one letter, three digits. The letter is kept as printed rather than
|
||||
* digitised, for the same reason `normalizeCadastralKey` keeps its third
|
||||
* character: `toDigits` maps `B` to `8` and `D` to `0`, and a real `14-D -014`
|
||||
* run through it becomes `140014`, which is not a clave at all.
|
||||
*
|
||||
* Stored without separators, because nothing on file holds this value yet (see
|
||||
* `parseZonaFederal`) so the canonical form is ours to pick, and a bare run
|
||||
* cannot be broken by the hyphen the scan renders as a dash, a minus or
|
||||
* nothing.
|
||||
*/
|
||||
export function normalizeZofematKey(raw: string): string | null {
|
||||
const m = raw.match(/^([0-9OIlSBD]{2})\s*-\s*([A-Za-z])\s*-?\s*([0-9OIlSBD]{3})$/);
|
||||
if (!m) return null;
|
||||
const zone = toDigits(m[1]);
|
||||
const lot = toDigits(m[3]);
|
||||
if (zone.length !== 2 || lot.length !== 3) return null;
|
||||
return `${zone}${m[2].toUpperCase()}${lot}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bimester the receipt prints as `2026-2 / 2026-2`, rendered in the
|
||||
* vocabulary the ledger already speaks.
|
||||
*
|
||||
* All 258 legacy FEDERAL ZONE transactions carry a period of `JAN/FEB`,
|
||||
* `MAR/APR`, `MAY/JUN` or `NOV/DEC`, and their payment dates confirm the
|
||||
* ordering — JAN/FEB was paid in March, MAR/APR in May, MAY/JUN in July,
|
||||
* NOV/DEC in January, i.e. always the month after the bimester closes. The
|
||||
* receipts agree: the two `2026-3` samples fall due 17/07/2026 with no
|
||||
* surcharge, which is bimester three, May and June. Writing `2026-3` instead
|
||||
* would leave the OCR-posted rows unsearchable alongside every hand-keyed one.
|
||||
*/
|
||||
const BIMESTERS = ["JAN/FEB", "MAR/APR", "MAY/JUN", "JUL/AUG", "SEP/OCT", "NOV/DEC"];
|
||||
|
||||
/**
|
||||
* Tijuana's "Zona Federal Marítimo Terrestre" — the federal maritime-zone
|
||||
* occupancy fee, billed by the municipality for beachfront lots.
|
||||
*
|
||||
* Nothing on file identifies these. `PropertyService.accountNumber` for
|
||||
* FEDERAL_ZONE holds DATMEX.zfed, which is not a reference at all but an
|
||||
* amount: its 77 values include `246.06`, `2369.09`, `22653.94` and a negative
|
||||
* `-1679`, and the concession claves these receipts are keyed by appear nowhere
|
||||
* in the database. So the clave goes to `meterNumber` (see `scopedRefField`),
|
||||
* every page starts cold, and the first confirm teaches the match — the same
|
||||
* arrangement Tijuana predial needed, for the same reason.
|
||||
*
|
||||
* The amount is taken from the SubTotal row rather than the "Total a pagar"
|
||||
* box, which is printed on a grey fill and OCR'd on only 1 of 8 sample pages
|
||||
* while the SubTotal row read on 8 of 8. The two differ by design: the
|
||||
* municipality rounds to whole pesos and prints the difference on its own
|
||||
* "Ajuste Ley Hacienda Mpal" line — `-$0.05` against a 591.05 subtotal, `$0.21`
|
||||
* against 2,872.79 — so the payable figure is the rounded subtotal, and where
|
||||
* the printed box did read, it agreed.
|
||||
*/
|
||||
function parseZonaFederal(page: OcrPage): ParsedStatement {
|
||||
const text = page.text;
|
||||
const notes: string[] = [];
|
||||
|
||||
// Printed twice, once on the receipt and once on the stub below it, which is
|
||||
// a free second reading: on one sample the heading was struck through by the
|
||||
// office's own highlighter and only the stub survived.
|
||||
const claves = [
|
||||
...new Set(
|
||||
allMatches(text, /Clave\s*[:;.]?\s*([0-9OIlSBD]{2}\s*-\s*[A-Za-z]\s*-?\s*[0-9OIlSBD]{3})/i)
|
||||
.map(normalizeZofematKey)
|
||||
.filter((k): k is string => k != null),
|
||||
),
|
||||
];
|
||||
|
||||
const accountRef = claves[0] ?? null;
|
||||
let crossChecked: boolean | null = null;
|
||||
|
||||
const subtotalRow = text.split("\n").find((l) => /SubTotal/i.test(l)) ?? "";
|
||||
const figures = allMatches(subtotalRow, /(\d[\d,]*\.\d{2})/);
|
||||
// Impuesto, Actualización, Recargo, Multa, Importe — the payable one is last.
|
||||
const importe = figures.length ? money(figures[figures.length - 1]) : null;
|
||||
const rounded = importe != null ? Math.round(importe) : null;
|
||||
const printed = money(
|
||||
firstMatch(text, [/Total\s*a\s*pagar\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i]),
|
||||
);
|
||||
|
||||
if (printed != null && rounded != null) {
|
||||
crossChecked = Math.abs(printed - rounded) < 0.5;
|
||||
if (!crossChecked) {
|
||||
notes.push(
|
||||
`el total impreso (${printed}) no coincide con el subtotal redondeado (${rounded})`,
|
||||
);
|
||||
}
|
||||
} else if (rounded != null) {
|
||||
notes.push("importe tomado del subtotal, redondeado al peso");
|
||||
} else if (printed == null) {
|
||||
notes.push("no se pudo leer el importe con certeza; capturarlo a mano");
|
||||
}
|
||||
|
||||
// A clave read two different ways means one of the two readings is wrong and
|
||||
// there is no third to break the tie, so the page goes to a human even if the
|
||||
// money cross-checked.
|
||||
if (claves.length > 1) {
|
||||
crossChecked = false;
|
||||
notes.push(`la clave se leyó de ${claves.length} formas distintas (${claves.join(", ")})`);
|
||||
}
|
||||
if (!accountRef) notes.push("no se pudo leer la clave de la concesión");
|
||||
|
||||
const bimester = text.match(/\b(20\d{2})\s*-\s*([1-6])\s*\/\s*20\d{2}\s*-\s*[1-6]/);
|
||||
|
||||
return {
|
||||
provider: "ZONA FEDERAL TIJUANA",
|
||||
serviceKind: "FEDERAL_ZONE",
|
||||
accountRef,
|
||||
cadastralKey: null,
|
||||
amount: printed ?? rounded,
|
||||
dueDate: parseDate(
|
||||
firstMatch(text, [/Vencimiento\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]),
|
||||
),
|
||||
period: bimester ? BIMESTERS[+bimester[2] - 1] : null,
|
||||
crossChecked,
|
||||
notes,
|
||||
};
|
||||
}
|
||||
|
||||
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||
CFE: parseCfe,
|
||||
CESPT: parseCespt,
|
||||
TELNOR: parseTelnor,
|
||||
"GAS TIJUANA": parseGas,
|
||||
"PREDIAL TIJUANA": parsePredialTijuana,
|
||||
"PREDIAL ROSARITO": parsePredialRosarito,
|
||||
"PREDIAL ENSENADA": parsePredialEnsenada,
|
||||
"ZONA FEDERAL TIJUANA": parseZonaFederal,
|
||||
};
|
||||
|
||||
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,236 @@
|
||||
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.
|
||||
*/
|
||||
/**
|
||||
* Which `PropertyService` column a given kind's statements actually print.
|
||||
*
|
||||
* Exported because the same answer governs three places that must agree: the
|
||||
* lookup here, the blank-service fill on review, and the write-back on confirm.
|
||||
* When they disagree, a reference gets learned into a column nothing searches,
|
||||
* and the same page returns to the review queue every month forever.
|
||||
*
|
||||
* `meterNumber` is doing double duty for the three kinds whose printed
|
||||
* reference DATMEX never held in `accountNumber`:
|
||||
* - GAS, where the number lived in free-text notes,
|
||||
* - PROPERTY_TAX, where `accountNumber` holds DATMEX.predial — a 3-4 digit
|
||||
* office file number that is neither unique nor printed on any statement.
|
||||
* The Tijuana municipal receipt prints an 8-digit account and no clave
|
||||
* catastral at all, so it needs a column of its own; overwriting the legacy
|
||||
* predial numbers to make room would destroy the only link back to the
|
||||
* original records, and
|
||||
* - FEDERAL_ZONE, where `accountNumber` holds DATMEX.zfed, which is not a
|
||||
* reference of any kind but a peso amount: 3 of its 77 values carry cents
|
||||
* (`246.06`, `2369.09`, `22653.94`) and one is negative. Searching it for
|
||||
* the concession clave the receipt prints would never hit, and — worse —
|
||||
* because every row already has a value, the `[field]: null` guards in
|
||||
* `learnAccountRefs` and the blank-service fill would never fire either, so
|
||||
* the same page would return to the review queue every bimester forever.
|
||||
*/
|
||||
export function scopedRefField(
|
||||
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 "CABLE":
|
||||
return "accountNumber";
|
||||
case "GAS": // bajagas "Cuenta" -> recovered from notes into meterNumber
|
||||
case "PROPERTY_TAX": // Tijuana's 8-digit municipal account
|
||||
case "FEDERAL_ZONE": // ZOFEMAT concession clave, e.g. `12T012`
|
||||
return "meterNumber";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StatementMatcherService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
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 = scopedRefField(kind);
|
||||
|
||||
if (field && parsed.accountRef) {
|
||||
const hit = await this.byServiceField(kind, field, parsed.accountRef);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// 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. On the Rosarito and Ensenada predial layouts it is not a rescue at
|
||||
// all but the only identifier the receipt carries, so a unique hit there is
|
||||
// as good as any account-number match and is treated as one.
|
||||
if (parsed.cadastralKey) {
|
||||
const primary = kind === "PROPERTY_TAX" && !parsed.accountRef;
|
||||
const hit = await this.byCadastralKey(kind, parsed.cadastralKey, primary);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
if (!field && !parsed.cadastralKey) {
|
||||
return this.unmatched(`no hay campo de búsqueda definido para ${kind}`);
|
||||
}
|
||||
if (!parsed.accountRef && !parsed.cadastralKey) {
|
||||
return this.unmatched(
|
||||
kind === "PROPERTY_TAX"
|
||||
? "no se leyó ni la clave catastral ni la cuenta municipal"
|
||||
: "no se pudo leer la referencia de la cuenta",
|
||||
);
|
||||
}
|
||||
return this.unmatched(
|
||||
parsed.accountRef
|
||||
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
|
||||
: `no se encontró ninguna propiedad con la clave catastral ${parsed.cadastralKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
/** True when the clave is the identifier the statement was issued against. */
|
||||
primary: boolean,
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
// When the clave is the *secondary* key — a utility bill that also happens
|
||||
// to print it — the page is left for review, because the clave was not the
|
||||
// number the statement was issued against and confirming is what teaches
|
||||
// the matcher the account number for next month. When it is the primary key
|
||||
// (Rosarito and Ensenada predial, which print nothing else), a unique hit
|
||||
// is a real match and there is no second number to learn.
|
||||
return {
|
||||
propertyServiceId: candidates[0].propertyServiceId ?? null,
|
||||
customerId: candidates[0].customerId,
|
||||
note: primary
|
||||
? `coincidencia exacta por clave catastral ${key}`
|
||||
: `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`,
|
||||
// A clave with no service row of the right kind behind it still needs a
|
||||
// human: there is nothing to attach the posting to.
|
||||
confident: primary && candidates[0].propertyServiceId != null,
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
private unmatched(note: string): MatchResult {
|
||||
return {
|
||||
propertyServiceId: null,
|
||||
customerId: null,
|
||||
note,
|
||||
confident: false,
|
||||
candidates: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,18 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { BillingModule } from "../billing/billing.module";
|
||||
import { OcrModule } from "../ocr/ocr.module";
|
||||
import { StatementsController } from "./statements.controller";
|
||||
import { StatementsService } from "./statements.service";
|
||||
import { StatementMatcherService } from "./statement-matcher.service";
|
||||
|
||||
/**
|
||||
* The concrete OCR engine is bound in OcrModule (see apps/api/src/ocr/) —
|
||||
* everything downstream depends on the OcrProvider interface, so swapping
|
||||
* Tesseract for a managed extraction API is a one-line change there.
|
||||
*/
|
||||
@Module({
|
||||
imports: [BillingModule, OcrModule],
|
||||
controllers: [StatementsController],
|
||||
providers: [StatementsService, StatementMatcherService],
|
||||
})
|
||||
export class StatementsModule {}
|
||||
@@ -0,0 +1,480 @@
|
||||
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, scopedRefField } 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);
|
||||
// Page images are still rendered and stored for every file, text layer or
|
||||
// not: the review screen shows the reviewer the page, and "what the
|
||||
// parser read" is only checkable against a picture of the paper.
|
||||
const textLayer = await this.ocr.textPages(file.buffer).catch(() => []);
|
||||
|
||||
for (const [index, image] of pages.entries()) {
|
||||
pageNumber += 1;
|
||||
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
||||
await this.storage.put(storageKey, image, "image/png");
|
||||
|
||||
try {
|
||||
const embedded = textLayer[index] ?? null;
|
||||
const ocr = embedded ?? (await this.ocr.recognize(image));
|
||||
const parsed = parseStatement(ocr);
|
||||
if (embedded) {
|
||||
parsed.notes.unshift("texto leído del PDF original, sin 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 },
|
||||
});
|
||||
const field = batch && scopedRefField(batch.serviceKind);
|
||||
if (batch && field) {
|
||||
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 = scopedRefField(kind);
|
||||
if (!field) return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
if (!this.client) {
|
||||
throw new ServiceUnavailableException(
|
||||
|
||||
@@ -18,6 +18,7 @@ const safeSelect = {
|
||||
email: true,
|
||||
role: true,
|
||||
active: true,
|
||||
uiScale: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} 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> {
|
||||
await this.ensureExists(id);
|
||||
const passwordHash = await argon2.hash(password);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/web",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 4500",
|
||||
|
||||
@@ -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
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ContextReports } from "@/components/ContextReports";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
getBankFacets,
|
||||
getBankStats,
|
||||
getBankSummary,
|
||||
listBankAccounts,
|
||||
listBankMovements,
|
||||
voidBankMovement,
|
||||
} from "@/lib/api";
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
monthName,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
BankAccount,
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankFacets,
|
||||
@@ -32,23 +35,28 @@ import type {
|
||||
BankSummary,
|
||||
BankTotals,
|
||||
CreateBankMovementInput,
|
||||
Currency,
|
||||
} 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
|
||||
* 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,
|
||||
* payee, cheque number or amount.
|
||||
* - "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
|
||||
* header in `bank.service.ts` for why there is no category/ramo filter.
|
||||
* Every amount is read in the selected account's currency. There is no "all
|
||||
* 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";
|
||||
|
||||
@@ -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() {
|
||||
const canCapture = useCan("bank:create");
|
||||
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 [facets, setFacets] = useState<BankFacets | null>(null);
|
||||
const [view, setView] = useState<View>("movimientos");
|
||||
@@ -104,16 +121,66 @@ function BankBrowser() {
|
||||
|
||||
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(() => {
|
||||
getBankStats().then(setStats).catch(() => setStats(null));
|
||||
getBankFacets().then(setFacets).catch(() => setFacets(null));
|
||||
listBankAccounts()
|
||||
.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(
|
||||
(p: number) => {
|
||||
if (!accountId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
listBankMovements({
|
||||
bankAccountId: accountId,
|
||||
query: query || undefined,
|
||||
direction: direction || undefined,
|
||||
cleared: cleared || undefined,
|
||||
@@ -132,23 +199,23 @@ function BankBrowser() {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[query, direction, cleared, from, to, sort],
|
||||
[accountId, query, direction, cleared, from, to, sort],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "movimientos") return;
|
||||
if (view !== "movimientos" || !accountId) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [runSearch, view]);
|
||||
}, [runSearch, view, accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "resumen") return;
|
||||
if (view !== "resumen" || !accountId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getBankSummary(summaryYear ?? undefined)
|
||||
getBankSummary(accountId, summaryYear ?? undefined)
|
||||
.then((res) => {
|
||||
setSummary(res);
|
||||
setLoading(false);
|
||||
@@ -157,7 +224,7 @@ function BankBrowser() {
|
||||
setError(e?.message ?? "No se pudo cargar el resumen.");
|
||||
setLoading(false);
|
||||
});
|
||||
}, [view, summaryYear]);
|
||||
}, [view, summaryYear, accountId]);
|
||||
|
||||
function goToPage(p: number) {
|
||||
runSearch(p);
|
||||
@@ -194,20 +261,71 @@ function BankBrowser() {
|
||||
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 (
|
||||
<>
|
||||
<div className="page-head rise">
|
||||
<p className="eyebrow">Cuenta propia de la oficina</p>
|
||||
<h1 className="page-title">Chequera</h1>
|
||||
<AccountPicker
|
||||
accounts={accounts}
|
||||
accountId={accountId}
|
||||
onPick={pickAccount}
|
||||
canManageAccounts={canManageAccounts}
|
||||
/>
|
||||
<BankStatStrip
|
||||
stats={stats}
|
||||
currency={currency}
|
||||
direction={view === "movimientos" ? direction : ""}
|
||||
onPickDirection={pickDirection}
|
||||
/>
|
||||
<p className="section-note">
|
||||
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma
|
||||
parte del estado de cuenta de los clientes y sus cifras no se suman
|
||||
con las de ellos.
|
||||
Movimientos de{" "}
|
||||
<strong>{account ? account.label : "la cuenta seleccionada"}</strong>,
|
||||
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>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<ContextReports
|
||||
@@ -252,7 +370,7 @@ function BankBrowser() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view === "movimientos" && canCapture && (
|
||||
{view === "movimientos" && canCapture && account?.active && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
@@ -263,12 +381,20 @@ function BankBrowser() {
|
||||
)}
|
||||
</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
|
||||
account={account}
|
||||
onSaved={() => {
|
||||
setCaptureOpen(false);
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBankStats().then(setStats).catch(() => setStats(null));
|
||||
refreshStats();
|
||||
}}
|
||||
onCancel={() => setCaptureOpen(false)}
|
||||
/>
|
||||
@@ -389,7 +515,7 @@ function BankBrowser() {
|
||||
)}
|
||||
|
||||
{view === "movimientos" && movements && !loading && (
|
||||
<FilteredTotals totals={movements.totals} />
|
||||
<FilteredTotals totals={movements.totals} currency={currency} />
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
@@ -402,6 +528,7 @@ function BankBrowser() {
|
||||
<SummaryView
|
||||
summary={summary}
|
||||
year={summaryYear}
|
||||
currency={currency}
|
||||
onPickYear={pickYear}
|
||||
/>
|
||||
) : movements && movements.total === 0 ? (
|
||||
@@ -430,12 +557,11 @@ function BankBrowser() {
|
||||
<BankRow
|
||||
key={m.id}
|
||||
m={m}
|
||||
currency={currency}
|
||||
canVoid={canVoid}
|
||||
onVoided={() => {
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBankStats()
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
refreshStats();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -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. */
|
||||
function BankStatStrip({
|
||||
stats,
|
||||
currency,
|
||||
direction,
|
||||
onPickDirection,
|
||||
}: {
|
||||
stats: BankStats | null;
|
||||
currency: Currency;
|
||||
direction: BankDirection | "";
|
||||
onPickDirection: (d: BankDirection) => void;
|
||||
}) {
|
||||
@@ -493,7 +672,7 @@ function BankStatStrip({
|
||||
aria-pressed={direction === "income"}
|
||||
>
|
||||
<div className="stat-value tx-amount pos">
|
||||
{formatMoney(stats.income, "MXN")}
|
||||
{formatMoney(stats.income, currency)}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
||||
@@ -508,14 +687,14 @@ function BankStatStrip({
|
||||
aria-pressed={direction === "expense"}
|
||||
>
|
||||
<div className="stat-value tx-amount neg">
|
||||
{formatMoney(stats.expense, "MXN")}
|
||||
{formatMoney(stats.expense, currency)}
|
||||
</div>
|
||||
<div className="stat-label">
|
||||
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
||||
</div>
|
||||
</button>
|
||||
<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. */}
|
||||
<div className="stat-label">Movimiento neto acumulado</div>
|
||||
</div>
|
||||
@@ -544,27 +723,33 @@ function BankStatStrip({
|
||||
}
|
||||
|
||||
/** 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)
|
||||
return null;
|
||||
return (
|
||||
<div className="filtered-totals">
|
||||
<div className="filtered-total">
|
||||
<span className="filtered-total-cur">MXN</span>
|
||||
<span className="filtered-total-cur">{currency}</span>
|
||||
<span>
|
||||
<strong className="tx-amount pos">
|
||||
{formatMoney(totals.income, "MXN")}
|
||||
{formatMoney(totals.income, currency)}
|
||||
</strong>{" "}
|
||||
en ingresos · {formatNumber(totals.incomeCount)}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="tx-amount neg">
|
||||
{formatMoney(totals.expense, "MXN")}
|
||||
{formatMoney(totals.expense, currency)}
|
||||
</strong>{" "}
|
||||
en egresos · {formatNumber(totals.expenseCount)}
|
||||
</span>
|
||||
<span className="filtered-total-net">
|
||||
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
|
||||
Neto <strong>{formatMoney(totals.net, currency)}</strong>
|
||||
</span>
|
||||
{totals.voidCount > 0 && (
|
||||
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
||||
@@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
|
||||
|
||||
function BankRow({
|
||||
m,
|
||||
currency,
|
||||
canVoid,
|
||||
onVoided,
|
||||
}: {
|
||||
m: BankListItem;
|
||||
currency: Currency;
|
||||
canVoid: boolean;
|
||||
onVoided: () => void;
|
||||
}) {
|
||||
@@ -620,7 +807,7 @@ function BankRow({
|
||||
<td>{bankSourceLabel(m.source)}</td>
|
||||
<td className="num">
|
||||
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
||||
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
|
||||
{m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
|
||||
</span>
|
||||
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
||||
</td>
|
||||
@@ -650,10 +837,12 @@ function BankRow({
|
||||
function SummaryView({
|
||||
summary,
|
||||
year,
|
||||
currency,
|
||||
onPickYear,
|
||||
}: {
|
||||
summary: BankSummary | null;
|
||||
year: number | null;
|
||||
currency: Currency;
|
||||
onPickYear: (y: number) => void;
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
@@ -687,12 +876,12 @@ function SummaryView({
|
||||
<td className="num">{formatNumber(r.count)}</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(r.income, "MXN")}
|
||||
{formatMoney(r.income, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(r.expense, "MXN")}
|
||||
{formatMoney(r.expense, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
@@ -701,10 +890,10 @@ function SummaryView({
|
||||
Number(r.net) < 0 ? "neg" : "pos"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(r.net, "MXN")}
|
||||
{formatMoney(r.net, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
|
||||
<td className="num mono">{formatMoney(r.cumulative, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -724,7 +913,7 @@ function SummaryView({
|
||||
<span className="section-rule cuenta" />
|
||||
<h2 className="section-title">Meses de {year}</h2>
|
||||
<span className="section-count">
|
||||
abre en {formatMoney(summary.opening, "MXN")}
|
||||
abre en {formatMoney(summary.opening, currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card">
|
||||
@@ -747,12 +936,12 @@ function SummaryView({
|
||||
<td className="num">{formatNumber(r.count)}</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount pos">
|
||||
{formatMoney(r.income, "MXN")}
|
||||
{formatMoney(r.income, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
<span className="tx-amount neg">
|
||||
{formatMoney(r.expense, "MXN")}
|
||||
{formatMoney(r.expense, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">
|
||||
@@ -761,11 +950,11 @@ function SummaryView({
|
||||
Number(r.net) < 0 ? "neg" : "pos"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(r.net, "MXN")}
|
||||
{formatMoney(r.net, currency)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num mono">
|
||||
{formatMoney(r.cumulative, "MXN")}
|
||||
{formatMoney(r.cumulative, currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -779,13 +968,16 @@ function SummaryView({
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline capture form for a single chequera movement. Single currency (MXN);
|
||||
* sign convention: positive = ingreso, negative = egreso. Booked rows are
|
||||
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
|
||||
/** Inline capture form for a single chequera movement. The amount is in the
|
||||
* selected account's currency; sign convention: positive = ingreso, negative
|
||||
* = egreso. Booked rows are never edited — fix mistakes with voidBankMovement
|
||||
* + a fresh capture. */
|
||||
function BankCaptureForm({
|
||||
account,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
account: BankAccount;
|
||||
onSaved: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
@@ -818,6 +1010,7 @@ function BankCaptureForm({
|
||||
}
|
||||
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
||||
const payload: CreateBankMovementInput = {
|
||||
bankAccountId: account.id,
|
||||
amount: signed,
|
||||
transactionDate,
|
||||
concept: s(concept),
|
||||
@@ -843,9 +1036,12 @@ function BankCaptureForm({
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
<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
|
||||
</h2>
|
||||
<p className="section-note" style={{ marginBottom: 14 }}>
|
||||
Se registra en <strong>{account.label}</strong>, en {account.currency}.
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
@@ -874,7 +1070,7 @@ function BankCaptureForm({
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">
|
||||
Monto (MXN) <span aria-hidden>*</span>
|
||||
Monto ({account.currency}) <span aria-hidden>*</span>
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Captura } from "@/components/Captura";
|
||||
|
||||
/** Daily capture, opened on the manual (key-by-hand) mode. */
|
||||
export default function BatchCapturePage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Captura initialMode="manual" />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getBillingStats,
|
||||
listBalances,
|
||||
listMovements,
|
||||
resolveOutstanding,
|
||||
voidMovement,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
@@ -117,6 +118,8 @@ function BillingBrowser() {
|
||||
const [direction, setDirection] = useState<LedgerDirection | "">("");
|
||||
const [typeId, setTypeId] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
// "" = no filter, "true" = only NOPAGO rows, "false" = only settled ones.
|
||||
const [outstanding, setOutstanding] = useState<"" | "true" | "false">("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [movementSort, setMovementSort] = useState<MovementSort>("date_desc");
|
||||
@@ -126,6 +129,7 @@ function BillingBrowser() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
const [resolving, setResolving] = useState<MovementListItem | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
@@ -165,6 +169,7 @@ function BillingBrowser() {
|
||||
direction: direction || undefined,
|
||||
typeId: typeId || undefined,
|
||||
source: source || undefined,
|
||||
outstanding: outstanding === "" ? undefined : outstanding === "true",
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
sort: movementSort,
|
||||
@@ -188,6 +193,7 @@ function BillingBrowser() {
|
||||
direction,
|
||||
typeId,
|
||||
source,
|
||||
outstanding,
|
||||
from,
|
||||
to,
|
||||
movementSort,
|
||||
@@ -304,16 +310,35 @@ function BillingBrowser() {
|
||||
))}
|
||||
</div>
|
||||
{view === "movimientos" && canCapture && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setCaptureOpen((v) => !v)}
|
||||
>
|
||||
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||
</button>
|
||||
<div style={{ display: "flex", gap: 10 }}>
|
||||
<Link href="/estado-cuenta/lote" className="btn btn-outline">
|
||||
Captura por cheque
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => setCaptureOpen((v) => !v)}
|
||||
>
|
||||
{captureOpen ? "Cerrar captura" : "Capturar movimiento"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{view === "movimientos" && resolving && (
|
||||
<ResolveDialog
|
||||
movement={resolving}
|
||||
onCancel={() => setResolving(null)}
|
||||
onDone={() => {
|
||||
setResolving(null);
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBillingStats()
|
||||
.then(setStats)
|
||||
.catch(() => setStats(null));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "movimientos" && captureOpen && (
|
||||
<section className="section">
|
||||
<div className="section-head">
|
||||
@@ -447,6 +472,21 @@ function BillingBrowser() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Estado de pago</span>
|
||||
<select
|
||||
className="input select"
|
||||
value={outstanding}
|
||||
onChange={(e) =>
|
||||
setOutstanding(e.target.value as "" | "true" | "false")
|
||||
}
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="true">Sin fondos (pendientes)</option>
|
||||
<option value="false">Pagados</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="filter-field">
|
||||
<span className="filter-label">Desde</span>
|
||||
<input
|
||||
@@ -552,7 +592,9 @@ function BillingBrowser() {
|
||||
<th>Concepto</th>
|
||||
<th>Referencia</th>
|
||||
<th className="num">Monto</th>
|
||||
{canVoid && <th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>}
|
||||
{(canVoid || canCapture) && (
|
||||
<th style={{ width: 1, whiteSpace: "nowrap" }}>Acciones</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -561,6 +603,8 @@ function BillingBrowser() {
|
||||
key={m.id}
|
||||
m={m}
|
||||
canVoid={canVoid}
|
||||
canCapture={canCapture}
|
||||
onResolve={setResolving}
|
||||
onVoided={() => {
|
||||
runSearch(movements?.page ?? 1);
|
||||
getBillingStats()
|
||||
@@ -799,11 +843,15 @@ function BalanceRow({
|
||||
function MovementRow({
|
||||
m,
|
||||
canVoid,
|
||||
canCapture,
|
||||
onVoided,
|
||||
onResolve,
|
||||
}: {
|
||||
m: MovementListItem;
|
||||
canVoid: boolean;
|
||||
canCapture: boolean;
|
||||
onVoided: () => void;
|
||||
onResolve: (m: MovementListItem) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -854,11 +902,29 @@ function MovementRow({
|
||||
</span>
|
||||
<div className="tx-cur">
|
||||
{m.currency} · {directionLabel(m.direction)}
|
||||
{m.outstanding && !m.voided && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="tx-outstanding">sin fondos</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{canVoid && (
|
||||
{(canVoid || canCapture) && (
|
||||
<td style={{ whiteSpace: "nowrap" }}>
|
||||
{!m.voided && (
|
||||
{/* Resolver only makes sense on a live outstanding row, and it's a
|
||||
capture action (completing one), not a void. */}
|
||||
{!m.voided && m.outstanding && canCapture && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||
onClick={() => onResolve(m)}
|
||||
>
|
||||
Resolver
|
||||
</button>
|
||||
)}
|
||||
{!m.voided && canVoid && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
@@ -875,6 +941,97 @@ function MovementRow({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an outstanding row: the check finally got cut. Takes the check number
|
||||
* and the date it was paid, which also becomes the movement's date — the legacy
|
||||
* behavior, since the ledger date is when money actually moved.
|
||||
*/
|
||||
function ResolveDialog({
|
||||
movement,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
movement: MovementListItem;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [resolvedDate, setResolvedDate] = useState(
|
||||
new Date().toISOString().slice(0, 10),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!checkNumber.trim()) {
|
||||
setError("Indica el número de cheque.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resolveOutstanding(movement.id, {
|
||||
checkNumber: checkNumber.trim(),
|
||||
resolvedDate,
|
||||
});
|
||||
onDone();
|
||||
} catch (e2) {
|
||||
setError((e2 as Error)?.message ?? "No se pudo resolver el movimiento.");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 6 }}>
|
||||
Resolver movimiento sin fondos
|
||||
</h2>
|
||||
<p className="muted" style={{ marginBottom: 14 }}>
|
||||
{movement.customerName} · {formatMoney(movement.amount, movement.currency)}{" "}
|
||||
{movement.currency}
|
||||
{movement.reference ? ` · ${movement.reference}` : ""}
|
||||
</p>
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
<form onSubmit={submit}>
|
||||
<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)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Fecha de pago *</span>
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
required
|
||||
value={resolvedDate}
|
||||
onChange={(e) => setResolvedDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted" style={{ fontSize: 13, marginTop: 10 }}>
|
||||
El movimiento tomará esta fecha y empezará a contar en el saldo del
|
||||
cliente.
|
||||
</p>
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
{busy ? "Resolviendo…" : "Resolver"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
|
||||
+722
-381
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
||||
getPolicyStats,
|
||||
getPropertyStats,
|
||||
getStats,
|
||||
listBankAccounts,
|
||||
} from "@/lib/api";
|
||||
import { useAuth } from "@/lib/abilities";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
trustStatusLabel,
|
||||
} from "@/lib/labels";
|
||||
import type {
|
||||
BankAccount,
|
||||
BankStats,
|
||||
BillingStats,
|
||||
CustomerStats,
|
||||
@@ -41,7 +43,29 @@ interface DashboardData {
|
||||
policies: PolicyStats | null;
|
||||
properties: PropertyStats | null;
|
||||
billing: BillingStats | null;
|
||||
/** Figures for ONE chequera — see `bankAccount` for which. */
|
||||
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() {
|
||||
@@ -52,25 +76,42 @@ function HomeDashboard() {
|
||||
properties: null,
|
||||
billing: null,
|
||||
bank: null,
|
||||
bankAccount: null,
|
||||
bankAccountCount: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
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([
|
||||
getStats(),
|
||||
getPolicyStats(),
|
||||
getPropertyStats(),
|
||||
getBillingStats(),
|
||||
getBankStats(),
|
||||
bank,
|
||||
]).then((results) => {
|
||||
if (!alive) return;
|
||||
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
|
||||
setData({
|
||||
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
||||
properties: results[2].status === "fulfilled" ? results[2].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);
|
||||
});
|
||||
@@ -260,22 +301,27 @@ function HomeDashboard() {
|
||||
loading={loading}
|
||||
title="Chequera del despacho"
|
||||
primary={
|
||||
data.bank ? (
|
||||
data.bank && data.bankAccount ? (
|
||||
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
||||
{formatMoney(data.bank.net, "MXN")}
|
||||
{formatMoney(data.bank.net, data.bankAccount.currency)}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
// Names the account, because this is one chequera's figure and the
|
||||
// office has more than one — they are never added together.
|
||||
sub={
|
||||
data.bank
|
||||
? balancePhrase(data.bank.net)
|
||||
data.bank && data.bankAccount
|
||||
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
|
||||
: undefined
|
||||
}
|
||||
meta={
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { readBuildInfoFromEnv } from "@/lib/build-info";
|
||||
|
||||
export const metadata = {
|
||||
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||
@@ -20,6 +21,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
process.env.API_ORIGIN ??
|
||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||
"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 (
|
||||
<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. */}
|
||||
<script
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
startOpsJob,
|
||||
uploadIngest,
|
||||
} from "@/lib/api";
|
||||
import type { UploadProgress } from "@/lib/api";
|
||||
import type {
|
||||
BackupFile,
|
||||
IngestFile,
|
||||
@@ -56,6 +57,7 @@ function Operaciones() {
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
@@ -119,14 +121,16 @@ function Operaciones() {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setUploading(name);
|
||||
setProgress(null);
|
||||
try {
|
||||
await uploadIngest(name, file);
|
||||
await uploadIngest(name, file, setProgress);
|
||||
setNotice(`${name} cargado.`);
|
||||
refreshLists();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo cargar el archivo.");
|
||||
} finally {
|
||||
setUploading(null);
|
||||
setProgress(null);
|
||||
const input = fileInputs.current[name];
|
||||
if (input) input.value = "";
|
||||
}
|
||||
@@ -243,45 +247,55 @@ function Operaciones() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ingest ?? []).map((f) => (
|
||||
<tr key={f.name}>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
{f.present && (
|
||||
<Fragment key={f.name}>
|
||||
<tr>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
Eliminar
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{f.present && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{uploading === f.name && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<UploadProgressBar progress={progress} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -496,6 +510,61 @@ function Operaciones() {
|
||||
);
|
||||
}
|
||||
|
||||
/** "1:05" / "0:09" — remaining time, coarse on purpose. */
|
||||
function formatEta(seconds: number): string {
|
||||
const s = Math.max(0, Math.round(seconds));
|
||||
if (s >= 3600) {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.round((s % 3600) / 60);
|
||||
return `${h} h ${m} min`;
|
||||
}
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live upload readout. The bar tracks bytes handed to the network; once those
|
||||
* are all sent the server still has to write the file, so the tail of the
|
||||
* upload reads "Procesando…" instead of sitting at 100%.
|
||||
*/
|
||||
function UploadProgressBar({ progress }: { progress: UploadProgress | null }) {
|
||||
const pct = progress?.fraction != null ? Math.round(progress.fraction * 100) : null;
|
||||
return (
|
||||
<div className="upload-progress">
|
||||
<div
|
||||
className={`progress-track${pct === null ? " progress-indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct ?? undefined}
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${pct ?? 100}%` }} />
|
||||
</div>
|
||||
<div className="upload-progress-stats mono">
|
||||
{progress === null ? (
|
||||
"Preparando…"
|
||||
) : progress.finishing ? (
|
||||
`Procesando en el servidor… (${formatBytes(progress.total)} enviados)`
|
||||
) : (
|
||||
<>
|
||||
{pct !== null && <strong>{pct}%</strong>}
|
||||
{progress.total > 0 && (
|
||||
<span>
|
||||
{formatBytes(progress.loaded)} / {formatBytes(progress.total)}
|
||||
</span>
|
||||
)}
|
||||
{progress.bytesPerSecond > 0 && (
|
||||
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
|
||||
)}
|
||||
{progress.secondsRemaining !== null && progress.bytesPerSecond > 0 && (
|
||||
<span>faltan {formatEta(progress.secondsRemaining)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpTile({
|
||||
title,
|
||||
desc,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PolicyOcrReview } from "@/components/PolicyOcrReview";
|
||||
|
||||
export default function PolicyOcrBatchPage({
|
||||
params,
|
||||
}: {
|
||||
params: { id: string };
|
||||
}) {
|
||||
return (
|
||||
<AppShell>
|
||||
<PolicyOcrReview id={params.id} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||
|
||||
/**
|
||||
* OCR mode of the policy intake screen. Drops the GMX PDF, walks through
|
||||
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
||||
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
||||
* modes doesn't drop state.
|
||||
*
|
||||
* Sister route `/polizas/captura/[id]` is the batch review screen once a
|
||||
* batch is uploaded.
|
||||
*/
|
||||
export default function CapturaOcrPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<PolicyCaptura initialMode="auto" />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { PolicyForm } from "@/components/PolicyForm";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||
|
||||
/**
|
||||
* Manual mode of the policy intake screen. Shares the tab wrapper with
|
||||
* `/polizas/captura` (OCR mode) so staff can swap between the two without
|
||||
* losing their place. Customer picker comes from the `?customerId=`
|
||||
* / `?customerName=` query string — used by `/clientes/[id]` when staff
|
||||
* creates a policy from a customer detail page.
|
||||
*/
|
||||
export default function NuevaPolizaPage() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Suspense fallback={null}>
|
||||
<NuevaPoliza />
|
||||
<PolicyCaptura initialMode="manual" />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function NuevaPoliza() {
|
||||
const allowed = useCan("policy:create");
|
||||
const params = useSearchParams();
|
||||
const customerId = params.get("customerId") ?? undefined;
|
||||
const customerName = params.get("customerName") ?? undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
||||
<h1 className="page-title">Nueva póliza</h1>
|
||||
</div>
|
||||
{allowed ? (
|
||||
<PolicyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
|
||||
) : (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para crear pólizas.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function PolizasPage() {
|
||||
|
||||
function PolizasBrowser() {
|
||||
const canCreate = useCan("policy:create");
|
||||
const canIngest = useCan("policy:ingest");
|
||||
const [stats, setStats] = useState<PolicyStats | null>(null);
|
||||
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
||||
|
||||
@@ -135,6 +136,11 @@ function PolizasBrowser() {
|
||||
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
|
||||
]}
|
||||
/>
|
||||
{canIngest && (
|
||||
<Link href="/polizas/captura" className="btn btn-outline">
|
||||
+ Captura OCR
|
||||
</Link>
|
||||
)}
|
||||
{canCreate && (
|
||||
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"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 && (
|
||||
<ProcessingBanner docsLength={docs.length} pendingOcr={batch.byStatus.PENDING_OCR ?? 0} />
|
||||
)}
|
||||
|
||||
<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ó",
|
||||
};
|
||||
|
||||
/**
|
||||
* Live readout while OCR is running. The backend tells us how many pages are
|
||||
* still PENDING_OCR, so we can show real progress instead of "loading…". When
|
||||
* the docs list hasn't caught up to the upload yet (total === 0) we fall back
|
||||
* to the indeterminate bar.
|
||||
*/
|
||||
function ProcessingBanner({
|
||||
docsLength,
|
||||
pendingOcr,
|
||||
}: {
|
||||
docsLength: number;
|
||||
pendingOcr: number;
|
||||
}) {
|
||||
const done = Math.max(docsLength - pendingOcr, 0);
|
||||
const pct =
|
||||
docsLength > 0 ? Math.min(100, Math.round((done / docsLength) * 100)) : null;
|
||||
return (
|
||||
<div className="card" style={{ padding: 16 }}>
|
||||
<div className="upload-progress" style={{ padding: 0 }}>
|
||||
<div
|
||||
className={`progress-track${pct === null ? " progress-indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct ?? undefined}
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${pct ?? 100}%` }} />
|
||||
</div>
|
||||
<div className="upload-progress-stats">
|
||||
{pct === null ? (
|
||||
<span>Leyendo los recibos…</span>
|
||||
) : (
|
||||
<>
|
||||
<strong>{pct}%</strong>
|
||||
<span>
|
||||
{done} de {docsLength} página(s) leídas
|
||||
</span>
|
||||
{pendingOcr > 0 && <span>{pendingOcr} en cola</span>}
|
||||
</>
|
||||
)}
|
||||
<span style={{ marginLeft: "auto" }}>
|
||||
Esta pantalla se actualiza sola.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
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 { 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";
|
||||
|
||||
/**
|
||||
@@ -14,34 +23,264 @@ import type { AuthUser, Ability } from "@/lib/types";
|
||||
* content. Provides the AuthContext so any page can read the user's
|
||||
* abilities. Used by every authenticated page.
|
||||
*/
|
||||
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
|
||||
{ href: "/inicio", label: "Inicio", exact: true },
|
||||
{ href: "/clientes", label: "Clientes" },
|
||||
{ href: "/servicios", label: "Propiedades" },
|
||||
{ href: "/polizas", label: "Pólizas" },
|
||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||
{ href: "/banco", label: "Chequera" },
|
||||
{ href: "/reportes", label: "Reportes" },
|
||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||
|
||||
type NavLink = {
|
||||
href: string;
|
||||
label: string;
|
||||
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: "/banco", label: "Chequera" },
|
||||
],
|
||||
},
|
||||
{ kind: "link", href: "/reportes", label: "Reportes" },
|
||||
{
|
||||
kind: "group",
|
||||
label: "Admin",
|
||||
items: [
|
||||
{ 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: "/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
|
||||
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
|
||||
* lighting up its parent (`/estado-cuenta`) — while `/estado-cuenta/<id>`, which
|
||||
* has no entry of its own, still correctly highlights the parent.
|
||||
*/
|
||||
function activeHref(pathname: string | null): string | null {
|
||||
if (!pathname) return null;
|
||||
let best: string | null = null;
|
||||
for (const item of NAV_LINKS) {
|
||||
const under = (href: string) =>
|
||||
pathname === href || pathname.startsWith(`${href}/`);
|
||||
const match = item.exact
|
||||
? pathname === item.href
|
||||
: under(item.href) || (item.aliases?.some(under) ?? false);
|
||||
if (match && (best === null || item.href.length > best.length)) {
|
||||
best = item.href;
|
||||
}
|
||||
}
|
||||
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 & 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 }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE);
|
||||
const current = activeHref(pathname);
|
||||
const nav = visibleNav(user);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
me()
|
||||
.then((u) => {
|
||||
if (alive) {
|
||||
setUser(u);
|
||||
setChecking(false);
|
||||
}
|
||||
if (!alive) return;
|
||||
setUser(u);
|
||||
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(() => {
|
||||
router.replace("/login");
|
||||
@@ -51,6 +290,39 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [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() {
|
||||
setLoggingOut(true);
|
||||
try {
|
||||
@@ -92,26 +364,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="appbar-nav" aria-label="Principal">
|
||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
||||
(item) => {
|
||||
const active = item.exact
|
||||
? pathname === item.href
|
||||
: pathname?.startsWith(item.href) ?? false;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`appbar-link${active ? " active" : ""}`}
|
||||
aria-current={active ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
{nav.map((entry) =>
|
||||
entry.kind === "link" ? (
|
||||
<Link
|
||||
key={entry.href}
|
||||
href={entry.href}
|
||||
className={`appbar-link${current === entry.href ? " active" : ""}`}
|
||||
aria-current={current === entry.href ? "page" : undefined}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
) : (
|
||||
<NavMenu
|
||||
key={entry.label}
|
||||
label={entry.label}
|
||||
items={entry.items}
|
||||
current={current}
|
||||
pathname={pathname}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
<span className="appbar-spacer" />
|
||||
<div className="appbar-user">
|
||||
<FontScaleControl value={uiScale} onChange={changeUiScale} />
|
||||
{user && (
|
||||
<span className="appbar-user-name">
|
||||
{user.name}
|
||||
@@ -127,9 +403,59 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
||||
</button>
|
||||
</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>
|
||||
{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>
|
||||
<main className="shell-main">{children}</main>
|
||||
<BuildFooter />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -62,9 +62,15 @@ export function MovementForm({
|
||||
const [reference, setReference] = useState("");
|
||||
const [checkNumber, setCheckNumber] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [outstanding, setOutstanding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// "Sin fondos" is a per-service *charge* concept: the office recorded a
|
||||
// utility bill it couldn't cover. It never applies to a credit (a payment
|
||||
// that arrived is, by definition, funded) or to the insurance/trust lines.
|
||||
const canBeOutstanding = domain === "UTILITY" && direction === "charge";
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!customerId) {
|
||||
@@ -88,6 +94,9 @@ export function MovementForm({
|
||||
reference: s(reference),
|
||||
checkNumber: s(checkNumber),
|
||||
message: s(message),
|
||||
// Guarded by canBeOutstanding so a stale checkbox can't ride along after
|
||||
// the user switches the row to a credit or another business line.
|
||||
outstanding: canBeOutstanding && outstanding ? true : undefined,
|
||||
};
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
@@ -225,6 +234,28 @@ export function MovementForm({
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{canBeOutstanding && (
|
||||
<label
|
||||
className="field"
|
||||
style={{ marginTop: 16, flexDirection: "row", alignItems: "center", gap: 10 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={outstanding}
|
||||
onChange={(e) => setOutstanding(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="field-label" style={{ display: "block" }}>
|
||||
Sin fondos (pendiente de pago)
|
||||
</span>
|
||||
<span className="muted" style={{ fontSize: 13 }}>
|
||||
El cargo se registra pero no afecta el saldo del cliente hasta
|
||||
que se resuelva con un cheque.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PolicyForm } from "@/components/PolicyForm";
|
||||
import { PolicyOcrIntake } from "@/components/PolicyOcrIntake";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
|
||||
/**
|
||||
* Policy intake — mirror of `Captura.tsx` (statement OCR side): one screen,
|
||||
* two ways in:
|
||||
*
|
||||
* - **manual** — `PolicyForm` keys every field by hand.
|
||||
* - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the
|
||||
* policy, a human still confirms.
|
||||
*
|
||||
* Both end at the same place (a `Policy` row on a customer's file) so they
|
||||
* live as two modes of one screen rather than two menu entries — exactly the
|
||||
* same shape Captura uses for `ManualCheckCapture` vs `StatementIntake`.
|
||||
*
|
||||
* `/polizas/nuevo` opens manual, `/polizas/captura` opens auto; both render
|
||||
* this component so the tab toggle works either way and an old bookmark
|
||||
* still lands on the right tab.
|
||||
*/
|
||||
export type PolicyCaptureMode = "manual" | "auto";
|
||||
|
||||
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
||||
manual:
|
||||
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
|
||||
auto: "Suelte el PDF descargado del portal de GMX y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||
};
|
||||
|
||||
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
||||
const canCreate = useCan("policy:create");
|
||||
const canIngest = useCan("policy:ingest");
|
||||
|
||||
// `/clientes/[id]` deep-links into /polizas/nuevo with the customer
|
||||
// pre-picked so staff can fill the rest without retyping. The OCR pane
|
||||
// ignores these — there's no customer to lock in until the batch is
|
||||
// confirmed.
|
||||
const params = useSearchParams();
|
||||
const fixedCustomerId = params.get("customerId") ?? undefined;
|
||||
const fixedCustomerName = params.get("customerName") ?? undefined;
|
||||
|
||||
// One user can land on either mode. The tab strip only renders when both
|
||||
// abilities are held — a STAFF with only policy:ingest (no create) still
|
||||
// sees the screen but only the OCR tab is offered.
|
||||
const modes: { key: PolicyCaptureMode; label: string }[] = [
|
||||
...(canCreate ? [{ key: "manual" as const, label: "Captura manual" }] : []),
|
||||
...(canIngest ? [{ key: "auto" as const, label: "Captura automática (OCR)" }] : []),
|
||||
];
|
||||
|
||||
const [mode, setMode] = useState<PolicyCaptureMode>(
|
||||
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 crear ni capturar pólizas.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-head">
|
||||
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
||||
<h1 className="page-title">Nueva póliza</h1>
|
||||
<p className="eyebrow">{MODE_HINT[mode]}</p>
|
||||
</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" ? (
|
||||
<ManualPane
|
||||
fixedCustomerId={fixedCustomerId}
|
||||
fixedCustomerName={fixedCustomerName}
|
||||
/>
|
||||
) : (
|
||||
<PolicyOcrIntake />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualPane({
|
||||
fixedCustomerId,
|
||||
fixedCustomerName,
|
||||
}: {
|
||||
fixedCustomerId?: string;
|
||||
fixedCustomerName?: string;
|
||||
}) {
|
||||
const allowed = useCan("policy:create");
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="state-box state-error">
|
||||
No tiene permisos para crear pólizas.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PolicyForm
|
||||
fixedCustomerId={fixedCustomerId}
|
||||
fixedCustomerName={fixedCustomerName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getPolicyOcrStatus,
|
||||
listPolicyOcrBatches,
|
||||
uploadPolicyOcrBatch,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate } from "@/lib/labels";
|
||||
import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
||||
* side. Today the only provider is GMX; the parser dispatches on a brand
|
||||
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
|
||||
* and a new portal only needs a new BRAND entry plus a parser file.
|
||||
*
|
||||
* Lives inside the `Pólizas` page rather than a top-level route because it
|
||||
* is one mode of one job (staff uploading whatever PDFs the office has on
|
||||
* hand that day, mixed service vs insurance), and the matching/review queue
|
||||
* already keys on the policyNumber → existing Policy transition that the
|
||||
* rest of /polizas owns.
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<PolicyOcrBatchStatus, string> = {
|
||||
UPLOADED: "Recibido",
|
||||
PROCESSING: "Procesando…",
|
||||
READY_FOR_REVIEW: "Listo para revisar",
|
||||
COMPLETED: "Aplicado",
|
||||
FAILED: "Falló",
|
||||
};
|
||||
|
||||
export function PolicyOcrIntake() {
|
||||
const canIngest = useCan("policy:ingest");
|
||||
const [batches, setBatches] = useState<PolicyOcrBatch[]>([]);
|
||||
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([
|
||||
listPolicyOcrBatches(),
|
||||
getPolicyOcrStatus(),
|
||||
]);
|
||||
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]);
|
||||
|
||||
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 PDFs
|
||||
de pólizas escaneados. La captura manual sigue funcionando.
|
||||
</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 PDFs. Mientras tanto, capture las
|
||||
pólizas a mano.
|
||||
</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 pólizas. Descargue el certificado del portal
|
||||
de GMX y suéltelo arriba.
|
||||
</div>
|
||||
) : (
|
||||
<div className="tx-scroll">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Aseguradora</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>{b.provider}</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={`/polizas/captura/${b.id}`}
|
||||
>
|
||||
Revisar
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: PolicyOcrBatchStatus }) {
|
||||
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
|
||||
}
|
||||
|
||||
function UploadCard({ onDone }: { onDone: () => void }) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
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 uploadPolicyOcrBatch(files, label.trim() || undefined);
|
||||
setFiles([]);
|
||||
setLabel("");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo subir el lote.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Subir PDFs de pólizas (GMX)
|
||||
</h2>
|
||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<span className="page-sub">Referencia (opcional)</span>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="ej. GMX 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>
|
||||
)}
|
||||
|
||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||
Un lote puede traer varios PDFs. Cada página se procesa por separado; el
|
||||
sistema busca una póliza existente por número y, si no la encuentra,
|
||||
propone crear una nueva bajo el cliente que se elija en la revisión.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import {
|
||||
confirmPolicyOcrBatch,
|
||||
getPolicyOcrBatch,
|
||||
listCustomers,
|
||||
listPolicyOcrDocuments,
|
||||
policyOcrDocumentUrl,
|
||||
rejectPolicyOcrDocument,
|
||||
reviewPolicyOcrDocument,
|
||||
} from "@/lib/api";
|
||||
import { useCan } from "@/lib/abilities";
|
||||
import { formatDate, formatMoney } from "@/lib/labels";
|
||||
import type {
|
||||
CustomerListItem,
|
||||
PolicyOcrBatchDetail,
|
||||
PolicyOcrConfirmDocument,
|
||||
PolicyOcrCoverage,
|
||||
PolicyOcrDocument,
|
||||
PolicyOcrReviewInput,
|
||||
} from "@/lib/types";
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING_OCR: "Pendiente",
|
||||
OCR_FAILED: "Falló OCR",
|
||||
NEEDS_REVIEW: "Para revisar",
|
||||
MATCHED: "Listo",
|
||||
CONFIRMED: "Confirmado",
|
||||
POSTED: "Aplicado",
|
||||
REJECTED: "Rechazado",
|
||||
};
|
||||
|
||||
const OPEN_FIRST = [
|
||||
"NEEDS_REVIEW",
|
||||
"MATCHED",
|
||||
"CONFIRMED",
|
||||
"PENDING_OCR",
|
||||
"OCR_FAILED",
|
||||
"REJECTED",
|
||||
"POSTED",
|
||||
];
|
||||
|
||||
type EditMap = Record<string, PolicyOcrConfirmDocument | undefined>;
|
||||
|
||||
export function PolicyOcrReview({ id }: { id: string }) {
|
||||
const canReview = useCan("policy:ocr-review");
|
||||
const [batch, setBatch] = useState<PolicyOcrBatchDetail | null>(null);
|
||||
const [docs, setDocs] = useState<PolicyOcrDocument[]>([]);
|
||||
const [edits, setEdits] = useState<EditMap>({});
|
||||
const [customerIndex, setCustomerIndex] = useState<Record<string, CustomerListItem>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [b, d, c] = await Promise.all([
|
||||
getPolicyOcrBatch(id),
|
||||
listPolicyOcrDocuments(id),
|
||||
canReview ? listCustomers({ pageSize: 200 }).then((r) => r.items) : Promise.resolve([]),
|
||||
]);
|
||||
setBatch(b);
|
||||
setDocs(d);
|
||||
setCustomerIndex(Object.fromEntries(c.map((x) => [x.id, x])));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, canReview]);
|
||||
|
||||
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 = Object.values(edits).filter(Boolean).length;
|
||||
|
||||
function setEdit(docId: string, edit: PolicyOcrConfirmDocument) {
|
||||
setEdits((prev) => ({ ...prev, [docId]: edit }));
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
if (!batch) return;
|
||||
const payload: PolicyOcrConfirmDocument[] = [];
|
||||
for (const d of docs) {
|
||||
const edit = edits[d.id];
|
||||
if (!edit) continue;
|
||||
if (!edit.policyId && !edit.customerId) {
|
||||
setError(`Página ${d.pageNumber}: falta cliente o póliza destino.`);
|
||||
return;
|
||||
}
|
||||
payload.push(edit);
|
||||
}
|
||||
if (!payload.length) {
|
||||
setError("No hay documentos revisados. Guarde cada página antes de aplicar.");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await confirmPolicyOcrBatch(batch.id, { documents: payload });
|
||||
setEdits({});
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? "No se pudo aplicar el lote.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
Pólizas — {batch.provider}
|
||||
{batch.label ? ` · ${batch.label}` : ""}
|
||||
</h1>
|
||||
<p className="page-sub">
|
||||
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
|
||||
{STATUS_LABEL[batch.status] ?? batch.status}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="btn btn-ghost" href="/polizas">
|
||||
Volver a pólizas
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{processing && <div className="state-box">Procesando…</div>}
|
||||
|
||||
{error && <div className="state-box state-error">{error}</div>}
|
||||
|
||||
{canReview && readyCount > 0 && (
|
||||
<section className="card" style={{ padding: 16 }}>
|
||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||
Aplicar lote
|
||||
</h2>
|
||||
<p className="page-sub" style={{ marginBottom: 12 }}>
|
||||
{readyCount} página(s) revisada(s). Se creará o actualizará la póliza
|
||||
y, si marcó la casilla, se registrará la prima en el estado de
|
||||
cuenta.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{submitting ? "Aplicando…" : "Aplicar"}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="stack">
|
||||
{sorted.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
customerIndex={customerIndex}
|
||||
canReview={canReview}
|
||||
onSave={async (edit) => {
|
||||
await reviewPolicyOcrDocument(doc.id, edit.reviewInput);
|
||||
setEdit(doc.id, edit.confirmInput);
|
||||
await load();
|
||||
}}
|
||||
onReject={async () => {
|
||||
await rejectPolicyOcrDocument(doc.id);
|
||||
setEdits((prev) => {
|
||||
const { [doc.id]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RowSaved {
|
||||
reviewInput: PolicyOcrReviewInput;
|
||||
confirmInput: PolicyOcrConfirmDocument;
|
||||
}
|
||||
|
||||
interface DocumentRowProps {
|
||||
doc: PolicyOcrDocument;
|
||||
customerIndex: Record<string, CustomerListItem>;
|
||||
canReview: boolean;
|
||||
onSave: (saved: RowSaved) => Promise<void>;
|
||||
onReject: () => Promise<void>;
|
||||
}
|
||||
|
||||
function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: DocumentRowProps) {
|
||||
const [v, setV] = useState({
|
||||
policyNumber: doc.extractedPolicyNumber ?? "",
|
||||
insuredName: doc.extractedInsuredName ?? "",
|
||||
additionalInsured: doc.extractedAdditionalInsured ?? "",
|
||||
agentName: doc.extractedAgentName ?? "",
|
||||
legalAddress: doc.extractedLegalAddress ?? "",
|
||||
zip: doc.extractedZip ?? "",
|
||||
policyFrom: doc.extractedPolicyFrom?.slice(0, 10) ?? "",
|
||||
policyTo: doc.extractedPolicyTo?.slice(0, 10) ?? "",
|
||||
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
|
||||
currency: doc.extractedCurrency ?? "USD",
|
||||
netPremium: doc.extractedNetPremium ?? "",
|
||||
total: doc.extractedTotal ?? "",
|
||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
||||
});
|
||||
const [customerId, setCustomerId] = useState(
|
||||
doc.matchedCustomer?.id ?? doc.matchedPolicy?.customerId ?? "",
|
||||
);
|
||||
const [customerName, setCustomerName] = useState(
|
||||
doc.matchedCustomer?.name ?? doc.matchedPolicy?.customer.name ?? "",
|
||||
);
|
||||
const [policyId, setPolicyId] = useState(doc.matchedPolicy?.id ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
function set<K extends keyof typeof v>(k: K, val: (typeof v)[K]) {
|
||||
setV((p) => ({ ...p, [k]: val }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const numOrUndef = (s: string) => (s.trim() === "" ? undefined : Number(s));
|
||||
const trimOrUndef = (s: string) => (s.trim() === "" ? undefined : s.trim());
|
||||
const currency = v.currency || undefined;
|
||||
const reviewInput: PolicyOcrReviewInput = {
|
||||
policyNumber: trimOrUndef(v.policyNumber),
|
||||
insuredName: trimOrUndef(v.insuredName),
|
||||
additionalInsured: trimOrUndef(v.additionalInsured),
|
||||
agentName: trimOrUndef(v.agentName),
|
||||
legalAddress: trimOrUndef(v.legalAddress),
|
||||
zip: trimOrUndef(v.zip),
|
||||
policyFrom: v.policyFrom || undefined,
|
||||
policyTo: v.policyTo || undefined,
|
||||
policyDate: v.policyDate || undefined,
|
||||
currency,
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
total: numOrUndef(v.total),
|
||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||
matchedPolicyId: policyId || undefined,
|
||||
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
||||
forceConfirm: true,
|
||||
};
|
||||
const confirmInput: PolicyOcrConfirmDocument = {
|
||||
documentId: doc.id,
|
||||
policyId: policyId || undefined,
|
||||
customerId: !policyId && customerId ? customerId : undefined,
|
||||
policyNumber: reviewInput.policyNumber,
|
||||
insuredName: reviewInput.insuredName,
|
||||
additionalInsured: reviewInput.additionalInsured,
|
||||
agentName: reviewInput.agentName,
|
||||
legalAddress: reviewInput.legalAddress,
|
||||
zip: reviewInput.zip,
|
||||
policyFrom: reviewInput.policyFrom,
|
||||
policyTo: reviewInput.policyTo,
|
||||
policyDate: reviewInput.policyDate,
|
||||
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
|
||||
netPremium: reviewInput.netPremium,
|
||||
total: reviewInput.total,
|
||||
premiumPayment: reviewInput.premiumPayment,
|
||||
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
||||
| PolicyOcrCoverage[]
|
||||
| undefined,
|
||||
postPremium: v.postPremium,
|
||||
};
|
||||
await onSave({ reviewInput, confirmInput });
|
||||
} catch (e) {
|
||||
setErr((e as Error)?.message ?? "No se pudo guardar.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||
const matchedExisting = !!doc.matchedPolicy;
|
||||
const candidates = doc.matchCandidates ?? [];
|
||||
|
||||
return (
|
||||
<article className="card" style={{ padding: 16 }}>
|
||||
<header className="row" style={{ gap: 12, alignItems: "center" }}>
|
||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
|
||||
<span className="page-sub">Página {doc.pageNumber}</span>
|
||||
{doc.extractedPolicyNumber && (
|
||||
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong>
|
||||
)}
|
||||
{doc.extractedInsuredName && (
|
||||
<span className="page-sub">· {doc.extractedInsuredName}</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="doc-detail">
|
||||
{/*
|
||||
* Embed the source PDF the office uploaded. One PDF = one parsed
|
||||
* policy, so the browser's PDF viewer handles multi-page navigation
|
||||
* natively; we don't need to render individual pages on the server.
|
||||
*/}
|
||||
<iframe
|
||||
src={policyOcrDocumentUrl(doc.id)}
|
||||
title={`Póliza ${doc.extractedPolicyNumber ?? doc.pageNumber}`}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 720,
|
||||
border: "1px solid var(--border, #ddd)",
|
||||
borderRadius: 6,
|
||||
background: "#fff",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="stack" style={{ flex: 1, minWidth: 0 }}>
|
||||
{doc.matchNote && <p className="page-sub">{doc.matchNote}</p>}
|
||||
|
||||
{matchedExisting ? (
|
||||
<div className="state-box">
|
||||
Coincide con la póliza{" "}
|
||||
<strong>{doc.matchedPolicy?.policyNumber}</strong> del cliente{" "}
|
||||
<strong>{doc.matchedPolicy?.customer.name}</strong>.
|
||||
</div>
|
||||
) : candidates.length > 1 ? (
|
||||
<div className="state-box state-warn">
|
||||
{candidates.length} pólizas comparten este número. Elija
|
||||
manualmente abajo.
|
||||
</div>
|
||||
) : (
|
||||
<div className="state-box">
|
||||
No se encontró una póliza con este número. Se creará una nueva
|
||||
bajo el cliente que elija abajo.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="form-grid" disabled={locked || !canReview}>
|
||||
<Field label="Número de póliza">
|
||||
<input
|
||||
className="input"
|
||||
value={v.policyNumber}
|
||||
onChange={(e) => set("policyNumber", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Asegurado">
|
||||
<input
|
||||
className="input"
|
||||
value={v.insuredName}
|
||||
onChange={(e) => set("insuredName", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Asegurado adicional">
|
||||
<input
|
||||
className="input"
|
||||
value={v.additionalInsured}
|
||||
onChange={(e) => set("additionalInsured", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Agente">
|
||||
<input
|
||||
className="input"
|
||||
value={v.agentName}
|
||||
onChange={(e) => set("agentName", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Desde">
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={v.policyFrom}
|
||||
onChange={(e) => set("policyFrom", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Hasta">
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={v.policyTo}
|
||||
onChange={(e) => set("policyTo", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Fecha de firma">
|
||||
<input
|
||||
className="input"
|
||||
type="date"
|
||||
value={v.policyDate}
|
||||
onChange={(e) => set("policyDate", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Moneda">
|
||||
<select
|
||||
className="input select"
|
||||
value={v.currency}
|
||||
onChange={(e) => set("currency", e.target.value)}
|
||||
>
|
||||
<option value="MXN">MXN</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Prima neta">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={v.netPremium}
|
||||
onChange={(e) => set("netPremium", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Total">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={v.total}
|
||||
onChange={(e) => set("total", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Pago de prima">
|
||||
<input
|
||||
className="input"
|
||||
value={v.premiumPayment}
|
||||
onChange={(e) => set("premiumPayment", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Dirección">
|
||||
<input
|
||||
className="input"
|
||||
value={v.legalAddress}
|
||||
onChange={(e) => set("legalAddress", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="C.P.">
|
||||
<input
|
||||
className="input"
|
||||
value={v.zip}
|
||||
onChange={(e) => set("zip", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</fieldset>
|
||||
|
||||
{doc.extractedCoveragesJson && doc.extractedCoveragesJson.length > 0 && (
|
||||
<details>
|
||||
<summary>
|
||||
Coberturas ({doc.extractedCoveragesJson.length}) ·{" "}
|
||||
{formatMoney(
|
||||
doc.extractedCoveragesJson
|
||||
.map((c) => Number(c.insuredAmount ?? 0))
|
||||
.reduce((a, b) => a + b, 0)
|
||||
.toString(),
|
||||
v.currency,
|
||||
)}
|
||||
</summary>
|
||||
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Riesgo</th>
|
||||
<th className="num">Suma</th>
|
||||
<th>Deducible</th>
|
||||
<th>Participación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.extractedCoveragesJson.map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>{c.risk}</td>
|
||||
<td className="num">
|
||||
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
||||
</td>
|
||||
<td>{c.deductible ?? "—"}</td>
|
||||
<td>{c.lossParticipation ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{candidates.length > 1 && (
|
||||
<Field label="Póliza destino">
|
||||
<select
|
||||
className="input select"
|
||||
value={policyId}
|
||||
onChange={(e) => {
|
||||
setPolicyId(e.target.value);
|
||||
const found = candidates.find((c) => c.policyId === e.target.value);
|
||||
if (found) {
|
||||
setCustomerId(found.customerId);
|
||||
setCustomerName(customerIndex[found.customerId]?.name ?? found.customerName);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">— elegir póliza —</option>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.policyId} value={c.policyId}>
|
||||
{c.policyNumber} · {c.customerName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!policyId && (
|
||||
<Field label={matchedExisting ? "Cliente" : "Cliente (póliza nueva)"}>
|
||||
<CustomerPicker
|
||||
value={customerId}
|
||||
valueName={customerName}
|
||||
onPick={(id, name) => {
|
||||
setCustomerId(id);
|
||||
setCustomerName(name);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<label className="field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={v.postPremium}
|
||||
onChange={(e) => set("postPremium", e.target.checked)}
|
||||
disabled={!v.netPremium || Number(v.netPremium) <= 0}
|
||||
/>{" "}
|
||||
Registrar prima en el estado de cuenta
|
||||
</label>
|
||||
|
||||
{err && <div className="state-box state-error">{err}</div>}
|
||||
|
||||
{!locked && canReview && (
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
|
||||
{busy ? "Guardando…" : "Guardar revisión"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void onReject()}
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field-label">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"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",
|
||||
"GAS",
|
||||
"PROPERTY_TAX",
|
||||
"FEDERAL_ZONE",
|
||||
];
|
||||
/** Uploadable, but every page will land in review until a parser learns it. */
|
||||
const OTHER_KINDS: ServiceKind[] = ["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 sí 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>
|
||||
);
|
||||
}
|
||||
+438
-28
@@ -6,18 +6,34 @@ import type {
|
||||
BalanceFilter,
|
||||
BalanceListResponse,
|
||||
BalanceSort,
|
||||
BankAccount,
|
||||
BankCleared,
|
||||
BankDirection,
|
||||
BankFacets,
|
||||
BankInstitution,
|
||||
BankListResponse,
|
||||
BankSort,
|
||||
BankStats,
|
||||
BankSummary,
|
||||
BatchCreateInput,
|
||||
ConfirmBatchInput,
|
||||
ConfirmBatchResult,
|
||||
BatchCreateResponse,
|
||||
BillingFacets,
|
||||
BillingStats,
|
||||
BusinessLine,
|
||||
ByCheckResponse,
|
||||
CreateBankAccountInput,
|
||||
CreateBankInput,
|
||||
CreateBankMovementInput,
|
||||
CreateMovementInput,
|
||||
UpdateBankAccountInput,
|
||||
ResolveOutstandingInput,
|
||||
ReviewDocumentInput,
|
||||
StatementBatch,
|
||||
StatementBatchDetail,
|
||||
StatementDocument,
|
||||
StatementDocumentStatus,
|
||||
CustomerDetail,
|
||||
CustomerInput,
|
||||
CustomerListResponse,
|
||||
@@ -33,6 +49,12 @@ import type {
|
||||
PolicySort,
|
||||
PolicyStats,
|
||||
PolicyStatus,
|
||||
PolicyOcrBatch,
|
||||
PolicyOcrBatchDetail,
|
||||
PolicyOcrDocument,
|
||||
PolicyOcrReviewInput,
|
||||
PolicyOcrConfirmInput,
|
||||
PolicyOcrConfirmResult,
|
||||
LookupsResponse,
|
||||
OpsJob,
|
||||
OpsJobKind,
|
||||
@@ -132,10 +154,30 @@ export function me(): Promise<AuthUser> {
|
||||
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 }> {
|
||||
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> {
|
||||
return apiFetch<CustomerStats>("/customers/stats");
|
||||
}
|
||||
@@ -484,6 +526,10 @@ export interface MovementQuery {
|
||||
typeId?: string;
|
||||
source?: string;
|
||||
customerId?: string;
|
||||
/** Restrict to captured-but-unpaid rows (the NOPAGO worklist). */
|
||||
outstanding?: boolean;
|
||||
/** Exact check number — the by-check reconciliation lookup. */
|
||||
checkNumber?: string;
|
||||
/** `YYYY-MM-DD`, inclusive on both ends. */
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -501,6 +547,8 @@ export function listMovements(q: MovementQuery): Promise<MovementListResponse> {
|
||||
if (q.typeId) params.set("typeId", q.typeId);
|
||||
if (q.source) params.set("source", q.source);
|
||||
if (q.customerId) params.set("customerId", q.customerId);
|
||||
if (q.outstanding !== undefined) params.set("outstanding", String(q.outstanding));
|
||||
if (q.checkNumber) params.set("checkNumber", q.checkNumber);
|
||||
if (q.from) params.set("from", q.from);
|
||||
if (q.to) params.set("to", q.to);
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
@@ -559,9 +607,45 @@ export function voidMovement(id: string): Promise<Transaction> {
|
||||
return apiFetch<Transaction>(`/billing/${id}/void`, { method: "POST" });
|
||||
}
|
||||
|
||||
/** Capture many customers' receipts against one check, in one transaction. The
|
||||
* returned `items` are positionally parallel to `input.lines`. */
|
||||
export function createMovementBatch(
|
||||
input: BatchCreateInput,
|
||||
): Promise<BatchCreateResponse> {
|
||||
return apiFetch<BatchCreateResponse>("/billing/batch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear an outstanding (NOPAGO) row: stamps the check number + resolution date
|
||||
* and starts counting it toward the balance. 400 if not outstanding or voided. */
|
||||
export function resolveOutstanding(
|
||||
id: string,
|
||||
input: ResolveOutstandingInput,
|
||||
): Promise<Transaction> {
|
||||
return apiFetch<Transaction>(`/billing/${id}/resolve-outstanding`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Everything captured against one check, with its reconciliation total. */
|
||||
export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
|
||||
return apiFetch<ByCheckResponse>(
|
||||
`/billing/by-check?checkNumber=${encodeURIComponent(checkNumber)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- 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 {
|
||||
bankAccountId: string;
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -574,7 +658,7 @@ export interface BankQuery {
|
||||
}
|
||||
|
||||
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.page) params.set("page", String(q.page));
|
||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||
@@ -583,20 +667,78 @@ export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
||||
if (q.from) params.set("from", q.from);
|
||||
if (q.to) params.set("to", q.to);
|
||||
if (q.sort) params.set("sort", q.sort);
|
||||
const qs = params.toString();
|
||||
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
|
||||
return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function getBankStats(): Promise<BankStats> {
|
||||
return apiFetch<BankStats>("/bank/stats");
|
||||
export function getBankStats(bankAccountId: string): Promise<BankStats> {
|
||||
return apiFetch<BankStats>(
|
||||
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBankFacets(): Promise<BankFacets> {
|
||||
return apiFetch<BankFacets>("/bank/facets");
|
||||
export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
|
||||
return apiFetch<BankFacets>(
|
||||
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
||||
export function getBankSummary(
|
||||
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
|
||||
@@ -667,38 +809,117 @@ export function listIngest(): Promise<IngestFile[]> {
|
||||
return apiFetch<IngestFile[]>("/ops/ingest");
|
||||
}
|
||||
|
||||
/** Live upload stats reported to `uploadFile`'s `onProgress` callback. */
|
||||
export type UploadProgress = {
|
||||
loaded: number;
|
||||
/** 0 when the browser can't compute the request size. */
|
||||
total: number;
|
||||
/** 0..1, or null when `total` is unknown. */
|
||||
fraction: number | null;
|
||||
/** Smoothed transfer rate. */
|
||||
bytesPerSecond: number;
|
||||
/** null until a rate and a total are both known. */
|
||||
secondsRemaining: number | null;
|
||||
/** True once the bytes are sent and we're waiting on the server's reply. */
|
||||
finishing: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path`
|
||||
* is API-relative (may include a query string); `filename` overrides the part
|
||||
* name sent to the server.
|
||||
* name sent to the server. Uses XHR rather than fetch because fetch has no way
|
||||
* to report request-body progress.
|
||||
*/
|
||||
export async function uploadFile(
|
||||
export function uploadFile(
|
||||
path: string,
|
||||
file: File,
|
||||
filename?: string,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
): Promise<unknown> {
|
||||
const body = new FormData();
|
||||
body.append("file", file, filename ?? file.name);
|
||||
const res = await fetch(`${API_ORIGIN}${path}`, {
|
||||
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 {
|
||||
/* ignore */
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${API_ORIGIN}${path}`);
|
||||
xhr.withCredentials = true;
|
||||
|
||||
if (onProgress) {
|
||||
// Exponentially smoothed rate — raw per-chunk deltas jump around too
|
||||
// much to read.
|
||||
let lastAt = performance.now();
|
||||
let lastLoaded = 0;
|
||||
let rate = 0;
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
const now = performance.now();
|
||||
const dt = (now - lastAt) / 1000;
|
||||
if (dt >= 0.15) {
|
||||
const sample = (e.loaded - lastLoaded) / dt;
|
||||
rate = rate === 0 ? sample : rate * 0.7 + sample * 0.3;
|
||||
lastAt = now;
|
||||
lastLoaded = e.loaded;
|
||||
}
|
||||
const total = e.lengthComputable ? e.total : 0;
|
||||
onProgress({
|
||||
loaded: e.loaded,
|
||||
total,
|
||||
fraction: total ? e.loaded / total : null,
|
||||
bytesPerSecond: rate,
|
||||
secondsRemaining:
|
||||
total && rate > 0 ? (total - e.loaded) / rate : null,
|
||||
finishing: false,
|
||||
});
|
||||
};
|
||||
// Bytes are out the door; the server still has to write the file.
|
||||
xhr.upload.onload = () => {
|
||||
onProgress({
|
||||
loaded: file.size,
|
||||
total: file.size,
|
||||
fraction: 1,
|
||||
bytesPerSecond: rate,
|
||||
secondsRemaining: 0,
|
||||
finishing: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return res.status === 204 ? undefined : res.json().catch(() => undefined);
|
||||
|
||||
xhr.onload = () => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : undefined;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(parsed);
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
(parsed as { message?: string } | undefined)?.message ??
|
||||
`Error ${xhr.status}`;
|
||||
reject(new ApiError(xhr.status, message));
|
||||
};
|
||||
xhr.onerror = () =>
|
||||
reject(new ApiError(0, "Fallo de red durante la carga."));
|
||||
xhr.onabort = () => reject(new ApiError(0, "Carga cancelada."));
|
||||
xhr.ontimeout = () => reject(new ApiError(0, "Tiempo de carga agotado."));
|
||||
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadIngest(name: string, file: File): Promise<unknown> {
|
||||
return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name);
|
||||
export function uploadIngest(
|
||||
name: string,
|
||||
file: File,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
): Promise<unknown> {
|
||||
return uploadFile(
|
||||
`/ops/ingest/${encodeURIComponent(name)}`,
|
||||
file,
|
||||
name,
|
||||
onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteIngest(name: string): Promise<unknown> {
|
||||
@@ -766,3 +987,192 @@ export function reportDownloadUrl(
|
||||
const tail = qs.toString();
|
||||
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`;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- Policy OCR (GMX) */
|
||||
|
||||
export function getPolicyOcrStatus(): Promise<{
|
||||
ocrAvailable: boolean;
|
||||
storageAvailable: boolean;
|
||||
}> {
|
||||
return apiFetch("/policy-ocr/status");
|
||||
}
|
||||
|
||||
export function listPolicyOcrBatches(
|
||||
page = 1,
|
||||
pageSize = 25,
|
||||
): Promise<{
|
||||
items: PolicyOcrBatch[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
pageCount: number;
|
||||
}> {
|
||||
return apiFetch(`/policy-ocr/batches?page=${page}&pageSize=${pageSize}`);
|
||||
}
|
||||
|
||||
export function getPolicyOcrBatch(id: string): Promise<PolicyOcrBatchDetail> {
|
||||
return apiFetch(`/policy-ocr/batches/${id}`);
|
||||
}
|
||||
|
||||
export function listPolicyOcrDocuments(batchId: string): Promise<PolicyOcrDocument[]> {
|
||||
return apiFetch(`/policy-ocr/batches/${batchId}/documents`);
|
||||
}
|
||||
|
||||
export async function uploadPolicyOcrBatch(
|
||||
files: File[],
|
||||
label?: string,
|
||||
): Promise<PolicyOcrBatch> {
|
||||
const body = new FormData();
|
||||
for (const f of files) body.append("files", f, f.name);
|
||||
const qs = new URLSearchParams();
|
||||
if (label) qs.set("label", label);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_ORIGIN}/policy-ocr/batches${qs.toString() ? `?${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 reviewPolicyOcrDocument(
|
||||
id: string,
|
||||
input: PolicyOcrReviewInput,
|
||||
): Promise<PolicyOcrDocument> {
|
||||
return apiFetch(`/policy-ocr/documents/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
export function rejectPolicyOcrDocument(id: string): Promise<PolicyOcrDocument> {
|
||||
return apiFetch(`/policy-ocr/documents/${id}/reject`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function confirmPolicyOcrBatch(
|
||||
batchId: string,
|
||||
input: PolicyOcrConfirmInput,
|
||||
): Promise<PolicyOcrConfirmResult> {
|
||||
return apiFetch(`/policy-ocr/batches/${batchId}/confirm`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* URL for the source PDF of a parsed policy document. The endpoint returns
|
||||
* the original upload (one PDF = one parsed policy), not a rendered page
|
||||
* image, so the review screen embeds it in an iframe.
|
||||
*/
|
||||
export function policyOcrDocumentUrl(documentId: string): string {
|
||||
return `${API_ORIGIN}/policy-ocr/documents/${documentId}/page`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const SERVICE_KIND_LABELS: Record<string, string> = {
|
||||
PROPERTY_TAX: "Predial",
|
||||
FEDERAL_ZONE: "Zona Federal",
|
||||
ALARM: "Alarma",
|
||||
TELEPHONE: "Teléfono",
|
||||
OTHER: "Otro",
|
||||
};
|
||||
|
||||
@@ -57,6 +58,7 @@ export const SERVICE_KIND_GLYPH: Record<string, string> = {
|
||||
WATER: "≈",
|
||||
ELECTRIC: "⚡",
|
||||
GAS: "◐",
|
||||
TELEPHONE: "☎",
|
||||
CABLE: "▤",
|
||||
PROPERTY_TAX: "⌂",
|
||||
FEDERAL_ZONE: "⇲",
|
||||
|
||||
+354
-4
@@ -12,6 +12,8 @@ export type Ability =
|
||||
| "policy:create"
|
||||
| "policy:update"
|
||||
| "policy:delete"
|
||||
| "policy:ingest"
|
||||
| "policy:ocr-review"
|
||||
| "property:create"
|
||||
| "property:update"
|
||||
| "property:delete"
|
||||
@@ -19,6 +21,9 @@ export type Ability =
|
||||
| "ledger:void"
|
||||
| "bank:create"
|
||||
| "bank:void"
|
||||
| "bank:manage-accounts"
|
||||
| "statement:ingest"
|
||||
| "statement:review"
|
||||
| "lookup:manage"
|
||||
| "user:manage"
|
||||
| "db:manage";
|
||||
@@ -29,6 +34,10 @@ export interface AuthUser {
|
||||
email: string;
|
||||
role: Role;
|
||||
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
|
||||
// reads this map, never re-derives the rules. Server still enforces.
|
||||
abilities: Record<Ability, boolean>;
|
||||
@@ -129,6 +138,7 @@ export type ServiceKind =
|
||||
| "PROPERTY_TAX"
|
||||
| "FEDERAL_ZONE"
|
||||
| "ALARM"
|
||||
| "TELEPHONE"
|
||||
| "OTHER"
|
||||
| string;
|
||||
|
||||
@@ -718,6 +728,9 @@ export interface Movement {
|
||||
type: TransactionType | null;
|
||||
/** App-voided (`voidedAt` set). UI strikes; totals exclude. */
|
||||
voided: boolean;
|
||||
/** Legacy "NOPAGO": captured but unpaid (no funds). Shown tagged, and kept
|
||||
* out of every balance until resolved via resolveOutstanding(). */
|
||||
outstanding?: boolean;
|
||||
}
|
||||
|
||||
/** Payload for POST /billing — a new ledger movement. Sign convention: negative
|
||||
@@ -733,6 +746,73 @@ export interface CreateMovementInput {
|
||||
reference?: string;
|
||||
checkNumber?: string;
|
||||
message?: string;
|
||||
/** Legacy NOPAGO — captured but unpaid; excluded from balances until resolved. */
|
||||
outstanding?: boolean;
|
||||
}
|
||||
|
||||
/** One customer's line inside a check batch; check-level fields sit on the parent. */
|
||||
export interface BatchLineInput {
|
||||
customerId: string;
|
||||
amount: number;
|
||||
reference?: string;
|
||||
period?: string;
|
||||
message?: string;
|
||||
outstanding?: boolean;
|
||||
}
|
||||
|
||||
/** Payload for POST /billing/batch — many receipts cut against one check. */
|
||||
export interface BatchCreateInput {
|
||||
domain: TransactionDomain;
|
||||
transactionDate: string;
|
||||
checkNumber: string;
|
||||
currency?: Currency;
|
||||
typeId?: string;
|
||||
lines: BatchLineInput[];
|
||||
}
|
||||
|
||||
export interface BatchCreateResponse {
|
||||
/** Positionally parallel to the submitted `lines`. */
|
||||
items: Transaction[];
|
||||
checkNumber: string;
|
||||
currency: LedgerCurrency;
|
||||
source: "MANUAL" | "BATCH" | "OCR";
|
||||
count: number;
|
||||
outstandingCount: number;
|
||||
/** Excludes outstanding lines — this is the figure to reconcile against the
|
||||
* physical check. */
|
||||
total: string;
|
||||
}
|
||||
|
||||
/** Payload for POST /billing/:id/resolve-outstanding. */
|
||||
export interface ResolveOutstandingInput {
|
||||
checkNumber: string;
|
||||
resolvedDate: string;
|
||||
}
|
||||
|
||||
export interface ByCheckItem {
|
||||
id: string;
|
||||
transactionDate: string | null;
|
||||
domain: TransactionDomain;
|
||||
amount: string;
|
||||
currency: LedgerCurrency;
|
||||
direction: LedgerDirection;
|
||||
reference: string | null;
|
||||
period: string | null;
|
||||
message: string | null;
|
||||
outstanding: boolean;
|
||||
type: TransactionType | null;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
customerNameSource: string | null;
|
||||
}
|
||||
|
||||
/** GET /billing/by-check — everything cut against one check, for reconciliation. */
|
||||
export interface ByCheckResponse {
|
||||
checkNumber: string;
|
||||
items: ByCheckItem[];
|
||||
count: number;
|
||||
outstandingCount: number;
|
||||
totals: { currency: LedgerCurrency; total: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface MovementListItem extends Movement {
|
||||
@@ -927,12 +1007,57 @@ export interface CustomerInput {
|
||||
/* ------------------------------------------------- Bank register (chequera) */
|
||||
|
||||
/**
|
||||
* The office's own checking account. Single-currency (MXN) and with no customer
|
||||
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and
|
||||
* exactly zero a cancelled cheque.
|
||||
* 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
|
||||
* 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";
|
||||
|
||||
/** 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 BankSort =
|
||||
@@ -964,8 +1089,10 @@ export interface BankListItem {
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
/** Which chequera it lands in. Required. */
|
||||
bankAccountId: string;
|
||||
amount: number;
|
||||
transactionDate: string;
|
||||
concept?: string;
|
||||
@@ -1082,3 +1209,226 @@ export interface ReportRunResult {
|
||||
export interface ReportCatalog {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
||||
|
||||
export type PolicyOcrBatchStatus =
|
||||
| "UPLOADED"
|
||||
| "PROCESSING"
|
||||
| "READY_FOR_REVIEW"
|
||||
| "COMPLETED"
|
||||
| "FAILED";
|
||||
|
||||
export type PolicyOcrDocumentStatus =
|
||||
| "PENDING_OCR"
|
||||
| "OCR_FAILED"
|
||||
| "NEEDS_REVIEW"
|
||||
| "MATCHED"
|
||||
| "CONFIRMED"
|
||||
| "POSTED"
|
||||
| "REJECTED";
|
||||
|
||||
export interface PolicyOcrBatch {
|
||||
id: string;
|
||||
provider: string;
|
||||
status: PolicyOcrBatchStatus;
|
||||
label: string | null;
|
||||
fileCount: number;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
uploadedBy?: { name: string };
|
||||
_count?: { documents: number };
|
||||
}
|
||||
|
||||
export interface PolicyOcrBatchDetail extends PolicyOcrBatch {
|
||||
byStatus: Partial<Record<PolicyOcrDocumentStatus, number>>;
|
||||
}
|
||||
|
||||
export interface PolicyOcrCoverage {
|
||||
risk: string;
|
||||
insuredAmount: number | null;
|
||||
deductible: string | null;
|
||||
lossParticipation: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyOcrMatchCandidate {
|
||||
policyId: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
policyNumber: string;
|
||||
}
|
||||
|
||||
export interface PolicyOcrDocument {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
status: PolicyOcrDocumentStatus;
|
||||
provider: string | null;
|
||||
ocrConfidence: string | null;
|
||||
extractedPolicyNumber: string | null;
|
||||
extractedInsuredName: string | null;
|
||||
extractedAdditionalInsured: string | null;
|
||||
extractedAgentName: string | null;
|
||||
extractedLegalAddress: string | null;
|
||||
extractedZip: string | null;
|
||||
extractedPolicyFrom: string | null;
|
||||
extractedPolicyTo: string | null;
|
||||
extractedPolicyDate: string | null;
|
||||
extractedCurrency: string | null;
|
||||
extractedNetPremium: string | null;
|
||||
extractedPolicyFee: string | null;
|
||||
extractedBrokerFee: string | null;
|
||||
extractedTotal: string | null;
|
||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||
extractedPremiumPayment: string | null;
|
||||
matchedPolicy: {
|
||||
id: string;
|
||||
policyNumber: string | null;
|
||||
customerId: string;
|
||||
customer: { name: string };
|
||||
} | null;
|
||||
matchedCustomer: { id: string; name: string } | null;
|
||||
matchCandidates: PolicyOcrMatchCandidate[] | null;
|
||||
matchNote: string | null;
|
||||
}
|
||||
|
||||
export interface PolicyOcrReviewInput {
|
||||
policyNumber?: string;
|
||||
insuredName?: string;
|
||||
additionalInsured?: string;
|
||||
agentName?: string;
|
||||
legalAddress?: string;
|
||||
zip?: string;
|
||||
policyFrom?: string;
|
||||
policyTo?: string;
|
||||
policyDate?: string;
|
||||
currency?: string;
|
||||
netPremium?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragesJson?: PolicyOcrCoverage[];
|
||||
matchedPolicyId?: string;
|
||||
matchedCustomerId?: string;
|
||||
forceConfirm?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyOcrConfirmDocument {
|
||||
documentId: string;
|
||||
policyId?: string;
|
||||
customerId?: string;
|
||||
policyNumber?: string;
|
||||
insuredName?: string;
|
||||
additionalInsured?: string;
|
||||
agentName?: string;
|
||||
legalAddress?: string;
|
||||
zip?: string;
|
||||
policyFrom?: string;
|
||||
policyTo?: string;
|
||||
policyDate?: string;
|
||||
currency?: string;
|
||||
netPremium?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
total?: number;
|
||||
premiumPayment?: string;
|
||||
coveragesJson?: PolicyOcrCoverage[];
|
||||
postPremium?: boolean;
|
||||
}
|
||||
|
||||
export interface PolicyOcrConfirmInput {
|
||||
documents: PolicyOcrConfirmDocument[];
|
||||
}
|
||||
|
||||
export interface PolicyOcrConfirmResult {
|
||||
applied: number;
|
||||
policies: string[];
|
||||
postedTransactions: number;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -27,6 +27,11 @@ version: "3.8"
|
||||
services:
|
||||
api:
|
||||
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:
|
||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||
@@ -36,6 +41,14 @@ services:
|
||||
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}
|
||||
# Object storage — internal endpoint the API (server-side) uses to reach
|
||||
# the minio stack. Not browser-facing (downloads proxy through the API).
|
||||
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
||||
@@ -68,6 +81,8 @@ services:
|
||||
|
||||
web:
|
||||
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
||||
labels:
|
||||
io.jorgecuadros.role: "web"
|
||||
environment:
|
||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+50
-2
@@ -8,7 +8,9 @@ RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
||||
|
||||
FROM base AS deps
|
||||
# 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 apps/api/package.json apps/api/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
@@ -32,7 +34,36 @@ ENV NODE_ENV=production
|
||||
# (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools)
|
||||
# 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.
|
||||
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).
|
||||
#
|
||||
# mdbtools-utils, NOT mdbtools. Alpine splits the project: `mdbtools` is the
|
||||
# shared library only, and the command-line tools migration/extract.py shells out
|
||||
# to (`mdb-tables`, `mdb-export`) are in the -utils subpackage. Installing the
|
||||
# wrong one builds fine and fails at run time — the re-import in the "Operaciones"
|
||||
# panel dies with:
|
||||
# RuntimeError: mdbtools not found on PATH (need mdb-tables and mdb-export)
|
||||
#
|
||||
# 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-utils mysql-client mariadb-connector-c openssl \
|
||||
tesseract-ocr tesseract-ocr-data-spa poppler-utils \
|
||||
&& apk add --no-cache --virtual .pybuild python3-dev build-base \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
@@ -40,6 +71,23 @@ COPY --from=build /repo/node_modules node_modules
|
||||
COPY --from=build /repo/packages/database packages/database
|
||||
COPY --from=build /repo/apps/api/dist apps/api/dist
|
||||
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).
|
||||
COPY migration migration
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# 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`, but only when the dumper supports it.** galactus is
|
||||
the replication *source* with GTID on, so on a MySQL client this flag is what
|
||||
keeps every dump from embedding `SET @@GLOBAL.GTID_PURGED` and becoming
|
||||
unrestorable onto the server it came from — which is precisely what the
|
||||
restore screen exists to do. The panel, however, dumps from *inside the API
|
||||
container*, where Alpine's `mysql-client` is MariaDB's: there `mysqldump` is a
|
||||
shim over `mariadb-dump`, the flag does not exist, and passing it failed every
|
||||
backup with `mysqldump: unknown variable 'set-gtid-purged=OFF'`. So the panel
|
||||
probes `mysqldump --help` and passes the flag only if it is advertised,
|
||||
invoking `mariadb-dump` directly otherwise (MariaDB writes no GTID state
|
||||
unless asked with `--gtid`, so there is nothing to suppress). The pre-migrate
|
||||
backup keeps the flag unconditionally — it runs in a real `mysql:8.4` image,
|
||||
not in the API container.
|
||||
- **`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.
|
||||
@@ -0,0 +1,786 @@
|
||||
# Insurance Features — Implementation Spec
|
||||
|
||||
Source: Jorge Cuadros meeting notes, 2026-07-25/26 (`Seguros` section), plus a
|
||||
read-through of the current `policies/`, `reports/`, `storage/` and `auth/`
|
||||
code and a live query of the dev database. This is a forward spec for work
|
||||
**not yet built** — contrast with [`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md),
|
||||
which documents the legacy renewal-report chain that has *already* been
|
||||
migrated into the `aviso-renovacion` report.
|
||||
|
||||
Companion doc: [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) covers the
|
||||
Utility Management half of the same meeting (PLAN.md step 11). This doc is the
|
||||
insurance half (PLAN.md step 12).
|
||||
|
||||
## Why these four features are one spec
|
||||
|
||||
The meeting produced four insurance asks. They are specified together because
|
||||
they share a spine — the `Policy` record and its expiry/settlement lifecycle:
|
||||
|
||||
1. **Renewal notification emails** — automates the *outbound* half of a
|
||||
policy's expiry (30 days before, 15 days before, 7 days after). The report
|
||||
that produces the letter text already exists; nothing sends it.
|
||||
2. **Liquidación batch workflow** — the *settlement* half of the same
|
||||
lifecycle. The per-policy fields are wired end to end; only the batch
|
||||
print-and-mark step is missing.
|
||||
3. **Certificate / "Solicitud Atlas"** — a customer-facing artifact rendered
|
||||
from the same policy record, delivered through the existing PHP portal.
|
||||
4. **Carrier API integration** — an *inbound* path that would populate the
|
||||
same `Policy` rows automatically instead of by hand.
|
||||
|
||||
1 and 2 are small additions on top of shipped code. 3 is half-buildable and
|
||||
half-blocked on infrastructure. 4 is fully blocked on vendor information.
|
||||
|
||||
**Two of the four are much smaller than they sound**, and the spec says so up
|
||||
front so nobody re-estimates them as greenfield work: §1 needs a scheduler, a
|
||||
mail client and one mutation — the notice table, its idempotency key, and the
|
||||
letter body all exist. §2 needs one report and one endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Ground truth (verified 2026-07-27, do not re-derive)
|
||||
|
||||
Everything below was checked against the code and the dev DB
|
||||
(`192.168.4.212:3307`), not inferred from the meeting notes.
|
||||
|
||||
### What exists
|
||||
|
||||
| Thing | Where | State |
|
||||
|---|---|---|
|
||||
| `Policy.liquidated` / `liquidationNumber` / `liquidationDate` | `schema.prisma:165-167` | wired end to end (DTOs, `?liquidated=` filter, stats, form checkbox, detail label) |
|
||||
| `RenewalNotice` model + `@@unique([policyId, generation])` | `schema.prisma:201-217` | **0 rows** — never written by anything |
|
||||
| `aviso-renovacion` letter report | `reports.registry.ts:623-799` | shipped; read-only. Its `enviadas`/`pendientes` totals are permanently 0 because nothing writes `RenewalNotice` |
|
||||
| Letter render + PDF/CSV/XLSX/print outputs | `reports.types.ts:32`, `outputs.ts`, `ReportRunner.tsx:502` (`LetterLayout`) | shipped, reusable as-is |
|
||||
| S3-style optional-client service pattern | `storage.service.ts:28-57` | the pattern the mail client should copy |
|
||||
| Single-running-job guard | `ops.service.ts:171-176` | the pattern the cron sweep should copy |
|
||||
| Ability matrix (17 abilities) | `auth/abilities.ts` | single source of truth; web consumes the server-resolved map |
|
||||
|
||||
### What does not exist
|
||||
|
||||
- **No scheduler.** No `@nestjs/schedule`, bull/bullmq, node-cron or
|
||||
`setInterval` in `apps/api`. `ops/` spawns detached child processes on user
|
||||
request only.
|
||||
- **No mail code or dependency.** Nothing in any `package.json`, `.env.example`
|
||||
or `docker-compose.yml`.
|
||||
- **`EmailTemplate` / `EmailCampaign` / `EmailLog`** (`schema.prisma:540-571`)
|
||||
are dead migrated legacy tables — no FKs, no code touches them. **Leave them
|
||||
alone**; `RenewalNotice` is the send log.
|
||||
- `express-session` uses the in-memory default store (`main.ts:28-39`), so
|
||||
sessions die on API restart. Relevant to any customer-identity idea in §3.
|
||||
- Reports are gated by `AuthenticatedGuard` alone (`reports.controller.ts:28`)
|
||||
— any logged-in user, including VIEWER, can run any report. Adding a
|
||||
*mutation* to the reports area (§2) means it cannot live on that controller.
|
||||
|
||||
### Live data shape
|
||||
|
||||
| Measure | Value |
|
||||
|---|---|
|
||||
| Customers | 1,536 — **1,304 (85%) have a non-blank email** |
|
||||
| Customers holding ≥1 policy | 893 — **815 (91%) have an email** |
|
||||
| Policies | 2,396 (0 archived); 1,865 have `policyTo` |
|
||||
| Policies expiring in the next 12 months | 1,045 (≈87/month) |
|
||||
| Liquidated | 2,170; **pending 226** |
|
||||
| `liquidationNumber` / `liquidationDate` populated | 2,245 / 2,239 |
|
||||
| Installments | 4,724 — 1,849 with `paidDate`, 1,651 with `checkNumber` |
|
||||
| `renewal_notices` rows | 0 |
|
||||
|
||||
Email volume for §1 sizing: ≈87 policies/month × 3 notices ≈ **260
|
||||
emails/month**, and 91% of policyholders are reachable. This is an email
|
||||
channel, not a print-fallback channel — but see §1's open question on the
|
||||
remaining 9%.
|
||||
|
||||
### Two meeting terms have no referent in the data
|
||||
|
||||
Do not guess at these. Negative greps re-run 2026-07-27 across `docs/`,
|
||||
`migration/` and `apps/`. (A third — "GDMX" — turned out to be a typo for
|
||||
`GMX`, confirmed with the user; see §4.)
|
||||
|
||||
- **"Solicitud"** — 0 hits. Not a legacy report, form or table. ("Atlas" is a
|
||||
carrier — `COMP = "ATLAS, S.A."` — not a report; see
|
||||
[`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md).) The closest legacy artifact to a
|
||||
certificate is the `* MENS`/`*MENSAJE` blob letter templates, one per line of
|
||||
business, deliberately excluded from migration
|
||||
(`LEGACY_DATABASES.md` → excluded tables).
|
||||
- **"Garantías"** — 0 hits for `garant`/`warranty`. No table, no column.
|
||||
|
||||
For reference, both carriers named in the meeting *do* appear in the data:
|
||||
`GMX` in `mult.comp`, `m_empr.comp` and `gen1.comp`, and `ANA SEGUROS`
|
||||
verbatim (with an inconsistent `ANA` variant in `licencias.comp`) — which
|
||||
matches the ANA-autos / GMX-daños split described in §4.
|
||||
|
||||
The only hit for `transferencia` anywhere is a bank-register UI label
|
||||
(`apps/web/src/app/banco/page.tsx:948`, a SCOTHIA movement type) — unrelated to
|
||||
policy settlement. "Número de transferencia" is therefore a **new** requirement
|
||||
mapping onto the existing `liquidationNumber` field, not a missed migration.
|
||||
|
||||
### Two defects found while verifying this spec
|
||||
|
||||
Both are pre-existing, both affect the features below, and both should be fixed
|
||||
as part of §2 rather than filed separately.
|
||||
|
||||
**(a) `INCENDIO` and `M_EMPR` have no `policy_types` row, and 5 policies lost
|
||||
their ramo.** `policy_types` currently holds only `AUTO`, `LICENCIAS`, `MULT`.
|
||||
`transform_policies.py:111-115` configures `INCENDIO` and `M_EMPR` too, so the
|
||||
migration creates all five — but `policies_policyTypeId_fkey` is **`ON DELETE
|
||||
SET NULL`**, so deleting an (apparently unused) lookup row silently blanked the
|
||||
ramo on every policy pointing at it. The 5 `m_empr` policies now have
|
||||
`policyTypeId = NULL`:
|
||||
|
||||
```
|
||||
3249481 / 3249872 vence 2014-03-26 pendiente
|
||||
3673 / 1200003673 vence 2013-03-30 pendiente
|
||||
7000017 sin vigencia liquidada
|
||||
```
|
||||
|
||||
Consequence: every ramo-parameterized query filters on
|
||||
`policyType: { name: … }` (`reports.registry.ts:703-707`), so these 5 are invisible
|
||||
to `aviso-renovacion` *and* would be invisible to §2's pending-liquidación
|
||||
report — including 4 that are genuinely pending. `INCENDIO` is a different
|
||||
story: the legacy `INCENDIO` table has exactly **1 row**, and it did not
|
||||
migrate (customer unresolved), so the ramo is legitimately empty — but the
|
||||
`aviso-renovacion` "Incendio" dropdown option still promises a report that can
|
||||
only ever return zero rows.
|
||||
|
||||
Fix as part of §2: re-seed the two missing `policy_types` rows, re-point the 5
|
||||
orphans, and change the FK to `ON DELETE RESTRICT` so a lookup delete fails
|
||||
loudly instead of silently blanking data.
|
||||
|
||||
**(b) The legacy settlement slots do not match the plan's assumption.**
|
||||
`MULT` and `INCENDIO` carry **two** slots (`LIQUIDADA`/`LIQUIDADA 2`,
|
||||
`NUM LIQUIDACION`/`NUM LIQUIDACION2`, `F LIQUIDA1`/`F LIQUIDA2`) — but
|
||||
`M EMPR` carries **four** (`liquidada` … `liquidada_4`,
|
||||
`num_liquidacion` … `num_liquidacion4`, `f_liquida1` … `f_liquida4`).
|
||||
Actual usage in the staged data:
|
||||
|
||||
| Table | rows | slot 2 number | slot 2 date | slots 3-4 |
|
||||
|---|---|---|---|---|
|
||||
| `mult` | 773 | 41 (5.3%) | 39 | n/a |
|
||||
| `m_empr` | 5 | 0 | 0 | 0 |
|
||||
| `incendio` | 1 | 0 | 0 | n/a |
|
||||
|
||||
So the second slot was used on ~5% of MULT policies and never anywhere else,
|
||||
and slots 3–4 were never used at all. `Policy` collapses this to one set, which
|
||||
means **≤41 rows lost a second settlement record** in migration. Design
|
||||
decision in §2.
|
||||
|
||||
### Utilities ↔ Seguros reconciliation — resolved, not open
|
||||
|
||||
The plan carried this as "two competing sources." It is not competitive; one of
|
||||
them is unusable.
|
||||
|
||||
- **`SEGUROS 16_be.mdb: DATGRAL.[NUM UTIL]`** — 563 complete
|
||||
`(num_id, num_util)` pairs. Validated by comparing the insurance customer's
|
||||
own `NOMBRE` against the utilities customer it points at: **298/563 (53%)
|
||||
match exactly**, the remainder being ordinary name variants (spouses,
|
||||
married names, entity vs. person). This is a real link, and it is the key
|
||||
`transform_customers.py` already uses.
|
||||
- **`UTILSEG`** (1,582 rows) — 379 rows carry both a `seguros` and a `util`
|
||||
number. Under the obvious reading (`seguros` → seguros `DATGRAL.num_id`,
|
||||
`util` → utilities `DATGRAL.num_id`) the row's own `NOMBRE` matches the
|
||||
target master's name **58/1,024** and **70/932** of the time respectively —
|
||||
i.e. essentially never. Spot-checking makes it plain:
|
||||
|
||||
```
|
||||
UTILSEG 'STEWART, KENNETH' seguros=220 → 'ZEPEDA, JAIME RAUL' util=441 → 'MENDOZA, SERGIO'
|
||||
UTILSEG 'HANCOCK, STEVENS' seguros=225 → 'RODRIGUEZ, MIKE' util=403 → 'JOW, LILY/EVANS, LARRY'
|
||||
UTILSEG 'HUDSON, RICHARD L.' seguros=227 → 'WELLES, ROBERT' util=218 → 'ARTER, KAREN'
|
||||
```
|
||||
|
||||
And where the two sources overlap they contradict each other: of 218
|
||||
`seguros` ids present in both, **170 (78%) point at a different utilities
|
||||
customer**; only 48 pairs agree outright.
|
||||
|
||||
**Rule: `DATGRAL.[NUM UTIL]` is authoritative. `UTILSEG` is a stale artifact of
|
||||
an older numbering and must not be used to reconcile customers.** This matters
|
||||
directly to [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §4
|
||||
(customer-number recycling), which touches the same identity space — a
|
||||
recycling backfill that consulted `UTILSEG` would merge unrelated people.
|
||||
|
||||
---
|
||||
|
||||
## 1. Renewal notification emails
|
||||
|
||||
### What Jorge asked for
|
||||
|
||||
Automatic notice to the customer at **30 days before expiry, 15 days before,
|
||||
and 7 days after** — replacing the manual monthly run of the legacy
|
||||
`RENEW`/`RENEW2`/`RENEW3` report batch.
|
||||
|
||||
### What's already built (do not re-build)
|
||||
|
||||
- The letter itself: `aviso-renovacion` (`reports.registry.ts:623-799`) already
|
||||
resolves customer, carrier, `policyTo`, premium, vehicle and the ramo-specific
|
||||
`coveragesJson` keys (`cov.cobertura`, `cov.csl_limite`, `cov.gastos_medico`,
|
||||
`cov.propiedades`, `cov.personas`, `cov.servicio_adicional`) into a
|
||||
`__kind: "letter"` row. **Do not fork this copy** — one letter definition,
|
||||
two render targets.
|
||||
- The send log: `RenewalNotice`, with `@@unique([policyId, generation])`
|
||||
(`schema.prisma:216`) — **this is the idempotency mechanism and it is already
|
||||
in place.** A sweep that upserts on that key cannot double-send, even on
|
||||
re-run, redeploy or double-fire. No new dedup design is needed.
|
||||
- The cadence maps onto the existing `generation Int` with **no schema
|
||||
change**: 30d-before = 1, 15d-before = 2, 7d-after = 3 — exactly the legacy
|
||||
1st/2nd/3rd notice model.
|
||||
|
||||
### 1.1 The scheduler
|
||||
|
||||
Add `@nestjs/schedule`. One `@Cron` job, daily, early morning local time.
|
||||
|
||||
```
|
||||
@Cron("0 6 * * *", { timeZone: "America/Tijuana" })
|
||||
async sweepRenewals()
|
||||
```
|
||||
|
||||
Guard multi-replica double-fire the same way `ops.service.ts:171-176` guards
|
||||
concurrent jobs — a DB row, not an in-process flag. Reuse `OpsJob` with a new
|
||||
kind, or add a minimal `ScheduledRun` row; either way the guard must be a
|
||||
database write, because the API is deployed as a Swarm service and may run more
|
||||
than one replica.
|
||||
|
||||
The sweep must also be **manually runnable** (an admin endpoint that invokes the
|
||||
same service method), so a missed day can be caught up without waiting 24h and
|
||||
so the job is testable without clock manipulation.
|
||||
|
||||
### 1.2 The sweep query
|
||||
|
||||
For each of the three offsets, select non-archived policies whose `policyTo`
|
||||
falls on the target date:
|
||||
|
||||
| Generation | Target date | Meaning |
|
||||
|---|---|---|
|
||||
| 1 | `today + 30d` | primer aviso |
|
||||
| 2 | `today + 15d` | segundo aviso |
|
||||
| 3 | `today - 7d` | tercer aviso (vencida) |
|
||||
|
||||
`archivedAt: null`, `policyTo` non-null. **Date comparison must be on the UTC
|
||||
date, not the timestamp** — `policyTo` is stored midnight-UTC (see the existing
|
||||
report's `Date.UTC(year, month - 1, 1)` bounds at `reports.registry.ts:700`),
|
||||
and a naive local-time comparison shifts the whole sweep by a day for
|
||||
`America/Tijuana`.
|
||||
|
||||
For each hit: render the letter, send, then upsert `RenewalNotice` on
|
||||
`[policyId, generation]` with `sentAt`, `channel: EMAIL`, and the provider
|
||||
message id. **Upsert after a successful send, not before** — a failed send must
|
||||
leave the row absent so the next day's sweep retries it. A row that already has
|
||||
`sentAt` is skipped.
|
||||
|
||||
Catch-up behaviour: because the query is date-*equality*, a day the job doesn't
|
||||
run is a day of notices silently skipped. Either make the sweep look at a
|
||||
window (`policyTo` between the target date and the last successful run's target
|
||||
date) or record the last successful sweep date and re-run the gap. **Recommend
|
||||
the window** — it needs no extra state beyond a `lastSweptAt` and it degrades
|
||||
correctly if the API is down for a week.
|
||||
|
||||
### 1.3 The mail client
|
||||
|
||||
`MailProvider` interface:
|
||||
|
||||
```ts
|
||||
send(msg: { to: string; subject: string; html: string; attachments?: … })
|
||||
=> Promise<{ providerId: string }>
|
||||
```
|
||||
|
||||
**Amazon SES is the first and intended implementation** — the user already runs
|
||||
SES for mass notification, so this reuses an established sending reputation
|
||||
rather than warming a new channel. Provider choice and budget are **settled,
|
||||
not open questions**; ≈260 emails/month is negligible against existing usage.
|
||||
|
||||
Implement it with `@aws-sdk/client-sesv2`, mirroring `StorageService`
|
||||
(`storage.service.ts:28-57`) exactly:
|
||||
|
||||
- env-driven config (`SES_REGION`, `SES_FROM`, `SES_ACCESS_KEY`,
|
||||
`SES_SECRET_KEY`, optional `SES_CONFIGURATION_SET`), added to `.env.example`;
|
||||
- **null client when unconfigured, `ServiceUnavailableException` on use** — an
|
||||
unconfigured mail setup must never crash API boot, same degradation as
|
||||
document storage today;
|
||||
- a no-op/log implementation for dev, selected when SES env vars are absent.
|
||||
|
||||
The interface stays swappable for testability, not for vendor escape.
|
||||
|
||||
Persist the SES message id — add `providerMessageId String?` to `RenewalNotice`
|
||||
rather than overloading `notes`, so a bounce or complaint notification can be
|
||||
traced back to the notice that caused it. (`notes` stays free-text for staff.)
|
||||
|
||||
### 1.4 Manual mark-as-sent
|
||||
|
||||
The `aviso-renovacion` doc comment (`reports.registry.ts:617-621`) already
|
||||
anticipates this: staff who *mail* a paper notice need to record it.
|
||||
`RenewalNoticeChannel` (`MAIL` | `EMAIL`) exists for exactly this distinction.
|
||||
|
||||
`POST /policies/:id/renewal-notices` — body `{ generation, channel, sentAt?,
|
||||
notes? }`, upserting on the same unique key. This closes the loop that makes
|
||||
the report's `enviadas`/`pendientes` totals meaningful for the first time.
|
||||
|
||||
### 1.5 Bounces and unsubscribes
|
||||
|
||||
Not in the meeting notes, but sending 260 mails/month to a 1,304-address list
|
||||
built from decades-old Access data will produce bounces. Minimum viable:
|
||||
record `providerMessageId`, and add a `Customer.emailOptOut Boolean @default(false)`
|
||||
checked by the sweep. Full SNS bounce-webhook handling is out of scope for the
|
||||
first build — but the opt-out flag is not, because there is no other way for a
|
||||
customer to stop the mail.
|
||||
|
||||
### API surface
|
||||
|
||||
| Method | Route | Ability |
|
||||
|---|---|---|
|
||||
| `POST` | `/policies/:id/renewal-notices` | `renewal:send` |
|
||||
| `POST` | `/renewals/sweep` (manual trigger of the cron body) | `renewal:send` |
|
||||
| `GET` | `/renewals/pending?days=` (what the next sweep would send) | read (AuthenticatedGuard) |
|
||||
|
||||
### Abilities (new)
|
||||
|
||||
| Ability | Min role | Notes |
|
||||
|---|---|---|
|
||||
| `renewal:send` | MANAGER | sends mail to customers on the office's behalf — a higher trust tier than ordinary data entry |
|
||||
|
||||
Add to both the `Ability` union and `ABILITY_MIN` in `auth/abilities.ts` — that
|
||||
file is the single source of truth; `apps/web/src/lib/abilities.ts` only
|
||||
consumes the server-resolved map.
|
||||
|
||||
### Open questions
|
||||
|
||||
- Which SES region + verified identity/configuration set this sends under, and
|
||||
whether it reuses existing IAM credentials or gets its own scoped
|
||||
`ses:SendEmail` user.
|
||||
- The 9% of policyholders with no email (78 of 893) — silently skipped, or
|
||||
surfaced as a "print these" worklist? Recommend the worklist: the existing
|
||||
`aviso-renovacion` report already produces exactly those letters, so it costs
|
||||
one filter parameter.
|
||||
- Spanish or English body? The legacy letters were Spanish; the customer base
|
||||
is substantially US-resident. `Customer` has no language preference field.
|
||||
|
||||
---
|
||||
|
||||
## 2. Liquidación batch workflow
|
||||
|
||||
### What Jorge asked for
|
||||
|
||||
Print the pending set, then mark many policies settled at once with one
|
||||
transfer number — "liquidación de pólizas MULT", garantías excluded.
|
||||
|
||||
### What's already built (do not re-build)
|
||||
|
||||
`liquidated` / `liquidationNumber` / `liquidationDate` are wired end to end:
|
||||
`schema.prisma:165-167`, create+update DTOs (`policy.dto.ts:35-37,59-61`),
|
||||
the `?liquidated=` list filter (`policies.service.ts:148`), liquidada/pendiente
|
||||
counts in `stats()` (`:219,:237`), `headerData()` pass-through (`:316`), the
|
||||
"Liquidada" checkbox in `PolicyForm.tsx:250`, and the detail-page label
|
||||
(`polizas/[id]/page.tsx:323`).
|
||||
|
||||
**Only the batch layer is missing.** 2,170 of 2,396 policies are already
|
||||
marked liquidated from migration; the live pending set is 226.
|
||||
|
||||
### 2.1 Pending-liquidación report
|
||||
|
||||
New entry in `reports.registry.ts`, `format: "tabular"` — gets print/PDF/CSV/XLSX
|
||||
free via the existing `/reportes/:slug` machinery.
|
||||
|
||||
Parameterized **by ramo**, mirroring how `vigente` and `aviso-renovacion` already
|
||||
take a `policyType` select param. The workflow is *not* MULT-only: the legacy
|
||||
`TABLA LIQUIDA MF` scratch table served `MULT`, `INCENDIO` **and** `M EMPR`
|
||||
(`LEGACY_DATABASES_OBJECTS.md:4887-5017`).
|
||||
|
||||
Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`.
|
||||
Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago.
|
||||
Totals: count + prima neta sum per currency (**never collapse MXN and USD** —
|
||||
same constraint as the billing module).
|
||||
|
||||
⚠️ Fix defect (a) above before building this, or the report inherits the same
|
||||
blind spot: 4 of the 226 pending policies carry `policyTypeId = NULL` and would
|
||||
be missing from every ramo-filtered run *and* from the "todos" run if that is
|
||||
implemented as a union over known types rather than as "no filter."
|
||||
|
||||
### 2.2 Batch settle endpoint
|
||||
|
||||
`POST /policies/liquidate-batch` — body:
|
||||
|
||||
```
|
||||
{ policyIds: string[], liquidationNumber: string, liquidationDate: string }
|
||||
```
|
||||
|
||||
One `prisma.$transaction`. Rejects ids that are already `liquidated` (return
|
||||
them in the response rather than silently skipping, so the UI can say which).
|
||||
Writes an `ActivityLog` row per policy — this is a financial settlement marker
|
||||
being set across many records at once, and it is the one place in the app where
|
||||
a single click changes dozens of rows.
|
||||
|
||||
**Ability: new `policy:liquidate` at MANAGER**, not the existing `policy:update`
|
||||
(STAFF). Reason: a STAFF user editing one policy's checkbox is data entry; a
|
||||
STAFF user settling 200 policies against one transfer number is a financial
|
||||
control. Recommend the new ability; note it as a question for Jorge only if he
|
||||
wants STAFF to keep doing it.
|
||||
|
||||
### 2.3 Un-settle path
|
||||
|
||||
The legacy had one (`MULT FAM X POLIZA Consulta`,
|
||||
`LEGACY_DATABASES_OBJECTS.md:5570-5573`). `POST /policies/liquidate-batch/undo`
|
||||
with the same shape, or `{ liquidationNumber }` to reverse a whole batch.
|
||||
Gated at MANAGER via the same `policy:liquidate`. Also logs.
|
||||
|
||||
### 2.4 The two-slot decision (defect (b))
|
||||
|
||||
`Policy` has one settlement slot; `MULT`/`INCENDIO` had two and `M EMPR` had
|
||||
four, with real usage on ≤41 MULT rows and nowhere else.
|
||||
|
||||
**Recommendation: move settlement onto `PolicyPaymentInstallment`, do not add a
|
||||
second slot to `Policy`.** Reasons:
|
||||
|
||||
- `PolicyPaymentInstallment` already exists, already has `paidDate` and
|
||||
`checkNumber`, and already models "the *n*-th payment of this policy" — which
|
||||
is exactly what the second settlement slot meant. 4,724 rows, 1,849 with a
|
||||
paid date.
|
||||
- Adding `liquidated2`/`liquidationNumber2`/`liquidationDate2` reproduces the
|
||||
legacy's hardcoded-repeated-columns mistake that this whole migration exists
|
||||
to undo — and `M EMPR` proves it doesn't stop at two.
|
||||
- The `Policy`-level fields stay as the *rollup* ("this policy is fully
|
||||
settled"), which is what the existing UI and `?liquidated=` filter already
|
||||
mean. No breaking change.
|
||||
|
||||
Concretely: add `liquidationNumber String?` + `liquidatedAt DateTime?` to
|
||||
`PolicyPaymentInstallment`; batch-settle writes the installment rows and sets
|
||||
`Policy.liquidated = true` when all installments are settled. Backfill the ≤41
|
||||
lost slot-2 values from `mult.num_liquidacion2` / `f_liquida2` in
|
||||
`transform_policies.py` at the same time.
|
||||
|
||||
If Jorge wants the simpler thing instead, say so explicitly and accept that
|
||||
those 41 second settlements stay unmigrated.
|
||||
|
||||
### 2.5 "Garantías excluded"
|
||||
|
||||
Blocked — the term has no referent anywhere in the data (0 hits). Do not guess
|
||||
at a filter. Spec'd as: the batch report takes an explicit exclusion list or a
|
||||
flag once Jorge identifies what a "garantía" is in his data. Most likely
|
||||
candidates to ask about: a `forma_pago` value, an aseguradora, or a
|
||||
`coveragesJson` key.
|
||||
|
||||
### API surface
|
||||
|
||||
| Method | Route | Ability |
|
||||
|---|---|---|
|
||||
| `GET` | `/reports/liquidacion-pendiente?policyType=&provider=` | read |
|
||||
| `POST` | `/policies/liquidate-batch` | `policy:liquidate` |
|
||||
| `POST` | `/policies/liquidate-batch/undo` | `policy:liquidate` |
|
||||
|
||||
Note the mutation lives on `PoliciesController`, **not** `ReportsController` —
|
||||
that controller is deliberately read-only and guarded by `AuthenticatedGuard`
|
||||
alone (`reports.controller.ts:28`), so any logged-in VIEWER reaches it.
|
||||
|
||||
### Web
|
||||
|
||||
Extend `/polizas` with a "Liquidación" tab: the pending list with checkboxes, a
|
||||
select-all-filtered action, and one dialog collecting número de transferencia +
|
||||
fecha. Print goes through the existing `/reportes/liquidacion-pendiente` runner
|
||||
rather than a bespoke print view.
|
||||
|
||||
### Abilities (new)
|
||||
|
||||
| Ability | Min role | Notes |
|
||||
|---|---|---|
|
||||
| `policy:liquidate` | MANAGER | batch settlement across many rows; distinct from `policy:update` (STAFF) |
|
||||
|
||||
### Open questions
|
||||
|
||||
- What "garantías" refers to (blocks the exclusion filter).
|
||||
- Two-slot settlement: installment-level (recommended) or a second `Policy` slot.
|
||||
- Should `policy:liquidate` be a new MANAGER ability, or is reusing
|
||||
`policy:update` (STAFF) what the office actually wants?
|
||||
|
||||
---
|
||||
|
||||
## 3. Certificate / "Solicitud Atlas" + portal delivery
|
||||
|
||||
### What Jorge asked for
|
||||
|
||||
A "Solicitud Atlas" / insurance certificate, visible to customers on the
|
||||
website.
|
||||
|
||||
### The blocked half
|
||||
|
||||
**"Solicitud" has no referent** — 0 hits across 212 SEGUROS reports and 96
|
||||
UTILITIES reports; "Atlas" is a carrier, not a report. A *solicitud* is
|
||||
normally an **application form** (pre-policy, filled in by the applicant),
|
||||
which is a materially different artifact from a **certificate** (post-policy,
|
||||
proof of coverage issued to the insured). These need different data, different
|
||||
timing and different delivery.
|
||||
|
||||
Do not build until Jorge confirms which one he means. The spec below covers the
|
||||
**certificate** reading, because that is what "visible to customers on the
|
||||
website" implies.
|
||||
|
||||
### The buildable half — certificate rendering
|
||||
|
||||
Reuse the letter machinery, exactly as `aviso-renovacion` does:
|
||||
|
||||
- `format: "letter"` report (`reports.types.ts:32`), rendered by `LetterLayout`
|
||||
(`ReportRunner.tsx:502`) on screen and by `outputs.ts` `renderPdf` for the
|
||||
file.
|
||||
- Data needed, all already on `Policy` and its relations: customer name +
|
||||
address, policy number, carrier, `policyFrom`/`policyTo`, and the
|
||||
ramo-specific coverage keys already mapped in
|
||||
[`RENEWAL_NOTICES.md`](RENEWAL_NOTICES.md) — plus `vehicles[0]` for auto and
|
||||
the property address for MULT/INCENDIO/M_EMPR.
|
||||
- Parameter is a single policy, not a month — `/reports/certificado?policyId=`.
|
||||
Staff-facing route: a "Certificado" button on `/polizas/[id]`.
|
||||
|
||||
### The infrastructure half — portal delivery
|
||||
|
||||
[`PLAN.md:16,20-24`](../PLAN.md) locks the customer portal
|
||||
(`my-jorgecuadros-web`, PHP/`mysqli`, its own `utility_dbo` DB) as **out of
|
||||
scope and unchanged**. This repo has no public route and no `CUSTOMER` role
|
||||
(`UserRole` = ADMIN/MANAGER/STAFF/VIEWER, `schema.prisma:43-48`), and its
|
||||
sessions are in-memory. Insurance therefore reaches customers as an **extension
|
||||
of the already-planned replication** (PLAN.md steps 8/9), not as a new public
|
||||
surface here.
|
||||
|
||||
What this spec adds to that design, to be finalized when step 8 runs:
|
||||
|
||||
- **Which policy fields join the replicated set** — recommend the certificate's
|
||||
own field list and nothing more (policy number, carrier, ramo, vigencia,
|
||||
customer link), explicitly excluding premiums, commissions, liquidation
|
||||
status, `observations` and `notes`. The replicated side is the
|
||||
internet-exposed one; it should never carry the office's margin data.
|
||||
- **Certificate as generated PDF, not portal-side rendering.** Render here,
|
||||
upload to the existing S3/MinIO bucket via `StorageService`, replicate the
|
||||
pointer. The portal is PHP and is not being modified; giving it a URL is
|
||||
cheaper than giving it a template. This also means the certificate the
|
||||
customer sees is byte-identical to the one staff printed.
|
||||
- Where in `utility_dbo` the pointer lands — depends on the portal's existing
|
||||
policy-facing views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`,
|
||||
`usa_liability`), and needs a read of the portal's PHP before it can be
|
||||
stated.
|
||||
|
||||
### Abilities
|
||||
|
||||
None new. Certificate generation is a read; delivery is a replication concern.
|
||||
|
||||
### Open questions
|
||||
|
||||
- **What "Solicitud Atlas" actually is** — application form or certificate.
|
||||
Blocks the whole section.
|
||||
- If it's an application form: who fills it in (staff on the customer's behalf,
|
||||
or the customer on the portal), and does it need to exist as a record before
|
||||
a `Policy` does? That would be a new model, not a report.
|
||||
- Does the certificate need a carrier logo/letterhead? The legacy `* MENS`
|
||||
templates were per-carrier blobs; `outputs.ts` `renderPdf` has no image
|
||||
support today.
|
||||
|
||||
---
|
||||
|
||||
## 4. Carrier API integration
|
||||
|
||||
### What Jorge asked for
|
||||
|
||||
Integration with **ANA Seguros** and **GMX**. ("GDMX" in the meeting notes was
|
||||
a typo — confirmed with the user 2026-07-27. The data's `GMX` is correct, and
|
||||
this is no longer an open question.)
|
||||
|
||||
### Carrier research (2026-07-27) — what actually exists
|
||||
|
||||
**The two carriers are one company.** ANA and GMX are both members of **Grupo
|
||||
Valore**, alongside Seguros Argos (vida) and Prevem Seguros (gastos médicos).
|
||||
ANA writes **autos**; GMX writes **daños** — which maps exactly onto the split
|
||||
in this database: ANA covers the `AUTO`/`LICENCIAS` book, GMX covers
|
||||
`MULT`/`INCENDIO`/`M_EMPR`. Practical consequence: **this is one commercial
|
||||
conversation, not two.** The group also shares infrastructure — GMX's own
|
||||
quoting micrositio is served from ANA's host
|
||||
(`server.anaseguros.com.mx/Micrositios/GRUPOVALOREGMXCOR/`), so one technical
|
||||
contact plausibly covers both.
|
||||
|
||||
**ANA has a real, live web service.** `https://server.anaseguros.com.mx/ananetws/service.asmx`
|
||||
— a classic ASP.NET `.asmx` endpoint speaking SOAP 1.1 and 1.2, with its
|
||||
operation list published on the standard help page:
|
||||
|
||||
| Purpose | Operations |
|
||||
|---|---|
|
||||
| Catálogos | `Marca`, `SubMarca`, `Modelo`, `MarcaMoto`, `SubMarcaMoto`, `Color`, `Categoria`, `CatVeh`, `CodigoPostal`, `Colonia`, `ColxCP`, `DelMun`, `EDOS`, `Bancos`, `FormaPago`, `TipoPersona`, `TipoIndem`, `RegimenFiscal`, `Nacionalidad`, `Ocupacion`, `Identificacion`, `GiroEmpresa`, `PropositoMotos`, `Vigencia` |
|
||||
| Cotización | `CalculaValor`, `CalculaMSI` |
|
||||
| Vehículo | `Vehiculo`, `VehiculoMoto`, `ValidaSerie` |
|
||||
| Recuperación / validación | `RecuperaCotizacion`, `ValidaAsegurado` |
|
||||
| Transacción | `Transaccion` |
|
||||
|
||||
**GMX publishes no machine interface.** Its agent area
|
||||
(`gmx.com.mx/soy-agente/herramientas/`) lists only human portals — reporte de
|
||||
agentes, cobranzas, envío/descarga de facturas, documentos emitidos, reporte de
|
||||
siniestros, artículo 492. No API, no WSDL, no developer contact. The only
|
||||
number published is **(55) 5480-4000**.
|
||||
|
||||
Neither carrier has a public developer portal or published documentation.
|
||||
Across this market, web service credentials are granted **by the carrier, at
|
||||
its discretion, to appointed agents on written request** — expect a lead time
|
||||
measured in weeks, not a signup form.
|
||||
|
||||
### ⚠️ The critical mismatch — read before estimating this
|
||||
|
||||
**The ANA service is a new-business quoting/issuance API. What this platform
|
||||
needs is an inbound feed of the office's *existing* book.** Every operation
|
||||
above serves "price and issue a policy that does not exist yet." Not one of
|
||||
them is "list the policies where I am the agent of record," which is what
|
||||
would populate `Policy` rows and keep them current.
|
||||
|
||||
So the honest reading of the research is:
|
||||
|
||||
- If Jorge's ask means **"stop re-typing new policies into two systems"** —
|
||||
the ANA service can do that for autos, and it is genuinely buildable once
|
||||
credentials arrive. GMX/daños would stay manual.
|
||||
- If Jorge's ask means **"keep our policy data in sync with the carrier
|
||||
automatically"** — no evidence exists that either carrier offers it, and the
|
||||
question to ask is specifically whether a *portfolio/cartera download*
|
||||
service exists for an agent's own book. That question has not been asked yet.
|
||||
|
||||
**Do not commit to this section until Jorge says which of the two he means.**
|
||||
The first is a moderate feature; the second may not be purchasable at all.
|
||||
|
||||
Note also that nothing in this spec authorizes calling those endpoints. The
|
||||
operation list above comes from a published help page; actually invoking
|
||||
`CalculaValor` or `Transaccion` requires the agent credentials Jorge would
|
||||
obtain, and should not be attempted before then.
|
||||
|
||||
### Legacy precedent
|
||||
|
||||
Carrier config that exists in the legacy system: `gen1`/`gen2`
|
||||
(`LEGACY_DATABASES.md:1872-1892`) — 9 rows keyed by carrier with `RFC`,
|
||||
`CLAVE`, `FPAGO`, `MONED`, plus a 14-row agent list. It is the only
|
||||
carrier-keyed table anywhere, and it carries **no API metadata** — no endpoint,
|
||||
no credential, no identifier that looks like one. In the new schema the
|
||||
equivalent is `InsuranceProvider`, which today holds only a name.
|
||||
|
||||
### Shape
|
||||
|
||||
- `CarrierConnector` interface — `fetchPolicies(since: Date)`,
|
||||
`fetchPolicy(number: string)`, returning a normalized DTO, one implementation
|
||||
per carrier. **The ANA implementation cannot satisfy `fetchPolicies` from the
|
||||
operations known today** (see the mismatch above); if the ask turns out to be
|
||||
outbound issuance instead, the interface is the wrong shape and should become
|
||||
`quote(...)` / `issue(...)` against `CalculaValor` / `Transaccion`.
|
||||
- SOAP, not REST, for ANA — `.asmx` with a WSDL. Node has no first-class SOAP
|
||||
client in this stack; budget for `strong-soap`/`soap` plus the schema work,
|
||||
and generate types from the WSDL rather than hand-writing envelopes.
|
||||
- Credentials and endpoint config per carrier: extend `InsuranceProvider` with
|
||||
the connector's identifier and store secrets in env, keyed by that identifier
|
||||
— never in the database row.
|
||||
- The catalog operations (`Marca`/`SubMarca`/`Modelo`/`CodigoPostal`/`Colonia`)
|
||||
are useful **independently of any policy sync** — they would let the policy
|
||||
form validate vehicle and address data against the carrier's own catalogs
|
||||
instead of free text. That is the cheapest possible first use of these
|
||||
credentials and a sensible pilot: read-only, no issuance risk, immediately
|
||||
visible in `PolicyForm`.
|
||||
- **An import-staging + review step, never a direct write to `Policy`.** Same
|
||||
principle as [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §2, which
|
||||
routes OCR results through a review queue instead of writing ledger rows: one
|
||||
write path, one audit trail, and a human confirms anything a machine
|
||||
proposed. A carrier feed that wrote `Policy` rows directly would also fight
|
||||
the Access sync (`run_all.py --sync`), which owns every row carrying
|
||||
provenance columns — an imported policy needs its own provenance
|
||||
(`legacySourceDb = 'carrier:<name>'`) or the next sync will delete it as a
|
||||
row that vanished from source.
|
||||
|
||||
### Abilities (new)
|
||||
|
||||
| Ability | Min role | Notes |
|
||||
|---|---|---|
|
||||
| `carrier:import` | MANAGER | trigger a fetch and approve imported policies |
|
||||
|
||||
### Open questions
|
||||
|
||||
- ~~Does "GDMX" mean `GMX`?~~ **Resolved 2026-07-27** — yes, a typo in the
|
||||
meeting notes.
|
||||
- **Direction — the one that decides whether this is buildable.** Does Jorge
|
||||
want to *stop re-typing new policies* (outbound quote/issue, which the ANA
|
||||
service supports), or *keep existing policies in sync* (inbound portfolio
|
||||
download, which nothing found suggests either carrier offers)?
|
||||
- What to ask Grupo Valore, in one call to **(55) 5480-4000** or the ANA agent
|
||||
channel:
|
||||
1. WSDL + test/production credentials for `server.anaseguros.com.mx/ananetws/service.asmx`,
|
||||
and whether an agent appointment is a prerequisite.
|
||||
2. Whether a **cartera / portfolio download** service exists for an agent's
|
||||
own book — the question that decides the direction above.
|
||||
3. Whether **GMX daños** has any machine interface at all, or whether its
|
||||
agent portals are the only access. This is the more valuable half for this
|
||||
office: GMX writes the `MULT`/`INCENDIO`/`M_EMPR` book.
|
||||
4. Whether one set of Grupo Valore credentials spans both carriers, given the
|
||||
shared hosting.
|
||||
- Does the office hold agent appointments with both ANA and GMX in good
|
||||
standing? Credential grants are discretionary and appointment-gated.
|
||||
|
||||
---
|
||||
|
||||
## Build sequencing
|
||||
|
||||
1. **§1 renewal emails** — highest value, schema already ready, no blocker
|
||||
beyond the SES sending account. ≈260 mails/month against a 91%-reachable
|
||||
policyholder base.
|
||||
2. **§2 liquidación batch** — small, builds on fields already wired. Do the two
|
||||
defect fixes (missing `policy_types` rows + FK `ON DELETE RESTRICT`) as part
|
||||
of it, since both distort its own report.
|
||||
3. **§3 certificate** — the report half is buildable now; portal delivery waits
|
||||
on PLAN.md steps 8/9 infrastructure, and the whole section waits on what
|
||||
"Solicitud" means.
|
||||
4. **§4 carrier APIs** — blocked on a single phone call, not on research.
|
||||
ANA's SOAP service is real and its operation list is known; what is missing
|
||||
is credentials and an answer on direction (§4's open questions). GMX appears
|
||||
to have nothing machine-readable, which matters because GMX writes the
|
||||
larger half of this office's book. Build last, and consider the catalog-only
|
||||
pilot before anything else.
|
||||
|
||||
§1 and §2 are independent of each other and can be built in parallel; both are
|
||||
independent of everything in `RECEIPT_CAPTURE_SPEC.md`.
|
||||
|
||||
## New abilities across this spec
|
||||
|
||||
| Ability | Min role | Section |
|
||||
|---|---|---|
|
||||
| `renewal:send` | MANAGER | §1 |
|
||||
| `policy:liquidate` | MANAGER | §2 |
|
||||
| `carrier:import` | MANAGER | §4 |
|
||||
|
||||
No collision with the abilities proposed in `RECEIPT_CAPTURE_SPEC.md`
|
||||
(`statement:ingest`, `statement:review`, `bank:manage-accounts`,
|
||||
`customer:recycle`, `customer:purge`).
|
||||
|
||||
## Open questions to take back to Jorge (collected)
|
||||
|
||||
**§1 — renewal emails**
|
||||
- Which SES region + verified identity/configuration set, and whether to reuse
|
||||
existing IAM credentials or create a scoped `ses:SendEmail` user.
|
||||
- The 78 policyholders with no email: skip silently, or produce a print
|
||||
worklist? (Recommend the worklist.)
|
||||
- Spanish or English notice body?
|
||||
|
||||
**§2 — liquidación**
|
||||
- What "garantías" refers to — blocks the exclusion filter.
|
||||
- Settlement on `PolicyPaymentInstallment` (recommended) vs. a second slot on
|
||||
`Policy`; and whether to backfill the ≤41 lost MULT second settlements.
|
||||
- New `policy:liquidate` (MANAGER) vs. reusing `policy:update` (STAFF).
|
||||
|
||||
**§3 — certificate**
|
||||
- What "Solicitud Atlas" is: application form or certificate. Blocks the section.
|
||||
- If application form: who fills it in, and does it precede the `Policy` record?
|
||||
- Does the certificate need carrier letterhead/logo?
|
||||
|
||||
**§4 — carrier APIs** (all four go in one call to Grupo Valore, (55) 5480-4000)
|
||||
- Direction: outbound quote/issue (supported by ANA today) or inbound portfolio
|
||||
sync (no evidence either carrier offers it)? This decides whether the feature
|
||||
is buildable at all.
|
||||
- WSDL + credentials for `server.anaseguros.com.mx/ananetws/service.asmx`.
|
||||
- Does a cartera/portfolio download exist for an agent's own book?
|
||||
- Does GMX daños have any machine interface, or portals only? GMX writes the
|
||||
`MULT`/`INCENDIO`/`M_EMPR` book — the bigger half for this office.
|
||||
- Does one Grupo Valore credential span both carriers?
|
||||
|
||||
**Resolved — no longer open**
|
||||
- ~~Which of `UTILSEG` / `DATGRAL.[NUM UTIL]` is authoritative~~ → `NUM UTIL`;
|
||||
`UTILSEG` is stale and must not be used (see Ground truth).
|
||||
- ~~OCR/mail provider and budget~~ → SES, settled before this spec was written.
|
||||
- ~~Does "GDMX" mean `GMX`~~ → yes, a typo in the meeting notes (2026-07-27).
|
||||
- ~~Do the carriers' APIs exist~~ → ANA: yes, a live SOAP service with a known
|
||||
operation list. GMX: no published machine interface. Both are Grupo Valore,
|
||||
so it is one relationship. See §4.
|
||||
|
||||
## Sources (§4 carrier research, 2026-07-27)
|
||||
|
||||
- [ANA Seguros web service (`ananetws/service.asmx`)](https://server.anaseguros.com.mx/ananetws/service.asmx)
|
||||
- [ANA Seguros — quiénes somos / Grupo Valore](https://anaseguros.com.mx/anaweb/ana_seguros.html)
|
||||
- [GMX Seguros — herramientas para agentes](https://www.gmx.com.mx/soy-agente/herramientas/)
|
||||
- [GMX quoting micrositio hosted on ANA's server](https://server.anaseguros.com.mx/Micrositios/GRUPOVALOREGMXCOR/cotizador.html)
|
||||
- [Agentemotor — how carriers grant web service credentials](https://www.agentemotor.com/blog/noticias-agentemotor/como-integrarte-a-las-aseguradoras-via-web-service-utilizando-agentemotor/)
|
||||
(Colombian market, cited only for the credential-request pattern)
|
||||
+226
-12
@@ -131,6 +131,179 @@ single-movement form.
|
||||
|
||||
## 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 / gas / predial, 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.
|
||||
|
||||
> **EXTENDED — gas and predial, 2026-08-01.** A second corpus (14 documents,
|
||||
> 29 pages: five municipal predial batches and ten gas invoices) added four
|
||||
> parsers — `GAS TIJUANA` plus one per municipality, because Tijuana, Rosarito
|
||||
> and Ensenada issue three completely different documents. End to end against
|
||||
> the dev database that is **21/29 auto-matched, 22/29 identified**, with the
|
||||
> provider read on 29/29 and an amount on 26/29.
|
||||
>
|
||||
> The eight review cases are all legitimate: five Tijuana pages whose municipal
|
||||
> account is not yet on file (see below), one clave not in the book, one page
|
||||
> too poorly scanned to read a clave at all, and one gas account shared by two
|
||||
> services. Excluding the structural Tijuana case, that is 21/24.
|
||||
>
|
||||
> **Five things this corpus proved:**
|
||||
>
|
||||
> 1. **Not every statement is a scan.** The gas company sends born-digital CFDI
|
||||
> invoices whose text layer is exact. Rasterising and re-recognising those
|
||||
> can only lose information — one sample turned `MEDIDOR: VM01014426` into
|
||||
> `ar (LTR): 014420` — so `OcrProvider.textPages` reads the embedded layer
|
||||
> first (`pdftotext -bbox-layout`, same poppler package as `pdftoppm`) and
|
||||
> OCR stays the fallback for real scans. Page images are still rendered and
|
||||
> stored either way, because the reviewer needs to see the paper.
|
||||
> 2. **The clave catastral is not two letters and six digits.** Positions four
|
||||
> through eight are digits in all 932 stored claves, but the third is a
|
||||
> letter in fifteen of them (`MMB01041`, `CGH52121`). Digitising the whole
|
||||
> tail maps that `B` to an `8` and produces a key matching no property.
|
||||
> 3. **Tijuana predial prints no clave catastral at all.** Its only identifier
|
||||
> is an 8-digit municipal account, carried in a 32-digit payment barcode
|
||||
> (`account(8) + DDMMYY + amount(9) + folio(9)`) that the legacy database
|
||||
> never held. It goes in `PROPERTY_TAX.meterNumber` — the same column gas
|
||||
> uses, and for the same reason: `accountNumber` holds `DATMEX.predial`,
|
||||
> which is not a per-property key and overwriting it would destroy the only
|
||||
> link back to the original records. So Tijuana pages start cold and are
|
||||
> taught by the first confirm, exactly like gas.
|
||||
> 4. **On Rosarito and Ensenada the clave is the primary key, not a fallback.**
|
||||
> Those receipts print nothing else, so a unique clave hit there is a real
|
||||
> match and auto-matches; on a utility bill that merely happens to print one
|
||||
> it stays a review hint, as before.
|
||||
> 5. **A misread `$` is the dangerous failure, not a missing one.** An Ensenada
|
||||
> receipt for `$2,203.00` OCR'd as `82,203.00` — the dollar sign read as an
|
||||
> 8, which would post a charge 37× too large and look entirely ordinary in
|
||||
> the ledger. Every predial amount therefore requires a literal `$`, and a
|
||||
> page that cannot produce one reports no amount and goes to review. Two of
|
||||
> the 29 pages take that path, which is the correct outcome for both.
|
||||
>
|
||||
> Regression cover for all of the above lives in
|
||||
> `statement-parser.spec.ts` and `tesseract.provider.spec.ts`; every fixture in
|
||||
> them is a verbatim OCR excerpt from a real receipt.
|
||||
|
||||
> **EXTENDED — zona federal, 2026-08-01.** A third corpus (one document, 8
|
||||
> pages of Tijuana "Zona Federal Marítimo Terrestre" receipts — the federal
|
||||
> maritime-zone occupancy fee billed on beachfront lots) added the
|
||||
> `ZONA FEDERAL TIJUANA` parser. Provider read on 8/8, amount on 8/8 (all
|
||||
> eight verified against the paper), concession clave on 6/8, period on 8/8,
|
||||
> payment deadline on 2/8. Nothing auto-matched, and nothing could have — see
|
||||
> point 2.
|
||||
>
|
||||
> **Four things this corpus proved:**
|
||||
>
|
||||
> 1. **Tijuana bills predial and zona federal from the same treasury.** Same
|
||||
> "Ayuntamiento de Tijuana" header, same Paseo del Centenario address, same
|
||||
> `ATB-541201` RFC — every discriminator the predial parser uses matches a
|
||||
> zona federal page too, so whichever rule is asked first wins. The words
|
||||
> only this layout prints are `Marítimo Terrestre`, so its brand rule is
|
||||
> asked ahead of all three predial ones.
|
||||
> 2. **`FEDERAL_ZONE.accountNumber` is an amount, not a reference.** It holds
|
||||
> `DATMEX.zfed`, whose 77 values include `246.06`, `2369.09`, `22653.94` and
|
||||
> a negative `-1679`; the concession claves the receipts are keyed by
|
||||
> (`12-T -012`, `14-D -014`) appear nowhere in the database. Matching on that
|
||||
> column could never hit — and because every row already has a value, the
|
||||
> `[field]: null` guards on learning and on the blank-service fill would
|
||||
> never fire either, so every page would return to review every bimester
|
||||
> forever. The clave moves to `meterNumber`, joining gas and Tijuana predial,
|
||||
> and the first confirm teaches the match. This is the same trap as
|
||||
> `policies.total` and `PROPERTY_TAX.accountNumber`: a legacy column whose
|
||||
> name promises an identifier and whose contents are something else.
|
||||
> 3. **The payable figure is not the printed subtotal.** The municipality rounds
|
||||
> to whole pesos and prints the difference as its own `Ajuste Ley Hacienda
|
||||
> Mpal` line — `-$0.05` against a 591.05 subtotal, `$0.21` against 2,872.79.
|
||||
> The "Total a pagar" box that carries the rounded figure sits on a grey fill
|
||||
> and OCR'd on 1 of 8 pages; the SubTotal row read on 8 of 8. So the amount
|
||||
> is the rounded subtotal, cross-checked against the printed box wherever it
|
||||
> survives (it agreed).
|
||||
> 4. **The office's own highlighter is an OCR failure mode.** Both pages that
|
||||
> lost their clave lost it to a marker stroke drawn across the `Clave:` line
|
||||
> — not to scan quality, which was otherwise fine. The clave is printed twice
|
||||
> (receipt and stub), which rescued a third page whose heading was struck
|
||||
> but whose stub was not; where both copies are struck, the page reports no
|
||||
> clave and goes to review rather than guessing.
|
||||
>
|
||||
> **Not attempted:** deriving the payment deadline from the bimester. It is the
|
||||
> 17th of the month after the bimester closes on a current bill, but four of
|
||||
> these eight are late — they carry a $1,000 `Multa` — and print a
|
||||
> recalculated deadline a month out. A derived date would be wrong on exactly
|
||||
> the pages a human most wants to look at, so an unreadable deadline stays
|
||||
> null.
|
||||
|
||||
### Motivation (from the meeting)
|
||||
|
||||
Each utility company (CFE, water, phone, gas...) sends 300+ individual
|
||||
@@ -254,8 +427,8 @@ Per the meeting notes' own field list:
|
||||
| Agua — Número de cuenta | `WATER` | `accountNumber` | `AGUA` | ✅ populated today |
|
||||
| Zona Fed — Número de Zona Federal | `FEDERAL_ZONE` | `accountNumber` | `ZFED` | ✅ populated today |
|
||||
| Tel — Número de teléfono | `TELEPHONE` *(new)* | `accountNumber` | `Property.phone1/2/3` (currently on `Property`, not `PropertyService`) | ⚠️ schema gap — see below |
|
||||
| Impuesto — Clave Catastral | `PROPERTY_TAX` | `accountNumber` | migrated from `PREDIAL`, **not** `CLAVE` | ⚠️ needs verification — see below |
|
||||
| Gas — Número de medidor | `GAS` | `meterNumber` | not populated — folded into free-text `notes` today | ⚠️ data gap — see below |
|
||||
| Impuesto — Clave Catastral | `PROPERTY_TAX` | `Property.cadastralKey`, plus `meterNumber` for Tijuana's municipal account | `CLAVE`; `PREDIAL` is left on `accountNumber` and never matched against | ✅ built — see the 2026-08-01 extension note |
|
||||
| Gas — Número de medidor | `GAS` | `meterNumber` | not populated — folded into free-text `notes` today | ✅ 160/334 recovered from `notes` |
|
||||
|
||||
Confidence rule of thumb once a field is confirmed populated, tune after
|
||||
seeing real statements:
|
||||
@@ -401,6 +574,40 @@ document-understanding problem. Recommend:
|
||||
|
||||
## 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
|
||||
|
||||
Seguros uses a US bank account; Utilities uses a Mexican bank account. The
|
||||
@@ -776,16 +983,23 @@ action (`customer:purge`) taken well after release — not bundled into
|
||||
|
||||
## Open questions to take back to Jorge (collected)
|
||||
|
||||
- OCR provider/budget for §2 (self-hosted vs. managed API, given 300+
|
||||
pages/month/company).
|
||||
- Whether source PDFs arrive pre-split per customer or as one bundled file
|
||||
needing page-range detection (§2).
|
||||
- Whether "Clave Catastral" and the already-migrated `PREDIAL`-sourced
|
||||
`PROPERTY_TAX.accountNumber` are the same number — blocks OCR matching
|
||||
for predial statements specifically until confirmed (§2).
|
||||
- Whether phone billing is really one service per phone number on file, or
|
||||
one per property regardless of how many numbers are recorded — decides
|
||||
how the new `TELEPHONE` service kind gets backfilled (§2).
|
||||
- ~~OCR provider/budget for §2~~ — **CLOSED**: self-hosted Tesseract, chosen on
|
||||
measured accuracy against real scans (see §2's BUILT note). No per-page cost.
|
||||
- ~~Whether source PDFs arrive pre-split per customer or bundled~~ —
|
||||
**CLOSED**: bundled, one customer per page. Split per page.
|
||||
- ~~Whether "Clave Catastral" and the `PREDIAL`-sourced
|
||||
`PROPERTY_TAX.accountNumber` are the same number~~ — **CLOSED**: they are
|
||||
different. `clave` is the cadastral key and is now on
|
||||
`Property.cadastralKey`; `predial` is not unique and is not printed on
|
||||
statements.
|
||||
- ~~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
|
||||
whether any historical Seguros bank data exists to migrate (§3).
|
||||
- Whether `BankAccount.businessLine` should be enforced or a soft hint
|
||||
|
||||
@@ -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()
|
||||
@@ -33,7 +33,7 @@ from pathlib import Path
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
from dbenv import connect, load_env
|
||||
from dbenv import connect, require, setting
|
||||
from extract import sanitize_column_name as san
|
||||
|
||||
csv.field_size_limit(300_000_000)
|
||||
@@ -102,12 +102,16 @@ def main():
|
||||
only = set(x.strip() for x in args.tables.split(",") if x.strip())
|
||||
sources = [s for s in SOURCES if not only or s["key"] in only]
|
||||
|
||||
env = load_env(args.env)
|
||||
# Process environment first, deploy/.env.<env> second, with the same
|
||||
# credential aliases the API uses — the "Operaciones" re-import runs this
|
||||
# inside the API container, which has S3_ENDPOINT / MINIO_ROOT_* injected
|
||||
# and no deploy/ directory at all.
|
||||
s3 = boto3.client(
|
||||
"s3", endpoint_url=env["S3_ENDPOINT"],
|
||||
aws_access_key_id=env["MINIO_ROOT_USER"], aws_secret_access_key=env["MINIO_ROOT_PASSWORD"],
|
||||
"s3", endpoint_url=require(args.env, "S3_ENDPOINT"),
|
||||
aws_access_key_id=require(args.env, "S3_ACCESS_KEY", "MINIO_ROOT_USER"),
|
||||
aws_secret_access_key=require(args.env, "S3_SECRET_KEY", "MINIO_ROOT_PASSWORD"),
|
||||
config=Config(signature_version="s3v4"), region_name="us-east-1")
|
||||
bucket = env["S3_BUCKET"]
|
||||
bucket = setting(args.env, "S3_BUCKET") or "jorgecuadros-documents"
|
||||
|
||||
conn = connect(args.env)
|
||||
cur = conn.cursor()
|
||||
|
||||
+31
-7
@@ -32,29 +32,53 @@ REPO = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_env(env: str) -> dict:
|
||||
"""deploy/.env.<env> parsed to a dict, or {} when the file is absent.
|
||||
|
||||
Absent is normal, not an error: the API container runs these scripts with
|
||||
DATABASE_URL / S3_* injected as real environment variables and ships no
|
||||
deploy/ directory. Use `setting()` / `require()` rather than this — they
|
||||
layer the process environment on top, which is what actually resolves."""
|
||||
f = REPO / "deploy" / f".env.{env}"
|
||||
if not f.exists():
|
||||
raise SystemExit(
|
||||
f"missing {f} — deploy the '{env}' DB stack and write its .env first "
|
||||
f"(see dbenv.py header)."
|
||||
)
|
||||
return {}
|
||||
out = {}
|
||||
for line in f.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
out[k] = v
|
||||
if "DATABASE_URL" not in out:
|
||||
raise SystemExit(f"{f} has no DATABASE_URL")
|
||||
return out
|
||||
|
||||
|
||||
def setting(env: str, *keys: str):
|
||||
"""First non-empty value for `keys`, process environment first, then
|
||||
deploy/.env.<env>. Several keys = fallback aliases (S3_ACCESS_KEY then
|
||||
MINIO_ROOT_USER, as apps/api/src/storage/storage.service.ts does)."""
|
||||
fromfile = load_env(env)
|
||||
for k in keys:
|
||||
v = os.environ.get(k) or fromfile.get(k)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
|
||||
def require(env: str, *keys: str) -> str:
|
||||
v = setting(env, *keys)
|
||||
if not v:
|
||||
raise SystemExit(
|
||||
f"missing {' / '.join(keys)} — set it in the environment, or deploy the "
|
||||
f"'{env}' stack and write {REPO / 'deploy' / f'.env.{env}'} "
|
||||
f"(see dbenv.py header)."
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
def database_url(env: str) -> str:
|
||||
"""Target DB URL. A DATABASE_URL in the process environment wins over
|
||||
deploy/.env.<env> — this is how the API container (which has its own
|
||||
DATABASE_URL and no deploy/.env files) drives a re-import against its own
|
||||
database."""
|
||||
return os.environ.get("DATABASE_URL") or load_env(env)["DATABASE_URL"]
|
||||
return require(env, "DATABASE_URL")
|
||||
|
||||
|
||||
def connect(env: str):
|
||||
|
||||
@@ -41,9 +41,18 @@ PY = sys.executable # the venv python running this orchestrator
|
||||
STEPS = [
|
||||
"transform_customers.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_transactions.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",
|
||||
"blob_extract.py",
|
||||
]
|
||||
@@ -51,11 +60,20 @@ STEPS = [
|
||||
SYNC_STEPS = [
|
||||
"transform_customers.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_transactions.py",
|
||||
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
||||
# re-creates from Parquet, but leaves manually-added customers alone.
|
||||
"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",
|
||||
]
|
||||
|
||||
|
||||
@@ -10,12 +10,20 @@ Sources:
|
||||
from the spelled-out "cantidad en letra"
|
||||
- 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
|
||||
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
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -27,6 +35,7 @@ from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backfill_bank_accounts import UTILITIES_ACCOUNT
|
||||
from dbenv import connect, env_arg
|
||||
from sync import parse_mode
|
||||
|
||||
@@ -78,6 +87,19 @@ def main():
|
||||
print(f"[bank] target env: {env}")
|
||||
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)
|
||||
cats, seen = [], set()
|
||||
for _, r in load("tabla_ramodos").iterrows():
|
||||
@@ -96,7 +118,8 @@ def main():
|
||||
skip_date += 1
|
||||
return
|
||||
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)
|
||||
1 if truthy(r["operado"]) else 0,
|
||||
1 if (income and truthy(r["transferido"])) else 0,
|
||||
@@ -110,9 +133,17 @@ def main():
|
||||
for _, r in load("datos_e").iterrows():
|
||||
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:
|
||||
# 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:
|
||||
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:
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||
for t in ("bank_transactions", "business_line_categories"):
|
||||
@@ -120,7 +151,7 @@ def main():
|
||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||
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()
|
||||
|
||||
def count(t):
|
||||
@@ -136,6 +167,15 @@ def main():
|
||||
for src, n, tot in by_src:
|
||||
print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
|
||||
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(" validation: OK")
|
||||
conn.close()
|
||||
|
||||
@@ -147,7 +147,7 @@ def main():
|
||||
props.append((
|
||||
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
||||
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]),
|
||||
notes=s(row["luz_tipo"]) if rc == "rpu" else None,
|
||||
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):
|
||||
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
|
||||
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"]),
|
||||
@@ -194,6 +201,19 @@ def main():
|
||||
if s(row["zfed"]) or flag("federalzone", False):
|
||||
svc("FEDERAL_ZONE", account=s(row["zfed"]), notes=s(row["zfed_t"]),
|
||||
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
|
||||
if 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 ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL")
|
||||
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")
|
||||
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)
|
||||
@@ -227,7 +247,7 @@ def main():
|
||||
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
||||
cur.execute(f"TRUNCATE TABLE {t}")
|
||||
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 trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||
conn.commit()
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jorgecuadros-platform",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -12,7 +12,9 @@
|
||||
"build": "npm run build -ws --if-present",
|
||||
"prisma:generate": "npm run generate -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": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@jorgecuadros/database",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.6",
|
||||
"private": true,
|
||||
"main": "generated/client/index.js",
|
||||
"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,80 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `policy_ocr_batches` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`provider` VARCHAR(191) NOT NULL DEFAULT 'GMX',
|
||||
`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 `policy_ocr_batches_status_createdAt_idx`(`status`, `createdAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `policy_ocr_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,
|
||||
`extractedPolicyNumber` VARCHAR(191) NULL,
|
||||
`extractedInsuredName` VARCHAR(191) NULL,
|
||||
`extractedAdditionalInsured` VARCHAR(191) NULL,
|
||||
`extractedAgentName` VARCHAR(191) NULL,
|
||||
`extractedLegalAddress` TEXT NULL,
|
||||
`extractedZip` VARCHAR(191) NULL,
|
||||
`extractedPolicyFrom` DATETIME(3) NULL,
|
||||
`extractedPolicyTo` DATETIME(3) NULL,
|
||||
`extractedPolicyDate` DATETIME(3) NULL,
|
||||
`extractedCurrency` VARCHAR(191) NULL,
|
||||
`extractedNetPremium` DECIMAL(12, 2) NULL,
|
||||
`extractedPolicyFee` DECIMAL(12, 2) NULL,
|
||||
`extractedBrokerFee` DECIMAL(12, 2) NULL,
|
||||
`extractedTotal` DECIMAL(12, 2) NULL,
|
||||
`extractedCoveragesJson` JSON NULL,
|
||||
`extractedPremiumPayment` VARCHAR(191) NULL,
|
||||
`matchedPolicyId` VARCHAR(191) NULL,
|
||||
`matchedCustomerId` VARCHAR(191) NULL,
|
||||
`matchCandidates` JSON NULL,
|
||||
`matchNote` VARCHAR(191) NULL,
|
||||
`reviewedById` VARCHAR(191) NULL,
|
||||
`reviewedAt` DATETIME(3) NULL,
|
||||
`createdPolicyId` VARCHAR(191) NULL,
|
||||
`postedTransactionId` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `policy_ocr_documents_createdPolicyId_key`(`createdPolicyId`),
|
||||
UNIQUE INDEX `policy_ocr_documents_postedTransactionId_key`(`postedTransactionId`),
|
||||
INDEX `policy_ocr_documents_status_idx`(`status`),
|
||||
INDEX `policy_ocr_documents_matchedCustomerId_idx`(`matchedCustomerId`),
|
||||
UNIQUE INDEX `policy_ocr_documents_batchId_pageNumber_key`(`batchId`, `pageNumber`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_batches` ADD CONSTRAINT `policy_ocr_batches_uploadedById_fkey` FOREIGN KEY (`uploadedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_batchId_fkey` FOREIGN KEY (`batchId`) REFERENCES `policy_ocr_batches`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_matchedPolicyId_fkey` FOREIGN KEY (`matchedPolicyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_matchedCustomerId_fkey` FOREIGN KEY (`matchedCustomerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_reviewedById_fkey` FOREIGN KEY (`reviewedById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_documents_createdPolicyId_fkey` FOREIGN KEY (`createdPolicyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `policy_ocr_documents` ADD CONSTRAINT `policy_ocr_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"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user