Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea | ||
|
|
5549a1e0cf | ||
|
|
c6feae9522 | ||
|
|
93f817158e | ||
|
|
29ae9fa5bc | ||
|
|
9973488330 | ||
|
|
75dcbc11b8 | ||
|
|
9929a9a3ac | ||
|
|
8b8de0fdca | ||
|
|
bc749055e7 | ||
|
|
75e9f582b4 | ||
|
|
48e01ddd21 | ||
|
|
8c144fe8c4 | ||
|
|
4f2f064955 | ||
|
|
2f99bd5f98 | ||
|
|
458b2b272d | ||
|
|
81938877ed | ||
|
|
d854dff091 | ||
|
|
19864f16f2 | ||
|
|
ca6432efc8 | ||
|
|
022d1935ad | ||
|
|
5a277f4885 | ||
|
|
d645ba51d3 | ||
|
|
14c4d44acb | ||
|
|
45be0ad77d | ||
|
|
7be897ef2b | ||
|
|
cf40cd22ef | ||
|
|
3b02c6944f | ||
|
|
683fd37b08 | ||
|
|
14c6183aa2 | ||
|
|
5352d49ecf | ||
|
|
2169ffa78d | ||
|
|
17d83291c3 | ||
|
|
6a97242fc3 | ||
|
|
7981c715ce | ||
|
|
d173c9e9a0 | ||
|
|
e9a5ee9e90 | ||
|
|
458e67340c | ||
|
|
b12382b436 | ||
|
|
2620559975 | ||
|
|
ed19f51a52 | ||
|
|
e85db73dbc | ||
|
|
d38bbc52ec | ||
|
|
fe761e119e | ||
|
|
4a929f7e7c | ||
|
|
66d0d071b0 | ||
|
|
f269dc8bfa | ||
|
|
eef9a5f4c8 | ||
|
|
7f1bfe906e | ||
|
|
7797c45e9f | ||
|
|
dac1f1982f | ||
|
|
1d689d8f46 | ||
|
|
7bec2a13d8 | ||
|
|
127eaa9689 | ||
|
|
7226772c22 | ||
|
|
2fa12890f5 | ||
|
|
e77e5546d8 | ||
|
|
3e12597204 | ||
|
|
ec139737be | ||
|
|
872a661051 | ||
|
|
6331481f82 | ||
|
|
89611da202 | ||
|
|
a491ef3eed | ||
|
|
f4b92fa7a5 | ||
|
|
33833c3af9 | ||
|
|
c0cc0d2ac2 | ||
|
|
53a5fe8076 | ||
|
|
0332292ae9 | ||
|
|
ec0e9c2a5d | ||
|
|
a52e59cbc5 | ||
|
|
87d8743251 | ||
|
|
3125b52057 | ||
|
|
905fa31e47 | ||
|
|
5e9cb12fba | ||
|
|
5bce0e4c94 | ||
|
|
d6501f1d74 | ||
|
|
216309190c | ||
|
|
e589bda28b | ||
|
|
98f7aa8a2d | ||
|
|
898cf48c80 | ||
|
|
70fe425043 | ||
|
|
567b033c46 | ||
|
|
1934470d53 | ||
|
|
fdbe9fdb88 | ||
|
|
e082113640 | ||
|
|
860d483bad | ||
|
|
783ec83464 | ||
|
|
a9b4aab7ec | ||
|
|
a8afd87c3f | ||
|
|
b59abda895 | ||
|
|
4d5008b545 |
@@ -4,6 +4,15 @@ SESSION_SECRET=change-me-to-a-random-string
|
|||||||
WEB_ORIGIN=http://localhost:3000
|
WEB_ORIGIN=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||||
|
|
||||||
|
# Object storage (MinIO / S3) for document blobs and scanned receipt pages.
|
||||||
|
# Without S3_ENDPOINT + credentials the API still boots, but every document
|
||||||
|
# upload/download and the whole recibo OCR intake are disabled. Credentials fall
|
||||||
|
# back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD when the S3_* pair is unset.
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_BUCKET=jorgecuadros-documents
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
|
||||||
# Login the "Operaciones" screen runs mysqldump/mysql as. Optional locally: when
|
# 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
|
# 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
|
# grants enough for. Required in any deployment, where the application user has
|
||||||
@@ -24,3 +33,20 @@ COMPANY_EMAIL=
|
|||||||
COMPANY_TAX_ID=
|
COMPANY_TAX_ID=
|
||||||
COMPANY_WEBSITE=
|
COMPANY_WEBSITE=
|
||||||
COMPANY_LOGO_PATH=
|
COMPANY_LOGO_PATH=
|
||||||
|
|
||||||
|
# Outbound mail (Amazon SES — the channel the office already uses for bulk
|
||||||
|
# notification, see docs/MASS_EMAIL_NOTIFICATIONS.md). Without all four
|
||||||
|
# vars the API still boots; in dev the MailService logs sends to stdout,
|
||||||
|
# in production every send throws ServiceUnavailableException.
|
||||||
|
SES_REGION=
|
||||||
|
SES_ACCESS_KEY=
|
||||||
|
SES_SECRET_KEY=
|
||||||
|
SES_FROM=mail@jorgecuadros.com
|
||||||
|
SES_FROM_NAME=Information Server
|
||||||
|
# Optional — bounce/complaint event publishing configuration set.
|
||||||
|
SES_CONFIGURATION_SET=
|
||||||
|
|
||||||
|
# Comma-separated addresses that receive the per-job admin summary email
|
||||||
|
# (one summary per address, JSON body, sent after every sweep). Defaults to
|
||||||
|
# the legacy pair if unset.
|
||||||
|
NOTIFICATION_ADMIN_EMAILS=rmancinas@freakma.net,mpulido@freakma.net
|
||||||
|
|||||||
@@ -14,7 +14,16 @@
|
|||||||
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
|
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
|
||||||
# container can report exactly what is deployed.
|
# container can report exactly what is deployed.
|
||||||
#
|
#
|
||||||
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images.
|
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images
|
||||||
|
# -> deploy-on-tag.yml waits for this run to go green and then
|
||||||
|
# dispatches deploy-galactus.yml.
|
||||||
|
#
|
||||||
|
# This workflow BUILDS ONLY — it never deploys. The deploy chain used to be a
|
||||||
|
# job here, gated to tag refs, but Gitea draws every job of a workflow into the
|
||||||
|
# run graph before it evaluates the job's `if`: a routine master build showed a
|
||||||
|
# pending "Deploy to galactus" and looked like prod was about to be redeployed
|
||||||
|
# off an unreleased commit. Keeping the deploy in a `on: push: tags` workflow of
|
||||||
|
# its own makes that structurally impossible.
|
||||||
|
|
||||||
name: Build and Push Images
|
name: Build and Push Images
|
||||||
|
|
||||||
@@ -37,6 +46,16 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.image }}
|
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
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: docker:27-dind
|
image: docker:27-dind
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
||||||
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
||||||
# the rule, the backup is the emergency lever.
|
# the rule, the backup is the emergency lever.
|
||||||
|
# Done HERE so the schema moves while the OLD code is
|
||||||
|
# still serving. The api container ALSO migrates at
|
||||||
|
# start (docker/api-entrypoint.sh); `migrate deploy`
|
||||||
|
# is idempotent, so the second run is a no-op and the
|
||||||
|
# container is what covers a restart that never goes
|
||||||
|
# through this workflow at all.
|
||||||
# 4. app (api + web) the new images.
|
# 4. app (api + web) the new images.
|
||||||
# 5. verify ask the running API what it actually is.
|
# 5. verify ask the running API what it actually is.
|
||||||
#
|
#
|
||||||
@@ -40,10 +46,25 @@
|
|||||||
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
||||||
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
||||||
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
||||||
# - The runner (which lives on cubex) must be able to reach BOTH
|
# Optional — outbound mail. Not needed to deploy; needed for
|
||||||
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
# /notificaciones to send anything at all (the image sets
|
||||||
# If it cannot reach 3306, run the migration by hand from a host that can
|
# NODE_ENV=production, which disables MailService's stdout fallback, so
|
||||||
# and dispatch with skip_migrate=true.
|
# a blank config fails every send loudly):
|
||||||
|
# SES_REGION e.g. us-west-2
|
||||||
|
# SES_FROM a VERIFIED SES sending identity
|
||||||
|
# SES_FROM_NAME display name, optional
|
||||||
|
# SES_ACCESS_KEY / SES_SECRET_KEY
|
||||||
|
# SES_CONFIGURATION_SET optional, for bounce/complaint events
|
||||||
|
# NOTIFICATION_ADMIN_EMAILS fallback only — the summary recipients
|
||||||
|
# are edited in the UI and stored in
|
||||||
|
# app_settings; this is what a deployment
|
||||||
|
# uses until somebody saves them there
|
||||||
|
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
|
||||||
|
# serves every deployment.
|
||||||
|
# - The runner (which lives on cubex) must be able to reach galactus:9443
|
||||||
|
# (Portainer). It should also reach galactus:3306 for step 3, but that is
|
||||||
|
# no longer load-bearing: dispatch with skip_migrate=true and the api
|
||||||
|
# container applies the migrations itself at start.
|
||||||
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
# - 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 `prisma db push`): baseline it before the first run, or step 3 fails
|
||||||
# with P3005 "database schema is not empty":
|
# with P3005 "database schema is not empty":
|
||||||
@@ -73,7 +94,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
skip_migrate:
|
skip_migrate:
|
||||||
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
description: "Skip the runner-side migrate step (safe: the api container migrates at start)"
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -116,6 +137,13 @@ jobs:
|
|||||||
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||||
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
# Not required — the app boots fine without mail. Warned about below,
|
||||||
|
# because the failure mode is remote: everything looks healthy until
|
||||||
|
# someone clicks "Ejecutar" and every send fails.
|
||||||
|
SES_REGION: ${{ secrets.SES_REGION }}
|
||||||
|
SES_FROM: ${{ secrets.SES_FROM }}
|
||||||
|
SES_ACCESS_KEY: ${{ secrets.SES_ACCESS_KEY }}
|
||||||
|
SES_SECRET_KEY: ${{ secrets.SES_SECRET_KEY }}
|
||||||
SCOPE: ${{ github.event.inputs.scope }}
|
SCOPE: ${{ github.event.inputs.scope }}
|
||||||
run: |
|
run: |
|
||||||
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
||||||
@@ -140,6 +168,20 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "all required secrets present for scope=$SCOPE"
|
echo "all required secrets present for scope=$SCOPE"
|
||||||
|
|
||||||
|
# Mail is optional to deploy but not optional to work. Say so loudly
|
||||||
|
# rather than letting /notificaciones fail one send at a time.
|
||||||
|
mail_missing=""
|
||||||
|
for name in SES_REGION SES_FROM SES_ACCESS_KEY SES_SECRET_KEY; do
|
||||||
|
eval "value=\${$name}"
|
||||||
|
[ -z "$value" ] && mail_missing="$mail_missing $name"
|
||||||
|
done
|
||||||
|
if [ -n "$mail_missing" ]; then
|
||||||
|
echo "::warning::outbound mail is NOT configured, missing:$mail_missing"
|
||||||
|
echo "::warning::the deploy will succeed, but every notification and"
|
||||||
|
echo "::warning::renewal aviso will fail with 'El envío de correo no"
|
||||||
|
echo "::warning::está configurado.' See docs/MASS_EMAIL_NOTIFICATIONS.md"
|
||||||
|
fi
|
||||||
|
|
||||||
# --- full only: database ---------------------------------------------
|
# --- full only: database ---------------------------------------------
|
||||||
- name: Deploy database stack
|
- name: Deploy database stack
|
||||||
if: ${{ github.event.inputs.scope == 'full' }}
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
@@ -201,6 +243,10 @@ jobs:
|
|||||||
run: node deploy/scripts/pre-migrate-backup.mjs
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
# --- schema, forward-only ---------------------------------------------
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
# Belt to the container's braces: this runs while the OLD code is still
|
||||||
|
# serving, which is the order expand/contract is designed around. The
|
||||||
|
# api container repeats it at start for the paths this step cannot
|
||||||
|
# reach (skip_migrate, a host reboot, a stack re-applied by hand).
|
||||||
- name: Apply database migrations
|
- name: Apply database migrations
|
||||||
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
env:
|
env:
|
||||||
@@ -245,13 +291,20 @@ jobs:
|
|||||||
standalone: true
|
standalone: true
|
||||||
pull: true
|
pull: true
|
||||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
# NOTE: the block below is parsed as JSON — no comments inside it.
|
||||||
|
#
|
||||||
|
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
||||||
|
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
||||||
|
# survives the box moving between the tailnet, the office LAN and a
|
||||||
|
# demo domain. Setting it here would pin it again and re-break an https
|
||||||
|
# front door with mixed active content. APP_API_ORIGIN_GALACTUS lives
|
||||||
|
# on only as the URL the verify step probes.
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
"API_PORT": "3001",
|
"API_PORT": "3001",
|
||||||
"WEB_PORT": "3000",
|
"WEB_PORT": "3000",
|
||||||
"S3_BUCKET": "jorgecuadros-documents",
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
|
||||||
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
||||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
||||||
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
||||||
@@ -259,8 +312,18 @@ jobs:
|
|||||||
"SESSION_COOKIE_SECURE": "false",
|
"SESSION_COOKIE_SECURE": "false",
|
||||||
"OPS_DB_ADMIN_USER": "root",
|
"OPS_DB_ADMIN_USER": "root",
|
||||||
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||||
|
"REPLICA_DB_HOST": "${{ secrets.REPLICA_DB_HOST }}",
|
||||||
|
"REPLICA_DB_USER": "${{ secrets.REPLICA_DB_USER }}",
|
||||||
|
"REPLICA_DB_PASS": "${{ secrets.REPLICA_DB_PASS }}",
|
||||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
|
||||||
|
"SES_REGION": "${{ secrets.SES_REGION }}",
|
||||||
|
"SES_FROM": "${{ secrets.SES_FROM }}",
|
||||||
|
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
|
||||||
|
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
|
||||||
|
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
|
||||||
|
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
|
||||||
|
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- prove it ----------------------------------------------------------
|
# --- prove it ----------------------------------------------------------
|
||||||
@@ -276,6 +339,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
apk add --no-cache curl >/dev/null
|
apk add --no-cache curl >/dev/null
|
||||||
|
# These secrets are CORS origin LISTS as far as the app is concerned
|
||||||
|
# (WEB_ORIGIN is comma-separated so one deployment can be reached by
|
||||||
|
# LAN IP, tailnet name and demo domain at once). A list is not a URL,
|
||||||
|
# so probe the FIRST entry — keep the runner-reachable origin first.
|
||||||
|
API_ORIGIN=${API_ORIGIN%%,*}
|
||||||
|
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
||||||
fetch_version() {
|
fetch_version() {
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Chain the PROD deploy onto a green tag build.
|
||||||
|
#
|
||||||
|
# This is a SEPARATE workflow, not a job in build.yml, and the trigger is the
|
||||||
|
# whole point: `on: push: tags` cannot fire on a push to master. When this was a
|
||||||
|
# `deploy` job inside build.yml gated by `if: startsWith(github.ref,
|
||||||
|
# 'refs/tags/v')`, Gitea still drew "Deploy to galactus" into the job graph of
|
||||||
|
# every ordinary master build — the `if` is not evaluated until `needs` resolve,
|
||||||
|
# so the job sits there looking like an imminent production deploy on a commit
|
||||||
|
# nobody released. That is indistinguishable from a real misfire, and the only
|
||||||
|
# safe reaction is to cancel the run, which kills the images with it.
|
||||||
|
#
|
||||||
|
# What it does NOT do is build. build.yml already builds and pushes both images
|
||||||
|
# from one run; this waits for that run to go green and then dispatches
|
||||||
|
# deploy-galactus.yml, which only pulls.
|
||||||
|
#
|
||||||
|
# Why wait for the build run rather than just dispatching: deploy-galactus.yml
|
||||||
|
# pulls api and web at the same tag, and a half-pushed pair is exactly the state
|
||||||
|
# that leaves prod running one new image and one old one. The build run turning
|
||||||
|
# green is the signal that both are in the registry.
|
||||||
|
#
|
||||||
|
# Why a dispatch and not a `workflow_run:` trigger, which Gitea does support as
|
||||||
|
# of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places (tag,
|
||||||
|
# scope, bootstrap, skip_migrate). Under workflow_run every one of them is the
|
||||||
|
# empty string, so the deploy would silently run with no tag and scope != 'full'.
|
||||||
|
# A dispatch keeps that workflow's contract intact and keeps it hand-runnable for
|
||||||
|
# rollbacks, which is the whole point of it.
|
||||||
|
#
|
||||||
|
# Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut the
|
||||||
|
# chain and go back to dispatching the deploy by hand. Anything else (including
|
||||||
|
# unset) deploys.
|
||||||
|
|
||||||
|
name: Deploy on tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy to galactus
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- 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, so this cannot wait"
|
||||||
|
echo "::error::for the build or dispatch the deploy. Once build.yml"
|
||||||
|
V=${GITHUB_REF#refs/tags/}
|
||||||
|
echo "::error::is green, run 'Deploy to galactus' by hand with tag=${V#v}."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Wait for the tag build, then dispatch deploy-galactus.yml
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
|
||||||
|
TAG_REF: ${{ github.ref }}
|
||||||
|
BUILD_SHA: ${{ github.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}` };
|
||||||
|
// refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
|
||||||
|
// it does not depend on how Gitea populates GITHUB_REF_NAME.
|
||||||
|
const tagRef = process.env.TAG_REF;
|
||||||
|
const tag = tagRef.replace(/^refs\/tags\//, "");
|
||||||
|
// The git tag carries the leading v; the image tag does not.
|
||||||
|
const version = tag.replace(/^v/, "");
|
||||||
|
const sha = process.env.BUILD_SHA;
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
const runs = async () => {
|
||||||
|
const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
|
||||||
|
if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
|
||||||
|
return (await r.json()).workflow_runs || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The release commit and its tag are the SAME sha, and build.yml
|
||||||
|
// skips the master run by design — so a sha match alone can latch
|
||||||
|
// onto that skipped run and call the build green when no image was
|
||||||
|
// ever pushed. Require the tag ref when the API reports one.
|
||||||
|
const isTagRun = (r) => {
|
||||||
|
const ref = r.head_branch || r.ref || "";
|
||||||
|
return !ref || ref === tag || ref === tagRef;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRun = async () =>
|
||||||
|
(await runs()).find(
|
||||||
|
(r) =>
|
||||||
|
r.head_sha === sha &&
|
||||||
|
String(r.path || "").includes("build.yml") &&
|
||||||
|
isTagRun(r),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Gitea reports a run as `status` and mirrors it into `conclusion`;
|
||||||
|
// read whichever is populated rather than betting on one field.
|
||||||
|
const outcome = (r) => String(r.conclusion || r.status || "").toLowerCase();
|
||||||
|
const DONE = ["success", "failure", "cancelled", "canceled", "skipped"];
|
||||||
|
|
||||||
|
// Every deploy-galactus run id visible right now. A dispatch is only
|
||||||
|
// confirmed by an id that is NOT in here — a plain "is there a deploy
|
||||||
|
// run" check is satisfied by the PREVIOUS release run, and would
|
||||||
|
// report success for a dispatch that never took.
|
||||||
|
const deployRunIds = async () =>
|
||||||
|
new Set(
|
||||||
|
(await runs())
|
||||||
|
.filter((r) => String(r.path || "").includes("deploy-galactus.yml"))
|
||||||
|
.map((r) => r.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (process.env.AUTO_DEPLOY === "false") {
|
||||||
|
console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
|
||||||
|
console.log(`Deploy by hand with tag=${version} when ready.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~20 min. A build is about 90s; the rest is queue time behind
|
||||||
|
// other runs on a single runner.
|
||||||
|
let run = null;
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
run = await buildRun();
|
||||||
|
if (run && DONE.includes(outcome(run))) break;
|
||||||
|
if (!run && i === 11) {
|
||||||
|
// Two minutes with no run at all. The post-receive hook drops
|
||||||
|
// runs silently when it errors (this cost v1.0.3 its images),
|
||||||
|
// so say so rather than timing out with no explanation.
|
||||||
|
console.log(`::warning::No build.yml run for ${tag} yet after 2 min.`);
|
||||||
|
console.log(`::warning::If the Gitea post-receive hook is broken, dispatch`);
|
||||||
|
console.log(`::warning::"Build and Push Images" by hand with ref=${tag}.`);
|
||||||
|
}
|
||||||
|
await sleep(15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!run) {
|
||||||
|
console.log(`::error::No build.yml run for ${tag} (${sha}) after 20 min.`);
|
||||||
|
console.log(`::error::Dispatch "Build and Push Images" with ref=${tag} (the`);
|
||||||
|
console.log(`::error::tag, not master), then deploy by hand with tag=${version}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = outcome(run);
|
||||||
|
if (result !== "success") {
|
||||||
|
console.log(`::error::build.yml for ${tag} ended as "${result}" — not deploying.`);
|
||||||
|
console.log(`::error::Fix the build, re-run it, then deploy by hand with tag=${version}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`build.yml for ${tag} is green (run ${run.id}). Deploying ${version}.`);
|
||||||
|
const before = await deployRunIds();
|
||||||
|
|
||||||
|
// Dispatch against the TAG, not master: the deploy applies the
|
||||||
|
// compose files under deploy/galactus/ from whatever ref it runs
|
||||||
|
// on, and those must be the ones this release was cut with.
|
||||||
|
const res = await fetch(
|
||||||
|
`${base}/actions/workflows/deploy-galactus.yml/dispatches`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ref: tagRef,
|
||||||
|
inputs: {
|
||||||
|
tag: version,
|
||||||
|
scope: "app",
|
||||||
|
bootstrap: "false",
|
||||||
|
skip_migrate: "false",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
|
||||||
|
console.log(`::error::Images for ${version} are published. Run`);
|
||||||
|
console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 204 only means Gitea accepted the request. Confirm a NEW run
|
||||||
|
// exists — an accepted call that creates no run is the failure mode
|
||||||
|
// that cost v1.0.3 its images.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await sleep(5_000);
|
||||||
|
const fresh = [...(await deployRunIds())].filter((id) => !before.has(id));
|
||||||
|
if (fresh.length) {
|
||||||
|
console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
|
||||||
|
console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
|
||||||
|
process.exit(1);
|
||||||
|
})();
|
||||||
|
'
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
# 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 ->
|
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
||||||
|
# (the api container also migrates at start; see docker/api-entrypoint.sh)
|
||||||
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
# 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
|
# 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.
|
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
||||||
@@ -47,9 +48,11 @@
|
|||||||
# # Database stack (full only)
|
# # Database stack (full only)
|
||||||
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||||
# MYSQL_ROOT_PASSWORD mysql root password
|
# MYSQL_ROOT_PASSWORD mysql root password
|
||||||
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
# - the runner must reach Portainer (9443). It should also reach MySQL (3306)
|
||||||
# migration step connects to the database directly. If it cannot reach 3306,
|
# for the migrate step, but that is no longer load-bearing: dispatch with
|
||||||
# migrate by hand and dispatch with skip_migrate=true.
|
# skip_migrate=true and the api container applies the migrations itself at
|
||||||
|
# start (docker/api-entrypoint.sh). `migrate deploy` is idempotent, so the
|
||||||
|
# two never conflict.
|
||||||
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
# - 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
|
# that exists today): baseline it before the first run, or the migrate step
|
||||||
# fails with P3005 "database schema is not empty":
|
# fails with P3005 "database schema is not empty":
|
||||||
@@ -79,7 +82,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
skip_migrate:
|
skip_migrate:
|
||||||
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
description: "Skip the runner-side migrate step (safe: the api container migrates at start)"
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -253,13 +256,19 @@ jobs:
|
|||||||
type: file
|
type: file
|
||||||
pull: true
|
pull: true
|
||||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
# NOTE: the block below is parsed as JSON — no comments inside it.
|
||||||
|
#
|
||||||
|
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
||||||
|
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
||||||
|
# survives the host moving. Setting it here would pin it again and
|
||||||
|
# re-break an https front door with mixed active content. APP_API_ORIGIN
|
||||||
|
# lives on only as the URL the verify step probes.
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
"API_PORT": "3001",
|
"API_PORT": "3001",
|
||||||
"WEB_PORT": "3000",
|
"WEB_PORT": "3000",
|
||||||
"S3_BUCKET": "jorgecuadros-documents",
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
|
||||||
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
||||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||||
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||||
@@ -267,7 +276,14 @@ jobs:
|
|||||||
"OPS_DB_ADMIN_USER": "root",
|
"OPS_DB_ADMIN_USER": "root",
|
||||||
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}",
|
||||||
|
"SES_REGION": "${{ secrets.SES_REGION }}",
|
||||||
|
"SES_FROM": "${{ secrets.SES_FROM }}",
|
||||||
|
"SES_FROM_NAME": "${{ secrets.SES_FROM_NAME }}",
|
||||||
|
"SES_ACCESS_KEY": "${{ secrets.SES_ACCESS_KEY }}",
|
||||||
|
"SES_SECRET_KEY": "${{ secrets.SES_SECRET_KEY }}",
|
||||||
|
"SES_CONFIGURATION_SET": "${{ secrets.SES_CONFIGURATION_SET }}",
|
||||||
|
"NOTIFICATION_ADMIN_EMAILS": "${{ secrets.NOTIFICATION_ADMIN_EMAILS }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- prove it ----------------------------------------------------------
|
# --- prove it ----------------------------------------------------------
|
||||||
@@ -281,6 +297,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
apk add --no-cache curl >/dev/null
|
apk add --no-cache curl >/dev/null
|
||||||
|
# These secrets are CORS origin LISTS as far as the app is concerned
|
||||||
|
# (WEB_ORIGIN is comma-separated so one deployment can be reached under
|
||||||
|
# several origins at once). A list is not a URL, so probe the FIRST
|
||||||
|
# entry — keep the runner-reachable origin first.
|
||||||
|
API_ORIGIN=${API_ORIGIN%%,*}
|
||||||
|
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
||||||
fetch_version() {
|
fetch_version() {
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
||||||
|
#
|
||||||
|
# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
|
||||||
|
# what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`,
|
||||||
|
# `sha-<short>` and `latest` image tags, and deploy-on-tag.yml, which waits for
|
||||||
|
# that build to go green and then dispatches deploy-galactus.yml with
|
||||||
|
# `tag=X.Y.Z scope=app` (no leading v — the git tag carries the `v`, the image
|
||||||
|
# tag does not). A tag is the only ref that starts either chain; pushing to
|
||||||
|
# master builds images and stops there.
|
||||||
|
#
|
||||||
|
# So cutting a release DOES reach prod. To cut a version without deploying it,
|
||||||
|
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; deploy-on-tag.yml then
|
||||||
|
# prints the manual command instead of running it.
|
||||||
|
#
|
||||||
|
# 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 "deploy-on-tag.yml is watching that build; when it goes green it dispatches"
|
||||||
|
echo "'Deploy to galactus' with tag=${VERSION} scope=app bootstrap=false skip_migrate=false."
|
||||||
|
echo ""
|
||||||
|
echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false),"
|
||||||
|
echo "dispatch 'Deploy to galactus' by hand with the same inputs."
|
||||||
|
echo "Rollback = re-dispatch it with an older tag."
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Unified Customer / Insurance / Utilities Platform — Migration & Rebuild Plan
|
# Unified Customer / Insurance / Utilities Platform — Migration & Rebuild Plan
|
||||||
|
|
||||||
|
> **Looking for what is still outstanding?** → [`docs/BACKLOG.md`](docs/BACKLOG.md).
|
||||||
|
> This document is the plan and its running status; the backlog collects every
|
||||||
|
> open item — blocked-on-Jorge decisions, live data defects, unbuilt features
|
||||||
|
> and deploy blockers — in one list, checked against the code rather than
|
||||||
|
> against these notes.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Jorge Cuadros & Assoc. runs two lines of business — property/utility management (`UTILITIES.accdb`) and insurance brokerage (`SEGUROS 16.mdb` + its linked backend `SEGUROS 16_be.mdb`) — out of separate, decades-old MS Access databases, plus a third file (`SCOTHIA.mdb`) that's the office's own Scotiabank checking-account register ("chequera"). The same people are customers of both business lines, but today there's no shared customer record: a person's utility account and their insurance policies live in unrelated systems with independent, inconsistent copies of their name/address/contact info. The bank register is a fourth, disconnected source of truth for the money actually moving through the office's own account.
|
Jorge Cuadros & Assoc. runs two lines of business — property/utility management (`UTILITIES.accdb`) and insurance brokerage (`SEGUROS 16.mdb` + its linked backend `SEGUROS 16_be.mdb`) — out of separate, decades-old MS Access databases, plus a third file (`SCOTHIA.mdb`) that's the office's own Scotiabank checking-account register ("chequera"). The same people are customers of both business lines, but today there's no shared customer record: a person's utility account and their insurance policies live in unrelated systems with independent, inconsistent copies of their name/address/contact info. The bank register is a fourth, disconnected source of truth for the money actually moving through the office's own account.
|
||||||
@@ -134,13 +140,22 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
|||||||
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
||||||
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
||||||
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
||||||
- **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all.
|
- **PDF/OCR auto-capture — DONE** (2026-08-01). As-built write-up in [`docs/STATEMENT_OCR.md`](docs/STATEMENT_OCR.md); the design and the measured evidence stay in the spec's §2. 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.
|
||||||
|
- **Policy OCR capture — DONE** (2026-08-01), **unplanned — it came out of building the bullet above.** Full write-up in [`docs/POLICY_OCR.md`](docs/POLICY_OCR.md). Once the receipt pipeline existed it was obvious the same render→OCR→parse→match→review shape fits the *other* stack of paper this office keys in by hand: the carrier policy PDFs behind every `Policy` row. Built in `apps/api/src/policy-ocr/` with a GMX parser, `policy_ocr_batches`/`policy_ocr_documents`, and abilities `policy:ingest`/`policy:ocr-review` (STAFF, same trust tier and same reason). Web: `/polizas/captura` is the "automática" tab of the policy-creation screen (`/polizas/nuevo` is the manual one, both render `PolicyCaptura.tsx`) with the review queue at `/polizas/captura/[id]`. The `OcrProvider` seam was **extracted out of `StatementsModule` into its own `OcrModule`** to make this possible — that was blocking, not cosmetic; `StatementsModule` now imports it and binds nothing.
|
||||||
|
**The statement pipeline's core assumption inverts here.** Utility statements arrive bundled *one customer per page*, so there a page is a document; a GMX certificate is one policy across two pages (header on 1, coverage table on 2), so the pipeline concatenates the pages and runs the parser and matcher **once per file**. `PolicyOcrDocument.pageNumber` is therefore the file ordinal in the batch, and `storageKey` points at the **source PDF** (the review screen embeds the exact artifact the office received) rather than at a page image. Matching is on `Policy.policyNumber` alone and never the printed insured name — the same registrant-vs-owner drift that rules names out on the utility side. Zero hits means a new policy and confirm creates it; more than one is surfaced, never auto-picked.
|
||||||
|
**The GMX certificate carries no premium at all** — the figure lives on a separate `recibo` PDF — so the premium fields stay null with a note saying why, confirm never overwrites an existing premium with null, and the optional ledger write is gated on staff ticking `postPremium` *and* a premium actually parsing. 8/8 parser tests against one real document (`HC_Folio_000767_Traduccion.pdf`). GMX is the only carrier implemented; the dispatcher is a pattern table, so a second one is a parser function and two entries.
|
||||||
- **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.
|
- **Multi-bank chequera — DONE** (2026-07-27). `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register. `bank_transactions` gained a **required** `bankAccountId` (plus an `(bankAccountId, transactionDate)` index, since every read is now filtered by account and ordered by date), and all 22,669 existing rows were backfilled onto a seeded "Utilities — Scotiabank (MXN)" account by `migration/backfill_bank_accounts.py` — a standalone step because `prisma db push` cannot add a required column to a populated table. It is idempotent and now runs inside `run_all.py` (both normal and `--sync`) ahead of `transform_bank.py`, which fails fast if the account is missing. Every read path in `bank.service.ts` is account-scoped, including `facets()` (which had no filter at all) and *both* raw-SQL rollups in `summary()`. API: `?bankAccountId=` is required on `list`/`stats`/`facets`/`summary` — **not** optional-with-an-all-accounts-default, since summing an MXN and a USD register repeats exactly the currency-collapsing mistake the billing module exists to prevent — plus a new `bank/accounts` + `bank/banks` sub-resource under a MANAGER `bank:manage-accounts` ability. Web: `/banco` gained an account picker (remembered per browser) and reads every figure in the selected account's currency, `/banco/cuentas` manages banks and accounts, and `/inicio`'s chequera card names the account it is showing instead of implying one register. An account's `currency` is immutable after creation by design — its booked movements are denominated in it. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged.
|
||||||
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
||||||
|
|
||||||
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
||||||
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:
|
12. **Insurance features — one of four built, rest spec'd.** 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.
|
- **Renewal notification emails — DONE** (2026-08-01, extended 08-02). A 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 letter body is the *existing* `aviso-renovacion` report; `@@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**.
|
||||||
|
**Three things came out differently from the spec.** (a) The manual mark-as-sent mutation was **dropped on purpose** — a button that marks a notice sent without sending anything lets the list claim a customer was told when they were not. `POST /renewals/send` replaced it: sending from the list *is* the marking, and the report's `enviadas` total becomes real the same way. (b) The send history is **not renewal-specific** — every attempt, including the failures and no-email skips a `RenewalNotice` row cannot represent, also writes `email_notification_log` as `RENEWAL_NOTICE`/`POLICIES`, shared with the four bulk jobs from [`docs/MASS_EMAIL_NOTIFICATIONS.md`](docs/MASS_EMAIL_NOTIFICATIONS.md). `RenewalNotice` stays *gating* state; the log is *history*. (c) The `@Cron("0 6 * * *")` literal the spec called for lasted one day: both this sweep and the servicios jobs now take their cadence from `NotificationScheduleService`, stored in `app_settings` and reinstalled on save — no redeploy. Defaults preserve the old behaviour (pólizas 06:00 daily, servicios off).
|
||||||
|
**Both halves live on one screen.** `/notificaciones` has Servicios and Pólizas tabs over the one log; `/renovaciones` is an alias onto the Pólizas tab. The send flags (`debug` in particular) sit in the shell above the tabs and govern both — before that there was no way to test a renewal aviso without mailing a real customer. A debug send diverts the mail, skips the `RenewalNotice` upsert **and** does not advance the sweep's `lastSuccessfulAt`; all three are needed together, or a test run silently narrows tomorrow's window and drops the letters it only pretended to send.
|
||||||
|
**Production status:** the `SES_*` Gitea secrets were created 2026-08-02, clearing the last blocker — but the feature has not shipped yet (master is well past the newest tag) and nothing has confirmed that `SES_FROM` is a verified SES identity or that the account is out of the sandbox. Run the first sweep with `debug` on. See [`docs/BACKLOG.md`](docs/BACKLOG.md) §0.
|
||||||
- **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.
|
- **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.
|
- **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.)
|
- **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.)
|
||||||
@@ -157,9 +172,15 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
|
|
||||||
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
|
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
|
||||||
|
|
||||||
**Step 11 spec written, not built.** `docs/RECEIPT_CAPTURE_SPEC.md` covers the receipt-capture ("Editor") completion plus the three net-new ops features (OCR auto-capture, multi-bank chequera, customer-number recycling) — see Build sequencing step 11 above for the summary. Written from the 2026-07-25/26 meeting notes and verified against the real migration scripts and current API code, not just designed from the meeting notes alone.
|
**Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown.
|
||||||
|
|
||||||
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
|
Each of the two OCR intakes now has an as-built doc separate from its spec — `docs/STATEMENT_OCR.md` and `docs/POLICY_OCR.md`. The specs record what was designed and why; those record what is in the code. They share one `OcrProvider` seam (`apps/api/src/ocr/`), so the Tesseract-vs-managed-API decision is one line for both.
|
||||||
|
|
||||||
|
**It also produced a feature nobody planned.** The statement OCR pipeline generalised: the same render→OCR→parse→match→review shape reads **carrier policy PDFs** into `Policy` rows, which is `docs/POLICY_OCR.md` (built 2026-08-01, GMX only so far). It belongs to step 12's subject matter but to step 11's lineage, and it is in no spec — worth knowing before reading `INSURANCE_FEATURES_SPEC.md`, which does not mention it. It also partly overlaps what §4's carrier API was wanted for, and unlike that section it is not blocked on a phone call.
|
||||||
|
|
||||||
|
**Step 12 is one-quarter 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. **§1 renewal emails is done** (2026-08-01/02) and carries a BUILT note recording the three places the build diverged from the spec; §2 liquidación is still the smallest remaining piece, since the per-policy fields are already wired end to end.
|
||||||
|
|
||||||
|
**Notifications are one screen, not two features.** The four legacy mass-email jobs (`docs/MASS_EMAIL_NOTIFICATIONS.md`) and the insurance renewal avisos both mean "tell a customer something by email", so they are tabs of `/notificaciones` over one `email_notification_log`, with one shared flags panel and one schedule editor. `app_settings` + `SettingsService` (db → env → default) is the operator-config seam they introduced: summary recipients and both sweep cadences live there, so changing any of them is a save, not a redeploy. Credentials stay in the environment.
|
||||||
|
|
||||||
## Decisions (locked)
|
## Decisions (locked)
|
||||||
|
|
||||||
@@ -183,8 +204,9 @@ Unlike the ops items above, these block design decisions, not just infrastructur
|
|||||||
|
|
||||||
**Step 11 — utilities/ops side:**
|
**Step 11 — utilities/ops side:**
|
||||||
|
|
||||||
- OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider).
|
- ~~OCR provider/budget~~ — **CLOSED**: self-hosted Tesseract, chosen on measured accuracy against real scans, so there is no per-page cost to approve.
|
||||||
- Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill.
|
- ~~Whether `PROPERTY_TAX.accountNumber` (from `DATMEX.PREDIAL`) is the same number as "Clave Catastral" (`DATMEX.CLAVE`)~~ — **CLOSED**: they are different numbers. Answered from real CESPT bills plus the staged data; the clave is now migrated separately and predial was left alone.
|
||||||
|
- 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 actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. (Multi-bank support itself is **built** — this is now only the missing content: staff can open the account in `/banco/cuentas` the moment the answer arrives, and it starts empty unless a historical register turns up.)
|
||||||
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
||||||
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ Internal platform for a Baja California insurance brokerage and property-service
|
|||||||
firm: a single expedient joining each client's **properties/services**,
|
firm: a single expedient joining each client's **properties/services**,
|
||||||
**insurance policies**, **account statement**, and the firm's **checkbook**.
|
**insurance policies**, **account statement**, and the firm's **checkbook**.
|
||||||
It replaces a legacy PHP/Access app (see `RESUME.md` and `PLAN.md` for the full
|
It replaces a legacy PHP/Access app (see `RESUME.md` and `PLAN.md` for the full
|
||||||
history and rebuild rationale).
|
history and rebuild rationale, and [`docs/BACKLOG.md`](docs/BACKLOG.md) for
|
||||||
|
everything still outstanding).
|
||||||
|
|
||||||
The UI is Spanish-first; the codebase and this document are in English.
|
The UI is Spanish-first; the codebase and this document are in English.
|
||||||
|
|
||||||
@@ -40,8 +41,21 @@ docker-compose.yml mysql + api + web
|
|||||||
```
|
```
|
||||||
|
|
||||||
API feature modules: `auth`, `users`, `customers`, `policies`, `properties`,
|
API feature modules: `auth`, `users`, `customers`, `policies`, `properties`,
|
||||||
`billing`, `bank`. Web routes: `/clientes`, `/polizas`, `/servicios`,
|
`billing`, `bank`, `reports`, `notifications`, `renewals`, `mail`, `statements`,
|
||||||
`/estado-cuenta`, `/banco` (chequera), `/catalogos`, `/usuarios`, `/login`.
|
`policy-ocr`, `ocr`, `storage`, `settings`, `ops`.
|
||||||
|
|
||||||
|
Web routes: `/inicio`, `/clientes`, `/polizas` (+ `/polizas/captura`, policy
|
||||||
|
PDF OCR capture), `/servicios`, `/estado-cuenta`, `/banco` (chequera),
|
||||||
|
`/recibos` (utility statement OCR capture), `/notificaciones` (mass email +
|
||||||
|
renewal avisos; `/renovaciones` is an alias onto its Pólizas tab), `/reportes`,
|
||||||
|
`/catalogos`, `/operaciones` (DB ingest/backup, ADMIN), `/usuarios`, `/login`.
|
||||||
|
|
||||||
|
Two OCR intakes share one `OcrProvider` seam (`src/ocr/`, Tesseract today):
|
||||||
|
utility statements → ledger rows ([`docs/STATEMENT_OCR.md`](docs/STATEMENT_OCR.md))
|
||||||
|
and carrier policy PDFs → `Policy` rows ([`docs/POLICY_OCR.md`](docs/POLICY_OCR.md)).
|
||||||
|
Both need `tesseract-ocr`, `tesseract-ocr-data-spa`, `poppler-utils` and object
|
||||||
|
storage; each reports its own availability and disables only itself if either
|
||||||
|
is missing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,7 +96,14 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
|||||||
```
|
```
|
||||||
|
|
||||||
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
||||||
`PORT` (default `3001`). The web app only needs `NEXT_PUBLIC_API_ORIGIN`.
|
`PORT` (default `3001`). `WEB_ORIGIN` is comma-separated — list every origin the
|
||||||
|
app is reached under, or credentialed fetches from the missing ones fail CORS.
|
||||||
|
|
||||||
|
The web app needs no API URL of its own: the browser derives it from the page it
|
||||||
|
loaded (same host on port `3001` over plain HTTP, or the same-origin `/api` path
|
||||||
|
behind a TLS proxy). Set `NEXT_PUBLIC_API_ORIGIN` (dev) or `API_ORIGIN` (deploy,
|
||||||
|
read at request time) only to override that — for instance when running the API
|
||||||
|
on a non-default port.
|
||||||
|
|
||||||
### 3. Start MySQL
|
### 3. Start MySQL
|
||||||
|
|
||||||
@@ -195,6 +216,28 @@ python migration/run_all.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Scheduled jobs
|
||||||
|
|
||||||
|
The API runs two automatic email sweeps. Neither cadence is in the source:
|
||||||
|
both are stored in `app_settings` and edited at `/notificaciones` →
|
||||||
|
"Programación de envíos" (ADMIN, `setting:manage`), taking effect immediately
|
||||||
|
without a restart. Shipped defaults:
|
||||||
|
|
||||||
|
| Job | Default | What it does |
|
||||||
|
| --- | ------- | ------------ |
|
||||||
|
| Pólizas | **on**, 06:00 daily (America/Tijuana) | Renewal avisos at 30/15 days before expiry and 7 days after. |
|
||||||
|
| Servicios | **off** | All four mass-email jobs in order, same as "Ejecutar todos". |
|
||||||
|
|
||||||
|
A scheduled run never uses the UI's send flags — in particular it ignores
|
||||||
|
`debug`, so a forgotten test toggle cannot silently stop customer mail. Full
|
||||||
|
detail in [`docs/MASS_EMAIL_NOTIFICATIONS.md`](docs/MASS_EMAIL_NOTIFICATIONS.md).
|
||||||
|
|
||||||
|
Sending needs `SES_*` in the environment. Without it the API still boots and
|
||||||
|
logs mail to stdout in dev; in production every send fails loudly and is
|
||||||
|
recorded as `FAILED` rather than quietly going nowhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Production notes
|
## Production notes
|
||||||
|
|
||||||
- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when
|
- Use `pnpm --filter @jorgecuadros/database exec prisma migrate deploy` if/when
|
||||||
|
|||||||
@@ -442,3 +442,256 @@ for what's actually next.
|
|||||||
verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals,
|
verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals,
|
||||||
clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail
|
clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail
|
||||||
mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.
|
mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statement OCR intake (`/recibos`) — DONE 2026-08-01
|
||||||
|
|
||||||
|
> As-built reference: **`docs/STATEMENT_OCR.md`** (written 2026-08-02) — the
|
||||||
|
> parsers, the matcher's scoped-field rules, confirm/learning semantics and the
|
||||||
|
> API surface. `docs/RECEIPT_CAPTURE_SPEC.md` §2 stays the design and the
|
||||||
|
> measured evidence. This section is the session record of building it.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Policy OCR capture (`/polizas/captura`) — DONE 2026-08-01, unplanned
|
||||||
|
|
||||||
|
**This feature was not in any spec.** It is what the statement OCR work above
|
||||||
|
turned into once the pipeline existed. Having built render → OCR → parse →
|
||||||
|
match → review for CFE/CESPT/Telnor receipts, the same shape obviously fits
|
||||||
|
the *other* stack of paper this office keys in by hand every week: the carrier
|
||||||
|
policy PDFs behind every `Policy` row. Full write-up in `docs/POLICY_OCR.md`.
|
||||||
|
|
||||||
|
The pipeline was reused rather than copied. `OcrModule` was **extracted out of
|
||||||
|
`StatementsModule`** in the same commit so `PolicyOcrModule` could inject
|
||||||
|
`OCR_PROVIDER` without taking on the statement pipeline — that extraction was
|
||||||
|
blocking, not tidying; the policy module could not resolve the provider at all
|
||||||
|
until it existed. `StatementsModule` imports it now and binds nothing itself,
|
||||||
|
so the Tesseract-vs-managed-API decision stays one line in one file for both
|
||||||
|
features.
|
||||||
|
|
||||||
|
Screens mirror Captura exactly: `/polizas/nuevo` is the manual tab,
|
||||||
|
`/polizas/captura` the automática one, both rendering `PolicyCaptura.tsx`, with
|
||||||
|
the batch review queue at `/polizas/captura/[id]`. Abilities `policy:ingest` /
|
||||||
|
`policy:ocr-review`, both STAFF — same trust tier as statement OCR, and for the
|
||||||
|
same reason: nothing reaches the books unconfirmed.
|
||||||
|
|
||||||
|
**The statement pipeline's central assumption inverts here, and that is the
|
||||||
|
thing to remember.** Utility statements arrive bundled *one customer per page*,
|
||||||
|
so there a page is a document and the parser runs per page. A policy PDF is the
|
||||||
|
opposite: the GMX certificate is one policy spread across two pages (contract
|
||||||
|
header on page 1, the per-coverage table on page 2). So every page's text is
|
||||||
|
concatenated and the parser and matcher run **once per file**. Consequences:
|
||||||
|
`PolicyOcrDocument.pageNumber` is repurposed as the file ordinal within the
|
||||||
|
batch (the `(batchId, pageNumber)` unique constraint still holds), `ocrConfidence`
|
||||||
|
is the mean across the file's pages, and a file that fails to parse yields
|
||||||
|
exactly one `OCR_FAILED` row.
|
||||||
|
|
||||||
|
`storageKey` points at the **source PDF**, not a rendered page image, so the
|
||||||
|
review screen embeds the exact artifact the office received and gets the
|
||||||
|
browser's native PDF scrolling, zoom and text selection for free. The page PNGs
|
||||||
|
are still written for future re-OCR, but nothing treats them as the document's
|
||||||
|
identity. (The statement side is the reverse, because there a page *is* the
|
||||||
|
document.)
|
||||||
|
|
||||||
|
Findings worth keeping:
|
||||||
|
|
||||||
|
- **The GMX certificate has no premium on it at all.** Not intermittently
|
||||||
|
missing — the figure lives on GMX's separate `recibo` PDF. The parser leaves
|
||||||
|
the premium fields null and pushes a note saying so, confirm never overwrites
|
||||||
|
an existing `Policy.netPremium` with null, and the optional ledger write is
|
||||||
|
gated on staff ticking `postPremium` *and* a premium actually parsing.
|
||||||
|
Without that second gate a premium-less certificate would book a $0 charge on
|
||||||
|
every confirm.
|
||||||
|
- **Match on `Policy.policyNumber`, never the printed insured name.** Same
|
||||||
|
registrant-vs-current-owner drift that rules names out on the utility side.
|
||||||
|
Zero hits means a new policy and confirm creates the row under a picked
|
||||||
|
customer; more than one hit is surfaced for a human, never auto-picked —
|
||||||
|
duplicate numbers across related parties do occur.
|
||||||
|
- Deductible and loss participation are stored as **strings** (`"5%"`,
|
||||||
|
`"USD 1,000"`): they are printed as a mix of percentages, amounts and free
|
||||||
|
text, and normalising them would lose the distinction.
|
||||||
|
- Carrier-portal PDFs are usually **born-digital**, so the text layer wins and
|
||||||
|
no OCR runs at all most of the time — same precedence rule as the statement
|
||||||
|
pipeline.
|
||||||
|
- The digit-confusion map and the amount-by-separator-position parser are
|
||||||
|
**duplicated on purpose** rather than imported, to keep the module
|
||||||
|
self-contained. Fix a bug in one, check the other.
|
||||||
|
|
||||||
|
8/8 parser tests, all against verbatim text from one real document
|
||||||
|
(`HC_Folio_000767_Traduccion.pdf`).
|
||||||
|
|
||||||
|
**Open:** GMX is the only carrier implemented — the dispatcher is a
|
||||||
|
`[provider, pattern]` table plus a parser map, so a second carrier is a
|
||||||
|
function and two entries, but no other layout has been seen. Reading the
|
||||||
|
premium off the separate `recibo` PDF and pairing it to its certificate is the
|
||||||
|
obvious next piece; it is what would let `postPremium` stop being a manual
|
||||||
|
tick. And nothing versions a re-issued policy — confirm updates the existing
|
||||||
|
row, so there is no record that this is the 2027 issue of that number.
|
||||||
|
|
||||||
|
|
||||||
|
## Notificaciones (`/notificaciones`) — DONE 2026-08-01 → 08-02
|
||||||
|
|
||||||
|
Two features that were spec'd separately turned out to be one screen. The four
|
||||||
|
legacy mass-email jobs (`docs/MASS_EMAIL_NOTIFICATIONS.md`, ported from
|
||||||
|
`email.notifications/send*.php`) and the insurance renewal avisos
|
||||||
|
(`docs/INSURANCE_FEATURES_SPEC.md` §1) both mean *tell a customer something by
|
||||||
|
email*, so they are **tabs of one screen over one log**, not two menu entries.
|
||||||
|
`/renovaciones` is an alias that lands on the Pólizas tab, the same pattern
|
||||||
|
Captura uses.
|
||||||
|
|
||||||
|
- **Servicios tab** — the four jobs (pagos pendientes, confirmación de pago,
|
||||||
|
estado de cuenta, fideicomiso), individually or "Ejecutar todos". Ability
|
||||||
|
`notification:send` (MANAGER); STAFF sees the log read-only.
|
||||||
|
- **Pólizas tab** — pending avisos at 30/15 days before expiry and 7 days
|
||||||
|
after, sent one at a time or as a sweep. Ability `renewal:send` (MANAGER).
|
||||||
|
|
||||||
|
**One send log for the whole platform.** `email_notification_log` is not
|
||||||
|
job-specific: renewals write it too (`RENEWAL_NOTICE` / `POLICIES`) through the
|
||||||
|
same `NotificationLogService`. That is what makes "Registro de envíos" complete
|
||||||
|
— the failures and no-email skips exist *only* there. `RenewalNotice` was not
|
||||||
|
made redundant by it: that row is **gating** state (one per policy+generation,
|
||||||
|
drives the pending list), the log is **history** (every attempt). `level` is
|
||||||
|
therefore per-type and unreadable without its `notificationType` — 0/1
|
||||||
|
yellow/red on `ACCOUNT_STATUS`, the aviso generation 1/2/3 on
|
||||||
|
`RENEWAL_NOTICE`.
|
||||||
|
|
||||||
|
**Manual mark-as-sent was dropped on purpose.** The spec called for it; a
|
||||||
|
button that marks a notice sent without sending anything is a button that lets
|
||||||
|
the list claim a customer was told when they were not. `POST /renewals/send`
|
||||||
|
replaced it — sending from the list *is* the marking.
|
||||||
|
|
||||||
|
**`app_settings` is the operator-config seam this work introduced.**
|
||||||
|
`SettingsService` resolves every key **db → env → default** and reports which
|
||||||
|
rung a value came from, so an existing deployment keeps behaving exactly as it
|
||||||
|
did until somebody saves in the UI. Three keys today: the summary recipients
|
||||||
|
(was `NOTIFICATION_ADMIN_EMAILS`, now a fallback) and the two sweep cadences.
|
||||||
|
Credentials deliberately stay in the environment — SES keys, `DATABASE_URL`
|
||||||
|
and S3 config are deployment identity, must exist before the app can reach its
|
||||||
|
own database, and a table only widens who can read them.
|
||||||
|
|
||||||
|
**The send flags are global, and that was a real bug fix (08-02).** The
|
||||||
|
`debug` / `ignoreDayRestriction` / `useEmailLimit` panel lived inside the
|
||||||
|
Servicios tab, so there was **no way to test a renewal aviso without mailing a
|
||||||
|
real customer**. It now lives in the shell above the tabs and both halves read
|
||||||
|
it. On the pólizas path `debug` does three things, and all three are required
|
||||||
|
together: it diverts the mail, it skips the `RenewalNotice` upsert, and it does
|
||||||
|
not advance the sweep's `lastSuccessfulAt`. Miss the third and `renewalWindow()`
|
||||||
|
narrows back to a single day on the next real run — a test send would silently
|
||||||
|
destroy the letters it only pretended to send. Flags are per-visit UI state and
|
||||||
|
are **never persisted**; a stored `debug` would survive a reload and swallow
|
||||||
|
real customer mail until somebody noticed.
|
||||||
|
|
||||||
|
**Both cadences are operator-editable (08-02).** The renewal sweep's
|
||||||
|
`@Cron("0 6 * * *")` literal lasted one day. `NotificationScheduleService` now
|
||||||
|
owns both: the owning services register a handler in `onModuleInit`, the
|
||||||
|
service compiles the stored `{hour, minute, weekdays}` to a cron expression and
|
||||||
|
installs it in `SchedulerRegistry`, and saving from the UI reinstalls the job —
|
||||||
|
no restart, which was the point. It lives in its own module for the same reason
|
||||||
|
as `NotificationLogModule`: `NotificationsModule` and `RenewalsModule` both need
|
||||||
|
it and neither may import the other. Defaults preserve prior behaviour exactly
|
||||||
|
(pólizas 06:00 daily, servicios **off** — a default that starts mailing 260
|
||||||
|
customers after a deploy is not a default, it's an incident). A scheduled run
|
||||||
|
never inherits the UI flags: no `debug`, and no `ignoreDayRestriction`, since an
|
||||||
|
automatic run on the operator's own cadence is precisely the case the
|
||||||
|
Mon/Wed/Fri gate was written for.
|
||||||
|
|
||||||
|
Implementation notes worth keeping:
|
||||||
|
|
||||||
|
- `cron` had to become a **direct dependency of `apps/api`**. It is a
|
||||||
|
transitive dep of `@nestjs/schedule`, but pnpm's strict layout does not hoist
|
||||||
|
it, so `import { CronJob } from "cron"` does not resolve without it.
|
||||||
|
- The pólizas sweep already had a DB lock (`scheduled_job_states`); the
|
||||||
|
servicios run-all does not, and relies on the deployment being
|
||||||
|
single-replica, which it is on galactus today.
|
||||||
|
- Wire shapes of the four jobs are byte-for-byte the legacy PHP responses,
|
||||||
|
quirks included (Job 1 reports `result`, not `request`).
|
||||||
|
|
||||||
|
**Open:** the `SES_*` Gitea secrets were created 2026-08-02, so the feature is
|
||||||
|
no longer blocked — but it has not shipped (master is well past the newest tag)
|
||||||
|
and two things nobody has checked decide whether mail leaves the building:
|
||||||
|
`SES_FROM` must be a verified identity in `SES_REGION`, and the AWS account
|
||||||
|
must be out of the SES sandbox, which otherwise restricts delivery to verified
|
||||||
|
recipients and would fail a real sweep while looking correctly configured. Run
|
||||||
|
the first sweep with `debug` on. Still open beyond that: the 78 policyholders
|
||||||
|
with no email are logged as `SKIPPED_NO_EMAIL` but have no printable worklist,
|
||||||
|
and the notice body is English-only (`Customer` carries no language
|
||||||
|
preference).
|
||||||
|
|||||||
@@ -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",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"tsConfigPath": "tsconfig.build.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.1",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -12,20 +12,23 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.665.0",
|
"@aws-sdk/client-s3": "^3.665.0",
|
||||||
|
"@aws-sdk/client-sesv2": "^3.1101.0",
|
||||||
"@jorgecuadros/database": "workspace:*",
|
"@jorgecuadros/database": "workspace:*",
|
||||||
"@nestjs/common": "^10.4.4",
|
"@nestjs/common": "^10.4.4",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
"@nestjs/core": "^10.4.4",
|
"@nestjs/core": "^10.4.4",
|
||||||
"@nestjs/passport": "^10.0.3",
|
"@nestjs/passport": "^10.0.3",
|
||||||
"@nestjs/platform-express": "^10.4.4",
|
"@nestjs/platform-express": "^10.4.4",
|
||||||
|
"@nestjs/schedule": "^4.1.2",
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"cron": "^3.2.1",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express-session": "^1.18.0",
|
"express-session": "^1.18.0",
|
||||||
"pdfkit": "^0.15.1",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
|
"pdfkit": "^0.15.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -34,11 +37,11 @@
|
|||||||
"@nestjs/testing": "^10.4.4",
|
"@nestjs/testing": "^10.4.4",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/express-session": "^1.18.0",
|
"@types/express-session": "^1.18.0",
|
||||||
"@types/pdfkit": "^0.13.5",
|
|
||||||
"@types/jest": "^29.5.13",
|
"@types/jest": "^29.5.13",
|
||||||
"@types/node": "^20.16.11",
|
"@types/node": "^20.16.11",
|
||||||
"@types/passport": "^1.0.17",
|
"@types/passport": "^1.0.17",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@types/pdfkit": "^0.13.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
|||||||
@@ -1,34 +1,46 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { StorageModule } from "./storage/storage.module";
|
import { StorageModule } from "./storage/storage.module";
|
||||||
import { CommonModule } from "./common/common.module";
|
import { CommonModule } from "./common/common.module";
|
||||||
|
import { MailModule } from "./mail/mail.module";
|
||||||
import { UsersModule } from "./users/users.module";
|
import { UsersModule } from "./users/users.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { CustomersModule } from "./customers/customers.module";
|
import { CustomersModule } from "./customers/customers.module";
|
||||||
import { PoliciesModule } from "./policies/policies.module";
|
import { PoliciesModule } from "./policies/policies.module";
|
||||||
import { PropertiesModule } from "./properties/properties.module";
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
|
import { StatementsModule } from "./statements/statements.module";
|
||||||
|
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
import { OpsModule } from "./ops/ops.module";
|
import { OpsModule } from "./ops/ops.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
|
import { RenewalsModule } from "./renewals/renewals.module";
|
||||||
|
import { NotificationsModule } from "./notifications/notifications.module";
|
||||||
import { AppController } from "./app.controller";
|
import { AppController } from "./app.controller";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
MailModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
PoliciesModule,
|
PoliciesModule,
|
||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
StatementsModule,
|
||||||
|
PolicyOcrModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
OpsModule,
|
OpsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
RenewalsModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ export type Ability =
|
|||||||
| "customer:create"
|
| "customer:create"
|
||||||
| "customer:update"
|
| "customer:update"
|
||||||
| "customer:delete"
|
| "customer:delete"
|
||||||
|
| "customer:portal-access"
|
||||||
| "policy:create"
|
| "policy:create"
|
||||||
| "policy:update"
|
| "policy:update"
|
||||||
| "policy:delete"
|
| "policy:delete"
|
||||||
|
| "policy:ingest"
|
||||||
|
| "policy:ocr-review"
|
||||||
|
| "renewal:send"
|
||||||
| "property:create"
|
| "property:create"
|
||||||
| "property:update"
|
| "property:update"
|
||||||
| "property:delete"
|
| "property:delete"
|
||||||
@@ -32,18 +36,33 @@ export type Ability =
|
|||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
| "bank:manage-accounts"
|
| "bank:manage-accounts"
|
||||||
|
| "statement:ingest"
|
||||||
|
| "statement:review"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage"
|
||||||
|
| "notification:send"
|
||||||
|
| "setting:manage";
|
||||||
|
|
||||||
/** Minimum role required for each ability. */
|
/** Minimum role required for each ability. */
|
||||||
export const ABILITY_MIN: Record<Ability, Role> = {
|
export const ABILITY_MIN: Record<Ability, Role> = {
|
||||||
"customer:create": "STAFF",
|
"customer:create": "STAFF",
|
||||||
"customer:update": "STAFF",
|
"customer:update": "STAFF",
|
||||||
"customer:delete": "ADMIN",
|
"customer:delete": "ADMIN",
|
||||||
|
// Assigning a portal NUMid is granting someone the ability to log in to
|
||||||
|
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
||||||
|
// editing a phone number is the day job, handing out portal identity is not.
|
||||||
|
// It is also close to irreversible in practice — the id is what the customer
|
||||||
|
// then types at every login.
|
||||||
|
"customer:portal-access": "MANAGER",
|
||||||
"policy:create": "STAFF",
|
"policy:create": "STAFF",
|
||||||
"policy:update": "STAFF",
|
"policy:update": "STAFF",
|
||||||
"policy:delete": "MANAGER",
|
"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",
|
||||||
|
"renewal:send": "MANAGER",
|
||||||
"property:create": "STAFF",
|
"property:create": "STAFF",
|
||||||
"property:update": "STAFF",
|
"property:update": "STAFF",
|
||||||
"property:delete": "MANAGER",
|
"property:delete": "MANAGER",
|
||||||
@@ -54,9 +73,24 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
// 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.
|
// movement into one — a wrong account silently mixes two sets of books.
|
||||||
"bank:manage-accounts": "MANAGER",
|
"bank:manage-accounts": "MANAGER",
|
||||||
|
// Uploading a stack of scans and reviewing what the OCR read are both
|
||||||
|
// "capturing a receipt" — the same trust tier as ledger:create, since
|
||||||
|
// confirming a statement *is* capturing it. The review step is what makes
|
||||||
|
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
|
||||||
|
"statement:ingest": "STAFF",
|
||||||
|
"statement:review": "STAFF",
|
||||||
"lookup:manage": "MANAGER",
|
"lookup:manage": "MANAGER",
|
||||||
"user:manage": "ADMIN",
|
"user:manage": "ADMIN",
|
||||||
"db:manage": "ADMIN",
|
"db:manage": "ADMIN",
|
||||||
|
// Mass email notifications — fires mail to customers on the office's
|
||||||
|
// behalf, with no per-row review. Same trust tier as `renewal:send`:
|
||||||
|
// a STAFF user typing one customer receipt is fine; a STAFF user firing
|
||||||
|
// 260 mail merges on the customer base is not.
|
||||||
|
"notification:send": "MANAGER",
|
||||||
|
// Editing operator configuration. Above `notification:send` on purpose:
|
||||||
|
// firing a sweep is the day job, but changing WHERE the audit summaries
|
||||||
|
// land is how someone would quietly stop them being read.
|
||||||
|
"setting:manage": "ADMIN",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import {
|
||||||
|
BALANCE_FLOOR_JOIN,
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
BillingService,
|
||||||
|
NOT_SUPERSEDED,
|
||||||
|
} from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The balance floor drops rows a later BALANCE FORWARD already accounts for.
|
||||||
|
*
|
||||||
|
* It is worth testing because it fails silently: nothing throws, the numbers are
|
||||||
|
* just wrong, and they were wrong for years — the whole book read +20.6M MXN in
|
||||||
|
* credit because every customer's pre-cutover history was counted twice, once
|
||||||
|
* inside their opening balance and once as itself.
|
||||||
|
*/
|
||||||
|
describe("balance floor", () => {
|
||||||
|
describe("SQL fragments", () => {
|
||||||
|
it("binds the type name rather than interpolating it", () => {
|
||||||
|
// A literal would be a second place to edit if the label ever changes,
|
||||||
|
// and this string reaches SQL from a module constant.
|
||||||
|
expect(BALANCE_FLOOR_JOIN.values).toEqual([BALANCE_FORWARD_TYPE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys the floor to the row's own customer", () => {
|
||||||
|
// Without this the derived table cross-joins and every customer inherits
|
||||||
|
// the earliest BALANCE FORWARD in the book.
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain(
|
||||||
|
"bfloor ON bfloor.customerId = t.customerId",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the most recent opening balance, not the first", () => {
|
||||||
|
// A customer accumulates one BALANCE FORWARD per year. MIN would floor at
|
||||||
|
// the oldest and leave every intervening year double-counted.
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain("MAX(bf.transactionDate)");
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).not.toContain("MIN(bf.transactionDate)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores voided opening balances when locating the floor", () => {
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain("bf.voidedAt IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is inclusive of the opening balance row itself", () => {
|
||||||
|
// `>` instead of `>=` would drop the carried balance and understate every
|
||||||
|
// customer by exactly that amount.
|
||||||
|
expect(NOT_SUPERSEDED.sql).toContain("t.transactionDate >= bfloor.floorDate");
|
||||||
|
expect(NOT_SUPERSEDED.sql).not.toMatch(/transactionDate\s*>\s*bfloor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves customers with no opening balance untouched", () => {
|
||||||
|
// NULL comparisons are never true, so without the explicit IS NULL branch
|
||||||
|
// a customer who has no BALANCE FORWARD row loses their entire ledger.
|
||||||
|
expect(NOT_SUPERSEDED.sql).toContain("bfloor.floorDate IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only ever references the alias the join defines", () => {
|
||||||
|
// The predicate is useless without the join; pairing them wrongly is a
|
||||||
|
// runtime "unknown column", so keep the alias identical in both.
|
||||||
|
const aliases = NOT_SUPERSEDED.sql.match(/bfloor\.\w+/g) ?? [];
|
||||||
|
expect(aliases.length).toBeGreaterThan(0);
|
||||||
|
for (const ref of aliases) {
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain(ref.split(".")[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("statement()", () => {
|
||||||
|
/**
|
||||||
|
* One customer means one floor date, so the statement uses a scalar lookup
|
||||||
|
* instead of the join. Asserting on the `where` Prisma is handed is the only
|
||||||
|
* way to see it without a database.
|
||||||
|
*/
|
||||||
|
function serviceWith(floor: Date | null) {
|
||||||
|
const findMany = jest.fn().mockResolvedValue([]);
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: "c1",
|
||||||
|
name: "CUADROS, JORGE H.",
|
||||||
|
preferredCurrency: "USD",
|
||||||
|
_count: { properties: 0, policies: 0 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new BillingService(prisma as never),
|
||||||
|
prisma,
|
||||||
|
findMany,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("looks the floor up from the customer's newest opening balance", async () => {
|
||||||
|
const { service, prisma } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
customerId: "c1",
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* statement() issues two findMany calls: first the period discovery (which
|
||||||
|
* years this customer has an archive for), then the statement rows. Select
|
||||||
|
* the rows query by its shape so adding another lookup later moves nothing
|
||||||
|
* here — the previous version indexed call 0 and broke the moment period
|
||||||
|
* support landed.
|
||||||
|
*/
|
||||||
|
function rowsQuery(findMany: jest.Mock) {
|
||||||
|
const call = findMany.mock.calls.find((c) => c[0]?.orderBy);
|
||||||
|
if (!call) throw new Error("statement() issued no ordered rows query");
|
||||||
|
return call[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
it("bounds the statement at the floor, inclusive", async () => {
|
||||||
|
const floor = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const { service, findMany } = serviceWith(floor);
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
expect(rowsQuery(findMany).where).toMatchObject({
|
||||||
|
customerId: "c1",
|
||||||
|
transactionDate: { gte: floor },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies no date bound when the customer has no opening balance", async () => {
|
||||||
|
const { service, findMany } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||||
|
// The three guards answer different questions — one windows imported
|
||||||
|
// periods, one drops the cash receipt book the ledger already posts, the
|
||||||
|
// floor drops superseded history — and dropping any one of them changes
|
||||||
|
// the customer's balance.
|
||||||
|
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
const where = rowsQuery(findMany).where;
|
||||||
|
// Imported periods are windowed rather than excluded: history below the
|
||||||
|
// year start (the only carry a floored-by-archive customer has), never
|
||||||
|
// at or above it (those rows sit inside the next BALANCE FORWARD).
|
||||||
|
expect(where.AND).toEqual([
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash journal, qualified by the database it came from — the
|
||||||
|
// insurance line has its own EFECTIVO and that one is a real ledger.
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: "UTILITIES" } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
|
legacySourceTable: {
|
||||||
|
notIn: expect.arrayContaining(["EFECTIVO"]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("regression: NUMid 501", () => {
|
||||||
|
/**
|
||||||
|
* The arithmetic that exposed the bug, pinned so it cannot silently return.
|
||||||
|
* Figures measured against the live ledger on 2026-08-05.
|
||||||
|
*/
|
||||||
|
const openingBalance = new Prisma.Decimal("-6732.29");
|
||||||
|
const activitySinceOpening = new Prisma.Decimal("-7333.00");
|
||||||
|
const preCutoverCashAlreadyInOpening = new Prisma.Decimal("3596.00");
|
||||||
|
|
||||||
|
it("matches the legacy portal once superseded rows are dropped", () => {
|
||||||
|
expect(openingBalance.plus(activitySinceOpening).toFixed(2)).toBe(
|
||||||
|
"-14065.29",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the wrong figure when they are not", () => {
|
||||||
|
expect(
|
||||||
|
openingBalance
|
||||||
|
.plus(activitySinceOpening)
|
||||||
|
.plus(preCutoverCashAlreadyInOpening)
|
||||||
|
.toFixed(2),
|
||||||
|
).toBe("-10469.29");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -119,10 +119,23 @@ export class BillingController {
|
|||||||
return this.billing.byCheck(n);
|
return this.billing.byCheck(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One customer's full statement across both business lines. */
|
/**
|
||||||
|
* One customer's statement across both business lines, for one period.
|
||||||
|
*
|
||||||
|
* `year` omitted means the current one. Any earlier year is served from its
|
||||||
|
* imported archive; the response carries `availableYears` so the caller can
|
||||||
|
* offer only the periods this customer actually has.
|
||||||
|
*/
|
||||||
@Get("customers/:id")
|
@Get("customers/:id")
|
||||||
statement(@Param("id") id: string) {
|
statement(@Param("id") id: string, @Query("year") year?: string) {
|
||||||
return this.billing.statement(id);
|
let parsed: number | undefined;
|
||||||
|
if (year !== undefined && year !== "") {
|
||||||
|
parsed = Number(year);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
throw new BadRequestException("year debe ser un año de cuatro dígitos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.billing.statement(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cross-customer movement browser. */
|
/** Cross-customer movement browser. */
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ import { BillingService } from "./billing.service";
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [BillingService],
|
providers: [BillingService],
|
||||||
|
// The statements module posts confirmed OCR captures through
|
||||||
|
// BillingService.createBatch rather than writing Transaction rows itself.
|
||||||
|
exports: [BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
@@ -104,24 +104,31 @@ interface BalanceRow {
|
|||||||
nameMissing: number;
|
nameMissing: number;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
movements: bigint | number | string;
|
movements: RawCount;
|
||||||
balanceMxn: Prisma.Decimal | null;
|
balanceMxn: Prisma.Decimal | null;
|
||||||
balanceUsd: Prisma.Decimal | null;
|
balanceUsd: Prisma.Decimal | null;
|
||||||
chargesMxn: Prisma.Decimal | null;
|
chargesMxn: Prisma.Decimal | null;
|
||||||
creditsMxn: Prisma.Decimal | null;
|
creditsMxn: Prisma.Decimal | null;
|
||||||
chargesUsd: Prisma.Decimal | null;
|
chargesUsd: Prisma.Decimal | null;
|
||||||
creditsUsd: Prisma.Decimal | null;
|
creditsUsd: Prisma.Decimal | null;
|
||||||
utilityMovements: bigint | number | string;
|
utilityMovements: RawCount;
|
||||||
insuranceMovements: bigint | number | string;
|
insuranceMovements: RawCount;
|
||||||
lastMovement: Date | null;
|
lastMovement: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw-query counts come back in three shapes depending on the aggregate:
|
* Every shape a raw-query count can arrive in. `COUNT(*)` is a bigint,
|
||||||
* `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers.
|
* `SUM(bool)` is a Prisma.Decimal, and plain numbers occur too — none of which
|
||||||
* Normalize all of them before they reach the client as JSON.
|
* survive JSON serialization the way the client expects.
|
||||||
*/
|
*/
|
||||||
function num(v: bigint | number | string | null | undefined): number {
|
type RawCount = bigint | number | string | Prisma.Decimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw-query count before it reaches the client as JSON. A bigint
|
||||||
|
* throws on JSON.stringify and a Decimal serializes to a *string*, so counts
|
||||||
|
* must not be passed through untouched.
|
||||||
|
*/
|
||||||
|
function num(v: RawCount | null | undefined): number {
|
||||||
if (v === null || v === undefined) return 0;
|
if (v === null || v === undefined) return 0;
|
||||||
return typeof v === "number" ? v : Number(v);
|
return typeof v === "number" ? v : Number(v);
|
||||||
}
|
}
|
||||||
@@ -153,18 +160,97 @@ const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
|||||||
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source tables excluded from the customer-facing statement.
|
* The legacy type name for a carried-forward opening balance.
|
||||||
*
|
*
|
||||||
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
|
* These rows are not movements. Access materialized one per customer per year,
|
||||||
* (`objects.json:1358`), so the customer's "current balance" never saw
|
* dated Jan 1, holding the closing balance of everything before it — that is
|
||||||
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
|
* what let the portal keep each year in its own table (`datosfreak` = current,
|
||||||
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
|
* `2025`, `2024`, ...) and still show a correct running balance from a single
|
||||||
* the statement must drop them to match the legacy number the customer has
|
* year's rows.
|
||||||
* been quoted for years. The staff-facing balances worklist and movement
|
|
||||||
* browser keep them — they're real money, just tracked separately
|
|
||||||
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
|
|
||||||
*/
|
*/
|
||||||
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
export const BALANCE_FORWARD_TYPE = "BALANCE FORWARD";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-customer date of the most recent BALANCE FORWARD row.
|
||||||
|
*
|
||||||
|
* Joined rather than correlated: one small derived table (1,170 rows) beats a
|
||||||
|
* subquery evaluated per ledger row.
|
||||||
|
*/
|
||||||
|
export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT bf.customerId, MAX(bf.transactionDate) AS floorDate
|
||||||
|
FROM transactions bf
|
||||||
|
JOIN type_transactions bft ON bft.id = bf.typeId
|
||||||
|
WHERE bft.nameEn = ${BALANCE_FORWARD_TYPE} AND bf.voidedAt IS NULL
|
||||||
|
GROUP BY bf.customerId
|
||||||
|
) bfloor ON bfloor.customerId = t.customerId`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excludes rows a later BALANCE FORWARD already accounts for.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS. The platform holds both the synthetic BALANCE FORWARD rows
|
||||||
|
* and the real pre-cutover history they summarize, so summing a customer's
|
||||||
|
* whole ledger counts that history twice — once inside the opening balance,
|
||||||
|
* once as itself. NUMid 501 read -10,469.29 on the worklist against -14,065.29
|
||||||
|
* on the customer's own statement and on the legacy portal, the gap being two
|
||||||
|
* cash receipts from 2009 and 2012 that the 2026 opening balance had already
|
||||||
|
* absorbed.
|
||||||
|
*
|
||||||
|
* The scale is what settles it: summed the old way the entire book came to
|
||||||
|
* +20,605,447.86 MXN — the office owing its customers 20.6 million pesos.
|
||||||
|
* Floored, it is -56,855.90, a modest net receivable. A receivables ledger
|
||||||
|
* cannot be 20M in credit.
|
||||||
|
*
|
||||||
|
* Applies to BALANCES ONLY, in the same spirit as NOT_OUTSTANDING: the movement
|
||||||
|
* browser still totals every captured row, because "how much water did we
|
||||||
|
* capture in April" is a question about what was recorded, not about what is
|
||||||
|
* owed. Customers with no BALANCE FORWARD row (the floor is NULL) are
|
||||||
|
* unaffected.
|
||||||
|
*/
|
||||||
|
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `legacySourceTable` of an imported prior period.
|
||||||
|
*
|
||||||
|
* A closed year arrives as its own Access snapshot and is tagged rather than
|
||||||
|
* dated (see migration/transform_transactions.py). The tag is what a period
|
||||||
|
* view filters on: the archives are not cleanly date-bounded — 2025 carries
|
||||||
|
* rows dated into 2026 — and legacy did not filter by date either, it selected
|
||||||
|
* `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly.
|
||||||
|
*/
|
||||||
|
export const periodSourceTable = (year: number) => `datos2@${year}`;
|
||||||
|
|
||||||
|
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||||
|
export const PERIOD_TABLE_PREFIX = "datos2@";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy cash receipt book — a journal, not a ledger.
|
||||||
|
*
|
||||||
|
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
|
||||||
|
* counter, a folio is written, and the same receipt is then *posted* to the
|
||||||
|
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
|
||||||
|
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
|
||||||
|
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
|
||||||
|
* both tables into one `transactions` table, so anything summing a customer's
|
||||||
|
* rows counts every cash receipt twice.
|
||||||
|
*
|
||||||
|
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
|
||||||
|
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
|
||||||
|
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
|
||||||
|
* than a code change. Six receipts post converted to pesos under a mistyped
|
||||||
|
* folio, which is why matching on folio and amount found fewer duplicate pairs
|
||||||
|
* than actually exist — a reason to exclude the whole journal rather than to
|
||||||
|
* exclude a list of confirmed pairs.
|
||||||
|
*
|
||||||
|
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
|
||||||
|
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
|
||||||
|
* nothing posts it anywhere else. Excluding by table name alone erases the
|
||||||
|
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
|
||||||
|
* customers, 99 of whom have no other rows at all.
|
||||||
|
*/
|
||||||
|
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
|
||||||
|
|
||||||
|
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
|
||||||
"EFECTIVO",
|
"EFECTIVO",
|
||||||
"EFECTIVO_BACKUP",
|
"EFECTIVO_BACKUP",
|
||||||
"EFECTIVO FM3",
|
"EFECTIVO FM3",
|
||||||
@@ -172,6 +258,53 @@ const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
|||||||
"IVA 2015",
|
"IVA 2015",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma form of the cash-journal exclusion.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`,
|
||||||
|
* and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row
|
||||||
|
* (no `legacySourceTable`) would silently vanish. Same for the database test.
|
||||||
|
*/
|
||||||
|
export const notCashJournal = (): Prisma.TransactionWhereInput => ({
|
||||||
|
OR: [
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */
|
||||||
|
export const NOT_CASH_JOURNAL = Prisma.sql`(
|
||||||
|
t.legacySourceDb IS NULL
|
||||||
|
OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB}
|
||||||
|
OR t.legacySourceTable IS NULL
|
||||||
|
OR t.legacySourceTable NOT IN (${Prisma.join([
|
||||||
|
...CASH_JOURNAL_SOURCE_TABLES,
|
||||||
|
])}))`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
|
*
|
||||||
|
* The balance floor does not settle the archives on its own, in both
|
||||||
|
* directions. A customer whose newest BALANCE FORWARD lives *inside* an archive
|
||||||
|
* floors at that archive's own January 1st, so every row of it clears the floor
|
||||||
|
* — and that is correct, because below the year start the archive is the only
|
||||||
|
* carry there is. At or above the year start it must go: the archives spill a
|
||||||
|
* couple of rows into the following January and those already sit inside the
|
||||||
|
* next year's BALANCE FORWARD, which is the sum of the whole archive.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR for the same NULL reason as above.
|
||||||
|
*/
|
||||||
|
export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
|
||||||
|
t.legacySourceTable IS NULL
|
||||||
|
OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`}
|
||||||
|
OR t.transactionDate < ${yearStart})`;
|
||||||
|
|
||||||
|
/** January 1st of the running year, UTC — the current period's lower bound. */
|
||||||
|
export const currentYearStart = () =>
|
||||||
|
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -341,6 +474,16 @@ export class BillingService {
|
|||||||
async balances(params: BalanceParams) {
|
async balances(params: BalanceParams) {
|
||||||
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
||||||
|
|
||||||
|
// A balance is what the customer owes, so it takes the same rules the
|
||||||
|
// statement takes: the floor, the cash journal, and the archive window.
|
||||||
|
// Without the last two the worklist quoted a different number than the
|
||||||
|
// customer's own statement — NUMid 295 read 14,377.46 against a statement
|
||||||
|
// of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in
|
||||||
|
// each case being a cash receipt already posted to the ledger.
|
||||||
|
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
|
||||||
|
currentYearStart(),
|
||||||
|
)}`;
|
||||||
|
|
||||||
const filters: Prisma.Sql[] = [];
|
const filters: Prisma.Sql[] = [];
|
||||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||||
const txFilter = filters.length
|
const txFilter = filters.length
|
||||||
@@ -402,22 +545,24 @@ export class BillingService {
|
|||||||
MAX(t.transactionDate) AS lastMovement
|
MAX(t.transactionDate) AS lastMovement
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>`
|
const counted = await this.prisma.$queryRaw<{ total: RawCount }[]>`
|
||||||
SELECT COUNT(*) AS total FROM (
|
SELECT COUNT(*) AS total FROM (
|
||||||
SELECT c.id
|
SELECT c.id
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
-- Must match the page query's filters exactly, or the total disagrees
|
-- Must match the page query's filters exactly, or the total disagrees
|
||||||
-- with the rows. (The void exclusion was missing here before the
|
-- with the rows. (The void exclusion was missing here before the
|
||||||
-- outstanding work; a voided-only customer inflated the count.)
|
-- outstanding work; a voided-only customer inflated the count.)
|
||||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
${having}
|
${having}
|
||||||
) x
|
) x
|
||||||
@@ -458,9 +603,29 @@ export class BillingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-line figures for the billing page header. */
|
/**
|
||||||
|
* Top-line figures for the billing page header.
|
||||||
|
*
|
||||||
|
* Two different questions live here and they use different row sets.
|
||||||
|
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||||
|
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||||
|
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
|
||||||
|
* `balances()` takes — the opening-balance floor, the cash journal and the
|
||||||
|
* archive window — and the book has to agree with the worklist that sits
|
||||||
|
* under it. The four aggregates moved from Prisma groupBy to raw SQL to
|
||||||
|
* express that join; groupBy cannot.
|
||||||
|
*
|
||||||
|
* KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding
|
||||||
|
* rows, while `balances()` does. Reconciling them moves the book by about
|
||||||
|
* 1.95M MXN and turns on whether an unfunded charge is owed by the customer,
|
||||||
|
* which is the client's call and not settled yet.
|
||||||
|
*/
|
||||||
async stats() {
|
async stats() {
|
||||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(
|
||||||
|
currentYearStart(),
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const [movements, ledgerCustomers] = await Promise.all([
|
||||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.transaction
|
this.prisma.transaction
|
||||||
.findMany({
|
.findMany({
|
||||||
@@ -469,34 +634,47 @@ export class BillingService {
|
|||||||
select: { customerId: true },
|
select: { customerId: true },
|
||||||
})
|
})
|
||||||
.then((r) => r.length),
|
.then((r) => r.length),
|
||||||
this.prisma.transaction.groupBy({
|
|
||||||
by: ["currency"],
|
|
||||||
where: NOT_VOIDED,
|
|
||||||
_sum: { amount: true },
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
this.prisma.transaction.groupBy({
|
|
||||||
by: ["domain", "currency"],
|
|
||||||
where: NOT_VOIDED,
|
|
||||||
_sum: { amount: true },
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const charges = await this.prisma.transaction.groupBy({
|
const byCurrency = await this.prisma.$queryRaw<
|
||||||
by: ["currency"],
|
{
|
||||||
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
currency: string;
|
||||||
_sum: { amount: true },
|
net: Prisma.Decimal | null;
|
||||||
_count: { _all: true },
|
count: RawCount;
|
||||||
});
|
charges: Prisma.Decimal | null;
|
||||||
const credits = await this.prisma.transaction.groupBy({
|
chargeCount: RawCount;
|
||||||
by: ["currency"],
|
credits: Prisma.Decimal | null;
|
||||||
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
creditCount: RawCount;
|
||||||
_sum: { amount: true },
|
}[]
|
||||||
_count: { _all: true },
|
>`
|
||||||
});
|
SELECT t.currency AS currency,
|
||||||
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
|
SUM(t.amount) AS net,
|
||||||
const creditMap = new Map(credits.map((c) => [c.currency, c]));
|
COUNT(*) AS count,
|
||||||
|
SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END) AS charges,
|
||||||
|
SUM(t.amount < 0) AS chargeCount,
|
||||||
|
SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) AS credits,
|
||||||
|
SUM(t.amount > 0) AS creditCount
|
||||||
|
FROM transactions t
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
|
GROUP BY t.currency
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byDomain = await this.prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
domain: string;
|
||||||
|
currency: string;
|
||||||
|
net: Prisma.Decimal | null;
|
||||||
|
count: RawCount;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
SELECT t.domain AS domain, t.currency AS currency,
|
||||||
|
SUM(t.amount) AS net, COUNT(*) AS count
|
||||||
|
FROM transactions t
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
|
GROUP BY t.domain, t.currency
|
||||||
|
`;
|
||||||
|
|
||||||
// How many customers sit on each side of the line, per currency — the
|
// How many customers sit on each side of the line, per currency — the
|
||||||
// headline for a receivables view. Counted in SQL; a customer can be
|
// headline for a receivables view. Counted in SQL; a customer can be
|
||||||
@@ -504,16 +682,19 @@ export class BillingService {
|
|||||||
const sides = await this.prisma.$queryRaw<
|
const sides = await this.prisma.$queryRaw<
|
||||||
{
|
{
|
||||||
currency: string;
|
currency: string;
|
||||||
owing: bigint | number | string;
|
owing: RawCount;
|
||||||
inCredit: bigint | number | string;
|
inCredit: RawCount;
|
||||||
}[]
|
}[]
|
||||||
>`
|
>`
|
||||||
SELECT currency,
|
SELECT currency,
|
||||||
SUM(bal < -0.005) AS owing,
|
SUM(bal < -0.005) AS owing,
|
||||||
SUM(bal > 0.005) AS inCredit
|
SUM(bal > 0.005) AS inCredit
|
||||||
FROM (
|
FROM (
|
||||||
SELECT customerId, currency, SUM(amount) AS bal
|
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||||
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
FROM transactions t
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
|
GROUP BY t.customerId, t.currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
`;
|
`;
|
||||||
@@ -534,7 +715,7 @@ export class BillingService {
|
|||||||
|
|
||||||
// Customers whose ledger spans both business lines — the whole reason this
|
// Customers whose ledger spans both business lines — the whole reason this
|
||||||
// module is one view instead of two.
|
// module is one view instead of two.
|
||||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
const crossLine = await this.prisma.$queryRaw<{ n: RawCount }[]>`
|
||||||
SELECT COUNT(*) AS n FROM (
|
SELECT COUNT(*) AS n FROM (
|
||||||
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
||||||
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||||
@@ -549,20 +730,20 @@ export class BillingService {
|
|||||||
lastMovement: lastRow?.transactionDate ?? null,
|
lastMovement: lastRow?.transactionDate ?? null,
|
||||||
byCurrency: byCurrency.map((c) => ({
|
byCurrency: byCurrency.map((c) => ({
|
||||||
currency: c.currency,
|
currency: c.currency,
|
||||||
net: c._sum.amount,
|
net: c.net,
|
||||||
count: c._count._all,
|
count: num(c.count),
|
||||||
charges: chargeMap.get(c.currency)?._sum.amount ?? null,
|
charges: c.charges,
|
||||||
chargeCount: chargeMap.get(c.currency)?._count._all ?? 0,
|
chargeCount: num(c.chargeCount),
|
||||||
credits: creditMap.get(c.currency)?._sum.amount ?? null,
|
credits: c.credits,
|
||||||
creditCount: creditMap.get(c.currency)?._count._all ?? 0,
|
creditCount: num(c.creditCount),
|
||||||
owing: num(sideMap.get(c.currency)?.owing),
|
owing: num(sideMap.get(c.currency)?.owing),
|
||||||
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
||||||
})),
|
})),
|
||||||
byDomain: byDomain.map((d) => ({
|
byDomain: byDomain.map((d) => ({
|
||||||
domain: d.domain,
|
domain: d.domain,
|
||||||
currency: d.currency,
|
currency: d.currency,
|
||||||
net: d._sum.amount,
|
net: d.net,
|
||||||
count: d._count._all,
|
count: num(d.count),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -589,7 +770,7 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const years = await this.prisma.$queryRaw<
|
const years = await this.prisma.$queryRaw<
|
||||||
{ year: number; count: bigint | number | string }[]
|
{ year: number; count: RawCount }[]
|
||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
||||||
@@ -617,13 +798,22 @@ export class BillingService {
|
|||||||
/**
|
/**
|
||||||
* One customer's statement across both business lines.
|
* One customer's statement across both business lines.
|
||||||
*
|
*
|
||||||
* Returns the *whole* ledger rather than a page of it: the heaviest customer
|
* Scoped to one calendar year and listed oldest-first, matching the legacy
|
||||||
|
* EDO CUENTA report the office has printed for years: an opening balance at
|
||||||
|
* the top, then the year's movements in the order they happened.
|
||||||
|
*
|
||||||
|
* `year` selects the period. The current year is read from the live tables;
|
||||||
|
* any earlier year is read from its imported archive, which legacy kept as a
|
||||||
|
* separate table and this reads by its `datos2@YYYY` tag. `availableYears`
|
||||||
|
* reports which periods this customer actually has, so a caller never offers
|
||||||
|
* a year that would render empty.
|
||||||
|
*
|
||||||
|
* Returns the *whole* year rather than a page of it: the heaviest customer
|
||||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||||
* the client only holds a slice. The running balance is accumulated per
|
* the client only holds a slice. The running balance is accumulated per
|
||||||
* currency in chronological order, then the list is handed back newest-first
|
* currency in chronological order, with each row's balance-after attached.
|
||||||
* with each row's balance-after already attached.
|
|
||||||
*/
|
*/
|
||||||
async statement(customerId: string) {
|
async statement(customerId: string, year?: number) {
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: {
|
select: {
|
||||||
@@ -647,23 +837,116 @@ export class BillingService {
|
|||||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which periods this customer has. The current year is always offered —
|
||||||
|
// it is the live ledger even when empty — and each imported archive adds
|
||||||
|
// the year it holds.
|
||||||
|
const archives = await this.prisma.transaction.findMany({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX },
|
||||||
|
},
|
||||||
|
distinct: ["legacySourceTable"],
|
||||||
|
select: { legacySourceTable: true },
|
||||||
|
});
|
||||||
|
const thisYear = new Date().getUTCFullYear();
|
||||||
|
const archiveYears = archives
|
||||||
|
.map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length)))
|
||||||
|
.filter((y) => Number.isInteger(y) && y < thisYear);
|
||||||
|
const availableYears = [...new Set([thisYear, ...archiveYears])].sort(
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
|
||||||
|
// An unknown year would silently render as the current one, which reads as
|
||||||
|
// "this customer had no activity in 2019" rather than "there is no 2019".
|
||||||
|
const requested = year ?? thisYear;
|
||||||
|
if (!availableYears.includes(requested)) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`El cliente no tiene movimientos del periodo ${requested}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isArchive = requested !== thisYear;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Deliberately open-ended at the top. A period is a table in legacy, not a
|
||||||
|
// date range, so whatever the office filed in it belongs to it — including
|
||||||
|
// the future-dated rows the current ledger carries (it runs to 2028). An
|
||||||
|
// upper bound would hide them from every view, which is not what legacy did
|
||||||
|
// and not what the office has been reading.
|
||||||
|
//
|
||||||
|
// An archive needs no fold at all: it *is* the period, and its own Jan-1
|
||||||
|
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
|
||||||
|
const yearStart = isArchive
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.UTC(requested, 0, 1));
|
||||||
|
|
||||||
|
// One customer, so the balance floor is a single date rather than the
|
||||||
|
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||||
|
// the opening balance are already inside it, and showing them would both
|
||||||
|
// double the total and make every balanceAfter below wrong.
|
||||||
|
//
|
||||||
|
// This is also what stops FEE ANUAL and fee15 leaking in. They are not in
|
||||||
|
// STATEMENT_EXCLUDED_SOURCE_TABLES — that list exists to reproduce legacy's
|
||||||
|
// DATOS2-only `datosfreak`, and it was letting 2,092 pre-cutover fee rows
|
||||||
|
// across 1,062 customers through, skewing the statement by -5,129,764
|
||||||
|
// against the number those customers have been quoted for years. Dating
|
||||||
|
// rather than source is the right test: a FEE ANUAL row *after* the opening
|
||||||
|
// balance is a real charge and still counts.
|
||||||
|
const floor = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
const rows = await this.prisma.transaction.findMany({
|
const rows = await this.prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
...(isArchive
|
||||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
? // An archive is already exactly one period's ledger, so the tag is
|
||||||
// (which has no legacySourceTable) silently vanished from the
|
// the whole filter. The balance floor is deliberately NOT applied:
|
||||||
// statement while still showing in the movement browser. Rows the app
|
// it exists to stop a later opening balance double-counting the
|
||||||
// books must appear on the customer's statement, so the null case is
|
// history it summarizes, and here that history is the thing being
|
||||||
// spelled out.
|
// asked for. The exclusion list is moot too — an archive holds only
|
||||||
OR: [
|
// DATOS2 rows, which is what legacy's year table held.
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: periodSourceTable(requested) }
|
||||||
{
|
: {
|
||||||
legacySourceTable: {
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
// An archive row belongs to this period only as history. Below
|
||||||
},
|
// the year start it is exactly what `opening` is for, and for the
|
||||||
},
|
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||||
],
|
// archive it is the only carry there is — dropping it outright
|
||||||
|
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||||
|
//
|
||||||
|
// At or above the year start it must go. The archives spill a
|
||||||
|
// couple of rows into the following January, and those are
|
||||||
|
// already inside the next year's BALANCE FORWARD (which is the
|
||||||
|
// sum of the whole archive), so listing them here would both
|
||||||
|
// double-count and file a closed year's row as current.
|
||||||
|
//
|
||||||
|
// Spelled as a positive OR because `NOT (col LIKE ... AND ...)`
|
||||||
|
// is NULL for an app-captured row, which would drop every one.
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
|
legacySourceTable: {
|
||||||
|
not: { startsWith: PERIOD_TABLE_PREFIX },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash receipt book is the ledger's own postings written a
|
||||||
|
// second time, so listing it here would show every counter
|
||||||
|
// payment twice and double the credit side.
|
||||||
|
notCashJournal(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -683,15 +966,50 @@ export class BillingService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The statement covers one calendar year. The floor above normally lands on
|
||||||
|
// January 1st of it already — the legacy publish writes one BALANCE FORWARD
|
||||||
|
// per customer per year — in which case nothing extra is dropped here. When
|
||||||
|
// it doesn't (a customer the last publish skipped, or one that never had an
|
||||||
|
// opening balance), the earlier rows still have to be *counted* or every
|
||||||
|
// balance below is wrong, so they are folded into `opening` rather than
|
||||||
|
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const movements = rows.map((r) => {
|
/** Balance carried into `yearStart`, per currency. */
|
||||||
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
/** The same carried balance split by business line, keyed `domain|currency`. */
|
||||||
|
const openingByDomain = new Map<
|
||||||
|
string,
|
||||||
|
{ domain: TransactionDomain; currency: string; amount: Prisma.Decimal }
|
||||||
|
>();
|
||||||
|
/** The rows the statement lists — this year's. Totals are built from these. */
|
||||||
|
const visible: typeof rows = [];
|
||||||
|
|
||||||
|
const movements = rows.flatMap((r) => {
|
||||||
const voided = r.voidedAt != null;
|
const voided = r.voidedAt != null;
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||||
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
||||||
// balance — both show tagged, with the balance unchanged from the previous
|
// balance — both show tagged, with the balance unchanged from the previous
|
||||||
// live movement. Outstanding rows start counting once resolved.
|
// live movement. Outstanding rows start counting once resolved.
|
||||||
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
|
const counted = !voided && !r.outstanding;
|
||||||
|
const next = counted ? prev.plus(r.amount) : prev;
|
||||||
running.set(r.currency, next);
|
running.set(r.currency, next);
|
||||||
|
|
||||||
|
if (r.transactionDate < yearStart) {
|
||||||
|
if (counted) {
|
||||||
|
opening.set(r.currency, next);
|
||||||
|
const dk = `${r.domain}|${r.currency}`;
|
||||||
|
const od = openingByDomain.get(dk) ?? {
|
||||||
|
domain: r.domain,
|
||||||
|
currency: r.currency,
|
||||||
|
amount: new Prisma.Decimal(0),
|
||||||
|
};
|
||||||
|
od.amount = od.amount.plus(r.amount);
|
||||||
|
openingByDomain.set(dk, od);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.push(r);
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
transactionDate: r.transactionDate,
|
transactionDate: r.transactionDate,
|
||||||
@@ -711,7 +1029,6 @@ export class BillingService {
|
|||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
movements.reverse();
|
|
||||||
|
|
||||||
// Per-currency summary, and the same split by business line so the two
|
// Per-currency summary, and the same split by business line so the two
|
||||||
// ledgers are visibly one statement without being illegally added up.
|
// ledgers are visibly one statement without being illegally added up.
|
||||||
@@ -739,7 +1056,29 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const [currency] of opening) {
|
||||||
|
perCurrency.set(currency, {
|
||||||
|
currency,
|
||||||
|
charges: new Prisma.Decimal(0),
|
||||||
|
credits: new Prisma.Decimal(0),
|
||||||
|
chargeCount: 0,
|
||||||
|
creditCount: 0,
|
||||||
|
count: 0,
|
||||||
|
first: null,
|
||||||
|
last: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [key, o] of openingByDomain) {
|
||||||
|
perDomain.set(key, {
|
||||||
|
domain: o.domain,
|
||||||
|
currency: o.currency,
|
||||||
|
charges: new Prisma.Decimal(0),
|
||||||
|
credits: new Prisma.Decimal(0),
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of visible) {
|
||||||
// Voided rows never enter a total; outstanding rows don't either until
|
// Voided rows never enter a total; outstanding rows don't either until
|
||||||
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
||||||
if (r.voidedAt != null || r.outstanding) continue;
|
if (r.voidedAt != null || r.outstanding) continue;
|
||||||
@@ -789,7 +1128,7 @@ export class BillingService {
|
|||||||
string,
|
string,
|
||||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const r of visible) {
|
||||||
if (r.voidedAt != null || r.outstanding) continue;
|
if (r.voidedAt != null || r.outstanding) continue;
|
||||||
if (!r.amount.lessThan(0)) continue;
|
if (!r.amount.lessThan(0)) continue;
|
||||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||||
@@ -808,25 +1147,38 @@ export class BillingService {
|
|||||||
propertyCount: customer._count.properties,
|
propertyCount: customer._count.properties,
|
||||||
policyCount: customer._count.policies,
|
policyCount: customer._count.policies,
|
||||||
},
|
},
|
||||||
summary: [...perCurrency.values()].map((c) => ({
|
year: requested,
|
||||||
currency: c.currency,
|
availableYears,
|
||||||
charges: c.charges.toFixed(2),
|
summary: [...perCurrency.values()].map((c) => {
|
||||||
credits: c.credits.toFixed(2),
|
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||||
balance: c.charges.plus(c.credits).toFixed(2),
|
return {
|
||||||
chargeCount: c.chargeCount,
|
currency: c.currency,
|
||||||
creditCount: c.creditCount,
|
/** Balance carried in from before this year — legacy's BALANCE FORWARD. */
|
||||||
count: c.count,
|
opening: open.toFixed(2),
|
||||||
firstMovement: c.first,
|
charges: c.charges.toFixed(2),
|
||||||
lastMovement: c.last,
|
credits: c.credits.toFixed(2),
|
||||||
})),
|
balance: open.plus(c.charges).plus(c.credits).toFixed(2),
|
||||||
byDomain: [...perDomain.values()].map((d) => ({
|
chargeCount: c.chargeCount,
|
||||||
domain: d.domain,
|
creditCount: c.creditCount,
|
||||||
currency: d.currency,
|
count: c.count,
|
||||||
charges: d.charges.toFixed(2),
|
firstMovement: c.first,
|
||||||
credits: d.credits.toFixed(2),
|
lastMovement: c.last,
|
||||||
balance: d.charges.plus(d.credits).toFixed(2),
|
};
|
||||||
count: d.count,
|
}),
|
||||||
})),
|
byDomain: [...perDomain.values()].map((d) => {
|
||||||
|
const open =
|
||||||
|
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
|
||||||
|
new Prisma.Decimal(0);
|
||||||
|
return {
|
||||||
|
domain: d.domain,
|
||||||
|
currency: d.currency,
|
||||||
|
opening: open.toFixed(2),
|
||||||
|
charges: d.charges.toFixed(2),
|
||||||
|
credits: d.credits.toFixed(2),
|
||||||
|
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
|
||||||
|
count: d.count,
|
||||||
|
};
|
||||||
|
}),
|
||||||
byType: [...byType.values()]
|
byType: [...byType.values()]
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import {
|
||||||
|
CASH_JOURNAL_SOURCE_DB,
|
||||||
|
CASH_JOURNAL_SOURCE_TABLES,
|
||||||
|
NOT_CASH_JOURNAL,
|
||||||
|
notCashJournal,
|
||||||
|
} from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `EFECTIVO` is the office's paper receipt book, and every receipt in it is
|
||||||
|
* also posted to the utilities ledger as `C<folio>`. Both copies were imported
|
||||||
|
* into one `transactions` table, so a balance that reads the journal counts
|
||||||
|
* each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide,
|
||||||
|
* and 3,500.00 of it on NUMid 10 alone.
|
||||||
|
*
|
||||||
|
* These fail silently in the worst way: the numbers stay plausible, they are
|
||||||
|
* just too generous to the customer. Two shapes of mistake are easy to make
|
||||||
|
* here and both are covered below — dropping the database qualifier (which
|
||||||
|
* erases the insurance line's only ledger) and writing the exclusion as a bare
|
||||||
|
* `NOT IN` (which erases every app-captured row).
|
||||||
|
*/
|
||||||
|
describe("cash journal exclusion", () => {
|
||||||
|
describe("raw SQL form", () => {
|
||||||
|
it("binds the source database rather than interpolating it", () => {
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("qualifies the table names with the database they came from", () => {
|
||||||
|
// `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's
|
||||||
|
// ONLY ledger — nothing posts it anywhere else. Matching on the table
|
||||||
|
// name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers.
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>");
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spells both null cases out instead of relying on NOT IN", () => {
|
||||||
|
// `NULL NOT IN (...)` is NULL, not true. Without these branches every
|
||||||
|
// app-captured row — the ones staff key in by hand — drops out of the
|
||||||
|
// balance while still showing in the movement browser.
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL");
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the whole cash family, not just EFECTIVO", () => {
|
||||||
|
for (const table of CASH_JOURNAL_SOURCE_TABLES) {
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a single parenthesised term, safe to AND into a WHERE clause", () => {
|
||||||
|
// It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed
|
||||||
|
// OR chain would swallow every condition after it and silently widen the
|
||||||
|
// whole query to the entire table.
|
||||||
|
const sql = NOT_CASH_JOURNAL.sql.trim();
|
||||||
|
expect(sql.startsWith("(")).toBe(true);
|
||||||
|
expect(sql.endsWith(")")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prisma form", () => {
|
||||||
|
it("matches the raw form's terms so the two cannot drift apart", () => {
|
||||||
|
const branches = notCashJournal().OR as Prisma.TransactionWhereInput[];
|
||||||
|
expect(branches).toEqual([
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fresh object each call", () => {
|
||||||
|
// It is spread into `AND: [...]` arrays that Prisma may mutate; a shared
|
||||||
|
// singleton would leak one query's filters into the next.
|
||||||
|
expect(notCashJournal()).not.toBe(notCashJournal());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { periodSourceTable } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A closed year is imported as its own tagged set of rows rather than being
|
||||||
|
* identified by date. The tag is written by migration/transform_transactions.py
|
||||||
|
* and read by BillingService.statement, the edo-cuenta-datos report, and the
|
||||||
|
* PHP portal — three places that must agree on the exact string.
|
||||||
|
*/
|
||||||
|
describe("periodSourceTable", () => {
|
||||||
|
it("names the archive the migration writes", () => {
|
||||||
|
expect(periodSourceTable(2025)).toBe("datos2@2025");
|
||||||
|
expect(periodSourceTable(2024)).toBe("datos2@2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays distinct from the live ledger's own table", () => {
|
||||||
|
// The live table is plain `datos2`. legacyId is a positional ordinal that
|
||||||
|
// restarts at 0 in every archive, so a shared name would collide with the
|
||||||
|
// current year row-for-row on the unique key.
|
||||||
|
expect(periodSourceTable(2025)).not.toBe("datos2");
|
||||||
|
expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not matched by the statement's cash-source exclusion list", () => {
|
||||||
|
// STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce
|
||||||
|
// legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must
|
||||||
|
// survive that filter or a prior year renders empty.
|
||||||
|
const excluded = [
|
||||||
|
"EFECTIVO",
|
||||||
|
"EFECTIVO_BACKUP",
|
||||||
|
"EFECTIVO FM3",
|
||||||
|
"CHEQUE FM3",
|
||||||
|
"IVA 2015",
|
||||||
|
];
|
||||||
|
expect(excluded).not.toContain(periodSourceTable(2025));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statement is a *year* statement, like the EDO CUENTA report the office
|
||||||
|
* prints: this year's movements, oldest-first, opening on the balance carried
|
||||||
|
* in from before it.
|
||||||
|
*
|
||||||
|
* The carrying is the part worth testing. Dropping earlier rows from the list
|
||||||
|
* is easy; dropping them from the arithmetic too would restart every balance at
|
||||||
|
* zero on January 1st, and nothing would throw — the numbers would just be
|
||||||
|
* wrong, which is exactly how the double-counting bug lived for years.
|
||||||
|
*/
|
||||||
|
describe("statement year scoping", () => {
|
||||||
|
const YEAR = new Date().getUTCFullYear();
|
||||||
|
|
||||||
|
function d(iso: string) {
|
||||||
|
return new Date(`${iso}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowSpec = {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
amount: string;
|
||||||
|
currency?: string;
|
||||||
|
domain?: string;
|
||||||
|
voidedAt?: Date | null;
|
||||||
|
outstanding?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function row(r: RowSpec) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
transactionDate: r.date,
|
||||||
|
domain: r.domain ?? "UTILITY",
|
||||||
|
amount: new Prisma.Decimal(r.amount),
|
||||||
|
currency: r.currency ?? "MXN",
|
||||||
|
reference: null,
|
||||||
|
period: null,
|
||||||
|
checkNumber: null,
|
||||||
|
message: null,
|
||||||
|
legacySourceTable: null,
|
||||||
|
voidedAt: r.voidedAt ?? null,
|
||||||
|
outstanding: r.outstanding ?? false,
|
||||||
|
type: { nameEn: "WATER", nameEs: "AGUA" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No BALANCE FORWARD row, so the floor is null and every row is fetched. */
|
||||||
|
function serviceWith(rows: RowSpec[]) {
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: "c1",
|
||||||
|
name: "CUADROS, JORGE H.",
|
||||||
|
preferredCurrency: "MXN",
|
||||||
|
_count: { properties: 0, policies: 0 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findMany: jest.fn().mockResolvedValue(rows.map(row)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return new BillingService(prisma as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists the year's movements oldest-first", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||||
|
{ id: "b", date: d(`${YEAR}-03-04`), amount: "250" },
|
||||||
|
{ id: "c", date: d(`${YEAR}-07-16`), amount: "-40" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
expect(s.movements.map((m) => m.id)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves earlier years off the list", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-11-30`), amount: "-500" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-100" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
expect(s.movements.map((m) => m.id)).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the earlier years' balance instead of discarding it", async () => {
|
||||||
|
// 1,000 credit left over from last year, 300 charged this year: the
|
||||||
|
// customer is 700 in credit, not 300 in debt.
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||||
|
expect(mxn?.opening).toBe("1000.00");
|
||||||
|
expect(mxn?.charges).toBe("-300.00");
|
||||||
|
expect(mxn?.balance).toBe("700.00");
|
||||||
|
// The running balance on the listed row picks up where last year left off.
|
||||||
|
expect(s.movements[0].balanceAfter).toBe("700.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries it per business line as well", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", domain: "INSURANCE" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300", domain: "INSURANCE" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const line = s.byDomain.find((x) => x.domain === "INSURANCE");
|
||||||
|
expect(line?.opening).toBe("1000.00");
|
||||||
|
expect(line?.balance).toBe("700.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a currency that only moved in earlier years", async () => {
|
||||||
|
// Otherwise a customer sitting on a dollar credit they haven't touched all
|
||||||
|
// year would appear to have no dollar balance at all.
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 2}-05-01`), amount: "180.83", currency: "USD" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const usd = s.summary.find((x) => x.currency === "USD");
|
||||||
|
expect(usd?.balance).toBe("180.83");
|
||||||
|
expect(usd?.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not carry a voided earlier row", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", voidedAt: d(`${YEAR - 1}-12-16`) },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||||
|
expect(mxn?.opening).toBe("0.00");
|
||||||
|
expect(mxn?.balance).toBe("-300.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the year it covers", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
expect(s.year).toBe(YEAR);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ export class CreateCustomerDto {
|
|||||||
@IsOptional() @IsString() mobile?: string;
|
@IsOptional() @IsString() mobile?: string;
|
||||||
@IsOptional() @IsString() fax?: string;
|
@IsOptional() @IsString() fax?: string;
|
||||||
@IsOptional() @IsEmail() email?: string;
|
@IsOptional() @IsEmail() email?: string;
|
||||||
|
@IsOptional() @IsBoolean() emailOptOut?: boolean;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
@IsOptional() @IsString() identificationType?: string;
|
@IsOptional() @IsString() identificationType?: string;
|
||||||
@IsOptional() @IsString() identificationNumber?: string;
|
@IsOptional() @IsString() identificationNumber?: string;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The /clientes/:id ledger card is titled "Estado de cuenta" and links straight
|
||||||
|
* to the statement, so its per-line totals must be the statement's numbers.
|
||||||
|
*
|
||||||
|
* They were a raw lifetime sum — no floor, no source exclusion — which
|
||||||
|
* double-counted the pre-cutover history each BALANCE FORWARD row absorbs.
|
||||||
|
* Importing prior periods made it visibly worse: every closed year is now held
|
||||||
|
* a second time as its own tagged copy, so an unfloored sum adds each one on
|
||||||
|
* top of the opening balance that already contains it.
|
||||||
|
*/
|
||||||
|
describe("customer file ledger card", () => {
|
||||||
|
function serviceWith(floor: Date | null) {
|
||||||
|
const groupBy = jest.fn().mockResolvedValue([]);
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: "c1", transactions: [] }),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||||
|
groupBy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new CustomersService(prisma as never),
|
||||||
|
prisma,
|
||||||
|
groupBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("takes the same balance floor the statement takes", async () => {
|
||||||
|
const floor = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const { service, prisma, groupBy } = serviceWith(floor);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||||
|
transactionDate: { gte: floor },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies no floor when the customer never had an opening balance", async () => {
|
||||||
|
// 102 customers have no BALANCE FORWARD row at all. Inventing a floor for
|
||||||
|
// them would hide their whole ledger.
|
||||||
|
const { service, groupBy } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an archive as history but never as current", async () => {
|
||||||
|
// The floor alone is not enough: a customer floored by an archive clears
|
||||||
|
// it with every row of that archive, and the rows archives spill into the
|
||||||
|
// following January clear any floor. But excluding archives outright is
|
||||||
|
// wrong too — below the year start they are the only carry a
|
||||||
|
// floored-by-archive customer has (NUMid 295, 785.46).
|
||||||
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
|
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||||
|
JSON.stringify(c).includes("datos2@"),
|
||||||
|
);
|
||||||
|
expect(rule.OR).toEqual([
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
||||||
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
|
const sourceRule = and.find((c: { OR?: unknown[] }) =>
|
||||||
|
JSON.stringify(c).includes("EFECTIVO"),
|
||||||
|
);
|
||||||
|
expect(sourceRule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops outstanding rows, as every balance does", async () => {
|
||||||
|
const { service, groupBy } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||||
|
outstanding: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps archives out of the year's movement list too", async () => {
|
||||||
|
// datos2@2024 carries rows dated into 2026; a date test alone would show
|
||||||
|
// them as current-year movements next to the live ledger's own copy.
|
||||||
|
const { service, prisma } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||||
|
expect(include.transactions.where.OR).toEqual([
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
// Nothing below yearStart reaches this list, so the third branch never
|
||||||
|
// admits an archive row here — it is carried for one shared rule.
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
|||||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ import { UpdateCustomerDto } from "./update-customer.dto";
|
|||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly customers: CustomersService,
|
private readonly customers: CustomersService,
|
||||||
|
private readonly numids: NumidService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,6 +38,13 @@ export class CustomersController {
|
|||||||
return this.customers.stats();
|
return this.customers.stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
||||||
|
* path is not swallowed by the wildcard route. */
|
||||||
|
@Get("numid/candidates")
|
||||||
|
async numidCandidates() {
|
||||||
|
return { candidates: await this.numids.emptyCandidates() };
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@@ -95,4 +104,27 @@ export class CustomersController {
|
|||||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give this customer a portal NUMid so they can log in to
|
||||||
|
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
||||||
|
* back rather than a second identity.
|
||||||
|
*/
|
||||||
|
@Post(":id/portal-access")
|
||||||
|
@RequireAbility("customer:portal-access")
|
||||||
|
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const allocation = await this.numids.allocate(id);
|
||||||
|
if (allocation.origin !== "existing") {
|
||||||
|
// Logged with the origin and the previous holder: a recycled id is the one
|
||||||
|
// case where reading this record later has to answer "whose number was
|
||||||
|
// this before, and was it taken or minted".
|
||||||
|
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
||||||
|
customerId: id,
|
||||||
|
numid: allocation.numid,
|
||||||
|
origin: allocation.origin,
|
||||||
|
previousCustomerId: allocation.previousCustomerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return allocation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
import { CustomersController } from "./customers.controller";
|
import { CustomersController } from "./customers.controller";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SettingsModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService],
|
providers: [CustomersService, NumidService],
|
||||||
|
exports: [NumidService],
|
||||||
})
|
})
|
||||||
export class CustomersModule {}
|
export class CustomersModule {}
|
||||||
|
|||||||
@@ -3,6 +3,35 @@ import { Prisma } from "@jorgecuadros/database";
|
|||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import {
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
|
PERIOD_TABLE_PREFIX,
|
||||||
|
} from "../billing/billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
|
*
|
||||||
|
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||||
|
* year start it is history and counts — for the one customer whose newest
|
||||||
|
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||||
|
* dropping it understated NUMid 295 by his entire 2025 closing balance. At or
|
||||||
|
* above the year start it must go: the archives spill a couple of rows into the
|
||||||
|
* following January, and those already sit inside the next year's BALANCE
|
||||||
|
* FORWARD, which is the sum of the whole archive.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||||
|
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||||
|
*/
|
||||||
|
const archiveIsHistory = (
|
||||||
|
yearStart: Date,
|
||||||
|
): Prisma.TransactionWhereInput => ({
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -96,6 +125,13 @@ export class CustomersService {
|
|||||||
|
|
||||||
/** Full unified customer view: identity + both business lines + ledger. */
|
/** Full unified customer view: identity + both business lines + ledger. */
|
||||||
async detail(id: string) {
|
async detail(id: string) {
|
||||||
|
// The movement list on the customer file is the same statement the office
|
||||||
|
// prints, so it follows the same rule as BillingService.statement: this
|
||||||
|
// calendar year, oldest-first. No `take` any more — the cap used to hide
|
||||||
|
// the end of a busy customer's year once the order flipped, and a single
|
||||||
|
// year is small (365 rows for the heaviest customer in the book).
|
||||||
|
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -117,8 +153,21 @@ export class CustomersService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
transactions: {
|
transactions: {
|
||||||
orderBy: { transactionDate: "desc" },
|
// Archives are excluded by tag, not by date. They are not cleanly
|
||||||
take: 100,
|
// bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
|
||||||
|
// in 2026, datos2@2025 two more — so a date test alone would surface
|
||||||
|
// a closed year's rows in the current year's list, duplicating the
|
||||||
|
// live ledger's own copy of them for three customers.
|
||||||
|
// Archives are kept out by tag, not by date. They are not cleanly
|
||||||
|
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
|
||||||
|
// alone would surface a closed year's rows in the current year's
|
||||||
|
// list. Nothing below yearStart reaches this list anyway, so the
|
||||||
|
// window rule reduces to a plain exclusion here.
|
||||||
|
where: {
|
||||||
|
transactionDate: { gte: yearStart },
|
||||||
|
...archiveIsHistory(yearStart),
|
||||||
|
},
|
||||||
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
include: { type: true },
|
include: { type: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -130,16 +179,51 @@ export class CustomersService {
|
|||||||
|
|
||||||
// Ledger totals per domain + currency (the "one statement across both
|
// Ledger totals per domain + currency (the "one statement across both
|
||||||
// business lines" payoff), computed in the DB rather than in JS.
|
// business lines" payoff), computed in the DB rather than in JS.
|
||||||
|
//
|
||||||
|
// These have to answer the same question BillingService.statement answers,
|
||||||
|
// because this card is titled "Estado de cuenta" and links straight to it —
|
||||||
|
// two screens quoting one customer two different balances is worse than
|
||||||
|
// either number alone. So it takes the same three rules the statement uses:
|
||||||
|
// the balance floor, the cash-source exclusion, and dropping outstanding
|
||||||
|
// rows the office has not paid yet.
|
||||||
|
//
|
||||||
|
// Without the floor these were a raw lifetime sum, double-counting the
|
||||||
|
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
|
||||||
|
// prior periods made that visibly worse: for NUMid 501 the tiles read
|
||||||
|
// -7,119.29 before the archives landed and -15,270.59 after, against a true
|
||||||
|
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
|
||||||
|
// balances, added a second time on top of the opening row that contains
|
||||||
|
// them.
|
||||||
|
const floor = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: id,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
const summary = await this.prisma.transaction.groupBy({
|
const summary = await this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
by: ["domain", "currency"],
|
||||||
// Exclude voided rows so the per-domain balance matches the statement.
|
where: {
|
||||||
where: { customerId: id, voidedAt: null },
|
customerId: id,
|
||||||
|
voidedAt: null,
|
||||||
|
outstanding: false,
|
||||||
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
|
// The floor alone does not settle the archives: a customer floored by
|
||||||
|
// an archive clears it with every row of that archive, and the rows
|
||||||
|
// archives spill into the following January clear any floor.
|
||||||
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||||
|
},
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...customer,
|
...customer,
|
||||||
|
/** Calendar year the movement list covers. */
|
||||||
|
transactionYear: yearStart.getUTCFullYear(),
|
||||||
transactionSummary: summary.map((s) => ({
|
transactionSummary: summary.map((s) => ({
|
||||||
domain: s.domain,
|
domain: s.domain,
|
||||||
currency: s.currency,
|
currency: s.currency,
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What matters about the allocator is the two things it must never do: hand the
|
||||||
|
* same id to two customers, and hand out a recycled id while Access can still
|
||||||
|
* take it back. Both are tested here; the emptiness SQL itself is exercised
|
||||||
|
* against real data by scripts/numid-audit.mjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
existingRef?: { legacyId: string } | null;
|
||||||
|
archived?: boolean;
|
||||||
|
missing?: boolean;
|
||||||
|
recycle?: boolean;
|
||||||
|
empty?: { numid: string; refId: string; customerId: string }[];
|
||||||
|
max?: number | null;
|
||||||
|
/** Make the first N create() calls fail the unique key, as a race would. */
|
||||||
|
createConflicts?: number;
|
||||||
|
/** Make updateMany report "nothing matched", as a lost recycle race would. */
|
||||||
|
recycleMisses?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(opts: Options = {}) {
|
||||||
|
const created: { legacyId: string }[] = [];
|
||||||
|
let conflictsLeft = opts.createConflicts ?? 0;
|
||||||
|
let missesLeft = opts.recycleMisses ?? 0;
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(
|
||||||
|
opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
customerLegacyRef: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
|
||||||
|
updateMany: jest.fn().mockImplementation(() => {
|
||||||
|
if (missesLeft > 0) {
|
||||||
|
missesLeft -= 1;
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
|
||||||
|
if (conflictsLeft > 0) {
|
||||||
|
conflictsLeft -= 1;
|
||||||
|
return Promise.reject(
|
||||||
|
new Prisma.PrismaClientKnownRequestError("dup", {
|
||||||
|
code: "P2002",
|
||||||
|
clientVersion: "5",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
created.push(data);
|
||||||
|
return Promise.resolve(data);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Two different raw queries share one mock: the MAX lookup returns a single
|
||||||
|
// {max} row, everything else is the empty-candidate list.
|
||||||
|
$queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
|
||||||
|
const text = String((sql as unknown as { sql?: string }).sql ?? "");
|
||||||
|
if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
|
||||||
|
return Promise.resolve(opts.empty ?? []);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
numidRecycleEmpty: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new NumidService(prisma as never, settings as never),
|
||||||
|
prisma,
|
||||||
|
created,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NUMid allocation", () => {
|
||||||
|
it("returns the id a customer already holds instead of minting a second one", async () => {
|
||||||
|
// A double-clicked button must not fork the customer's portal identity.
|
||||||
|
const { service, prisma } = build({ existingRef: { legacyId: "501" } });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "501",
|
||||||
|
origin: "existing",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allocates one past the highest id in the pool", async () => {
|
||||||
|
const { service, created } = build({ max: 1171 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(created[0]).toMatchObject({
|
||||||
|
sourceSystem: "utilities",
|
||||||
|
sourceTable: "DATGRAL",
|
||||||
|
legacyId: "1172",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts at 1 when the pool is empty", async () => {
|
||||||
|
const { service } = build({ max: null });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recycle while the setting is off, even with candidates free", async () => {
|
||||||
|
// The default has to be the safe one: every reusable id still exists in
|
||||||
|
// Access, and a --sync run reassigns it back to its Access owner.
|
||||||
|
const { service, prisma } = build({
|
||||||
|
max: 1171,
|
||||||
|
empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the lowest empty id once recycling is switched on", async () => {
|
||||||
|
const { service, prisma } = build({
|
||||||
|
recycle: true,
|
||||||
|
max: 1171,
|
||||||
|
empty: [
|
||||||
|
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||||
|
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "1089",
|
||||||
|
origin: "recycled",
|
||||||
|
previousCustomerId: "cust-old",
|
||||||
|
});
|
||||||
|
// Guarded on the owner read a moment ago, so a ref that moved underneath us
|
||||||
|
// matches nothing rather than being stolen.
|
||||||
|
expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: "ref-1089", customerId: "cust-old" },
|
||||||
|
data: { customerId: "cust-new" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a candidate that someone else took first", async () => {
|
||||||
|
const { service } = build({
|
||||||
|
recycle: true,
|
||||||
|
max: 1171,
|
||||||
|
recycleMisses: 1,
|
||||||
|
empty: [
|
||||||
|
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||||
|
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1094",
|
||||||
|
origin: "recycled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a new id when recycling is on but nothing is free", async () => {
|
||||||
|
const { service } = build({ recycle: true, max: 1171, empty: [] });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when two writers pick the same id", async () => {
|
||||||
|
// The unique key on (sourceSystem, sourceTable, legacyId) is what decides
|
||||||
|
// the winner; the loser must retry, never overwrite.
|
||||||
|
const { service, prisma } = build({ max: 1171, createConflicts: 1 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up loudly rather than looping forever", async () => {
|
||||||
|
const { service } = build({ max: 1171, createConflicts: 99 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an archived customer", async () => {
|
||||||
|
const { service } = build({ archived: true });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a customer that does not exist", async () => {
|
||||||
|
const { service } = build({ missing: true });
|
||||||
|
|
||||||
|
await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { SettingsService } from "../settings/settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com
|
||||||
|
* asks for at login.
|
||||||
|
*
|
||||||
|
* The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with
|
||||||
|
* (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create`
|
||||||
|
* deliberately writes none: a natively created customer has no legacy provenance.
|
||||||
|
* The consequence is that every customer created in the staff UI is invisible to
|
||||||
|
* the portal until this service gives them an id.
|
||||||
|
*
|
||||||
|
* WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the
|
||||||
|
* platform before utilities, and an insurance-only customer has no reason to hold
|
||||||
|
* a portal identity. Allocating on every create would spend utilities ids — and
|
||||||
|
* the handful of reusable ones — on people who will never log in. So this is an
|
||||||
|
* explicit staff action instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The pair that identifies a portal NUMid. */
|
||||||
|
export const UTILITIES_SYSTEM = "utilities";
|
||||||
|
export const UTILITIES_TABLE = "DATGRAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name
|
||||||
|
* and runs past 4,000. It must never be read as a NUMid, and never allocated
|
||||||
|
* from: the portal cannot resolve those ids. Every query here filters on BOTH
|
||||||
|
* columns for that reason, never on sourceTable alone. A customer can also hold
|
||||||
|
* more than one insurance ref — 16 of them do, where several insurance rows
|
||||||
|
* folded into one customer — so those are tested with EXISTS rather than joined.
|
||||||
|
*/
|
||||||
|
const POOL = {
|
||||||
|
sourceSystem: UTILITIES_SYSTEM,
|
||||||
|
sourceTable: UTILITIES_TABLE,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AllocationOrigin = "existing" | "new" | "recycled";
|
||||||
|
|
||||||
|
export interface Allocation {
|
||||||
|
numid: string;
|
||||||
|
origin: AllocationOrigin;
|
||||||
|
/** Set only on a recycle — the customer the id was taken from. */
|
||||||
|
previousCustomerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMids that were created and never used, safe for an allocator to take.
|
||||||
|
*
|
||||||
|
* THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way
|
||||||
|
* it does. "Owns no rows" matches nobody: migration gave all 1,171 NUMids a
|
||||||
|
* property and a transaction. "No transaction in N years" also matches nobody:
|
||||||
|
* every customer carries a synthetic Jan-1 opening-balance row, so everyone
|
||||||
|
* looks active in the current year. That row has to be subtracted before any
|
||||||
|
* activity test means anything, which is what `bf` does below.
|
||||||
|
*
|
||||||
|
* The balance-forward row is matched in two shapes on purpose.
|
||||||
|
* transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD';
|
||||||
|
* databases loaded before that change carry the same rows with typeId NULL,
|
||||||
|
* dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors
|
||||||
|
* nothing on such a database and turns the balance test into a raw lifetime sum
|
||||||
|
* — the double-count that read the whole book as +20.6M MXN in credit before
|
||||||
|
* d173c9e, and which here would mark live customers as empty.
|
||||||
|
*
|
||||||
|
* Services are tested as "any service" rather than "any ACTIVE service": a
|
||||||
|
* deactivated water account is still a record of somebody having lived behind
|
||||||
|
* this id.
|
||||||
|
*
|
||||||
|
* Kept in step with scripts/numid-audit.sql, which reports the same tier for a
|
||||||
|
* human. That script is the reporting copy of this rule; change both together.
|
||||||
|
*/
|
||||||
|
const EMPTY_NUMID_SQL = Prisma.sql`
|
||||||
|
WITH bf AS (
|
||||||
|
SELECT t.id, t.customerId
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
|
WHERE t.voidedAt IS NULL
|
||||||
|
AND (
|
||||||
|
tt.nameEn = 'BALANCE FORWARD'
|
||||||
|
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||||
|
AND t.legacySourceTable = 'datos2')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId
|
||||||
|
FROM customer_legacy_refs r
|
||||||
|
JOIN customers c ON c.id = r.customerId
|
||||||
|
WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE}
|
||||||
|
AND (c.email IS NULL OR c.email = '')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||||
|
AND t.id NOT IN (SELECT id FROM bf))
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM property_services ps
|
||||||
|
JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM trust_accounts ta
|
||||||
|
JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i
|
||||||
|
WHERE i.customerId = c.id AND i.sourceSystem = 'insurance')
|
||||||
|
ORDER BY CAST(r.legacyId AS UNSIGNED)`;
|
||||||
|
|
||||||
|
interface EmptyRow {
|
||||||
|
numid: string;
|
||||||
|
refId: string;
|
||||||
|
customerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NumidService {
|
||||||
|
private readonly logger = new Logger(NumidService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The customer's portal id, or null if they have none. */
|
||||||
|
async current(customerId: string): Promise<string | null> {
|
||||||
|
const ref = await this.prisma.customerLegacyRef.findFirst({
|
||||||
|
where: { customerId, ...POOL },
|
||||||
|
select: { legacyId: true },
|
||||||
|
});
|
||||||
|
return ref?.legacyId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reusable ids, lowest first. Empty unless recycling is switched on. */
|
||||||
|
async emptyCandidates(): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||||
|
return rows.map((r) => r.numid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a customer a portal NUMid.
|
||||||
|
*
|
||||||
|
* Idempotent: a customer who already holds one gets it back rather than a
|
||||||
|
* second id, so a double-clicked button cannot fork an identity.
|
||||||
|
*/
|
||||||
|
async allocate(customerId: string): Promise<Allocation> {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
select: { id: true, archivedAt: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
|
if (customer.archivedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"No se puede asignar un número de portal a un cliente archivado",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.current(customerId);
|
||||||
|
if (existing) return { numid: existing, origin: "existing" };
|
||||||
|
|
||||||
|
const recycle = await this.recycleEnabled();
|
||||||
|
|
||||||
|
// Two writers can pick the same id between the read and the write. The
|
||||||
|
// unique key on (sourceSystem, sourceTable, legacyId) is what actually
|
||||||
|
// decides the winner; the loser retries and takes the next id rather than
|
||||||
|
// silently overwriting. Bounded so a genuinely wedged pool fails loudly.
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
if (recycle) {
|
||||||
|
const recycled = await this.tryRecycle(customerId);
|
||||||
|
if (recycled) return recycled;
|
||||||
|
}
|
||||||
|
return await this.allocateNext(customerId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueViolation(error)) throw error;
|
||||||
|
this.logger.warn(
|
||||||
|
`NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ConflictException(
|
||||||
|
"No se pudo asignar un número de portal; intente de nuevo",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the recycle tier is live.
|
||||||
|
*
|
||||||
|
* Off by default, and that default is the safe one while Access is still the
|
||||||
|
* utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a
|
||||||
|
* `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId
|
||||||
|
* (transform_customers.py:327) — so an id recycled today is silently handed
|
||||||
|
* back to its Access owner on the next sync, and the customer who was given it
|
||||||
|
* loses their portal identity. Turn this on once utilities has cut over, or
|
||||||
|
* for ids that have been deleted at the source.
|
||||||
|
*/
|
||||||
|
private async recycleEnabled(): Promise<boolean> {
|
||||||
|
const { value } = await this.settings.numidRecycleEmpty();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-point the lowest empty id at this customer. Null when none is free. */
|
||||||
|
private async tryRecycle(customerId: string): Promise<Allocation | null> {
|
||||||
|
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||||
|
for (const row of rows) {
|
||||||
|
// Guarded by the owner we just read: if anything moved the ref in the
|
||||||
|
// meantime the update matches nothing and we fall through to the next
|
||||||
|
// candidate rather than stealing an id that is no longer empty.
|
||||||
|
const moved = await this.prisma.customerLegacyRef.updateMany({
|
||||||
|
where: { id: row.refId, customerId: row.customerId },
|
||||||
|
data: { customerId },
|
||||||
|
});
|
||||||
|
if (moved.count === 1) {
|
||||||
|
this.logger.log(
|
||||||
|
`NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
numid: row.numid,
|
||||||
|
origin: "recycled",
|
||||||
|
previousCustomerId: row.customerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One past the highest id in the pool. */
|
||||||
|
private async allocateNext(customerId: string): Promise<Allocation> {
|
||||||
|
const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>(
|
||||||
|
// MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain
|
||||||
|
// MAX returns '999' as the highest of 1,171 rows and the allocator hands
|
||||||
|
// out an id that is already taken.
|
||||||
|
Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max
|
||||||
|
FROM customer_legacy_refs
|
||||||
|
WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`,
|
||||||
|
);
|
||||||
|
const numid = String(Number(max ?? 0) + 1);
|
||||||
|
await this.prisma.customerLegacyRef.create({
|
||||||
|
data: { customerId, ...POOL, legacyId: numid },
|
||||||
|
});
|
||||||
|
return { numid, origin: "new" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export class UpdateCustomerDto {
|
|||||||
@IsOptional() @IsString() mobile?: string;
|
@IsOptional() @IsString() mobile?: string;
|
||||||
@IsOptional() @IsString() fax?: string;
|
@IsOptional() @IsString() fax?: string;
|
||||||
@IsOptional() @IsEmail() email?: string;
|
@IsOptional() @IsEmail() email?: string;
|
||||||
|
@IsOptional() @IsBoolean() emailOptOut?: boolean;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
@IsOptional() @IsString() identificationType?: string;
|
@IsOptional() @IsString() identificationType?: string;
|
||||||
@IsOptional() @IsString() identificationNumber?: string;
|
@IsOptional() @IsString() identificationNumber?: string;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Global, Module } from "@nestjs/common";
|
||||||
|
import { MailService } from "./mail.service";
|
||||||
|
|
||||||
|
/** Global so any feature module can inject MailService without re-importing.
|
||||||
|
* Matches the StorageService pattern: env-driven, null when unconfigured,
|
||||||
|
* and never blocks API boot. Notifications use it; renewals reuse it.
|
||||||
|
* ConfigService comes from the global ConfigModule in AppModule. */
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [MailService],
|
||||||
|
exports: [MailService],
|
||||||
|
})
|
||||||
|
export class MailModule {}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import {
|
||||||
|
SESv2Client,
|
||||||
|
SendEmailCommand,
|
||||||
|
SendEmailCommandInput,
|
||||||
|
SendEmailCommandOutput,
|
||||||
|
} from "@aws-sdk/client-sesv2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound mail transport. Amazon SES — the channel the office already uses
|
||||||
|
* for bulk notification, per docs/INSURANCE_FEATURES_SPEC.md §1.3 (the
|
||||||
|
* renewal-notice spec settled on SES for the same reason: established sender
|
||||||
|
* reputation, existing IAM, negligible incremental cost at our volume).
|
||||||
|
*
|
||||||
|
* Mirrors `StorageService` exactly: env-driven config, null client when
|
||||||
|
* unconfigured, `ServiceUnavailableException` on use, never blocks API boot.
|
||||||
|
* When the env vars are missing AND we're in dev/test we fall back to a
|
||||||
|
* console-logging transport so the NotificationsService can be exercised
|
||||||
|
* end-to-end without SES credentials — a missing mail setup in production
|
||||||
|
* still throws, so a real deployment can't accidentally no-op its sends.
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* SES_REGION — required when client is configured
|
||||||
|
* SES_ACCESS_KEY / SES_SECRET_KEY — required
|
||||||
|
* SES_FROM — verified sending identity (e.g. mail@jorgecuadros.com)
|
||||||
|
* SES_FROM_NAME — display name, optional
|
||||||
|
* SES_CONFIGURATION_SET — optional, for bounce/complaint event publishing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SendArgs {
|
||||||
|
to: string;
|
||||||
|
/** Optional display name; SES will not display it for "to" but we keep it on
|
||||||
|
* the log row so customer-facing audit reads naturally. */
|
||||||
|
toName?: string;
|
||||||
|
subject: string;
|
||||||
|
/** HTML body. The four notification jobs all produce HTML. */
|
||||||
|
html: string;
|
||||||
|
/** Optional override of the configured From; rare but useful for the
|
||||||
|
* trust-payment test mail to a different identity. */
|
||||||
|
from?: string;
|
||||||
|
fromName?: string;
|
||||||
|
/** Marker header kept on every send so a downstream mail-log search for
|
||||||
|
* "X-Tracking: 1" surfaces only this app's outbound traffic. The legacy
|
||||||
|
* PHP sendEmail() always set it; we keep the convention. */
|
||||||
|
xTracking?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendResult {
|
||||||
|
/** SES MessageId (or our mock prefix in dev). Stored verbatim on the
|
||||||
|
* notification log row so a SES bounce/complaint webhook can be matched
|
||||||
|
* back to the exact send. */
|
||||||
|
messageId: string;
|
||||||
|
/** Truncated SES response payload (or empty in dev). 4k cap matches the
|
||||||
|
* notification log column width. */
|
||||||
|
response: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MailService {
|
||||||
|
private readonly logger = new Logger(MailService.name);
|
||||||
|
private readonly client: SESv2Client | null;
|
||||||
|
private readonly fromAddress: string | null;
|
||||||
|
private readonly fromName: string;
|
||||||
|
private readonly configurationSet: string | undefined;
|
||||||
|
private readonly devMode: boolean;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const region = config.get<string>("SES_REGION");
|
||||||
|
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
|
||||||
|
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
|
||||||
|
this.fromAddress =
|
||||||
|
config.get<string>("SES_FROM") ??
|
||||||
|
config.get<string>("MAIL_FROM") ??
|
||||||
|
null;
|
||||||
|
this.fromName =
|
||||||
|
config.get<string>("SES_FROM_NAME") ??
|
||||||
|
config.get<string>("MAIL_FROM_NAME") ??
|
||||||
|
"Information Server";
|
||||||
|
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET");
|
||||||
|
// Dev fallback: when nothing is configured, log sends to stdout instead
|
||||||
|
// of throwing. Lets the API boot in a fresh checkout and lets the
|
||||||
|
// notifications UI show "0 sent" meaningfully on `debug=1`. Production
|
||||||
|
// (NODE_ENV !== development) still requires real config.
|
||||||
|
this.devMode = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
if (!region || !accessKeyId || !secretAccessKey || !this.fromAddress) {
|
||||||
|
if (!this.devMode) {
|
||||||
|
this.logger.warn(
|
||||||
|
"SES not configured (SES_REGION / SES_ACCESS_KEY / SES_SECRET_KEY / SES_FROM). " +
|
||||||
|
"Outbound mail will throw ServiceUnavailableException.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.client = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = new SESv2Client({
|
||||||
|
region,
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`SES mail client configured (region=${region}, from=${this.fromAddress}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the deployment has a real mail transport. Callers use this to
|
||||||
|
* refuse work up front — a mass-notification job that throws on its
|
||||||
|
* first send half-completes and the log is unrecoverable, so we fail
|
||||||
|
* fast at the controller. */
|
||||||
|
get available(): boolean {
|
||||||
|
return this.client !== null || this.devMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the underlying transport is the dev console-log fallback. */
|
||||||
|
get isDevFallback(): boolean {
|
||||||
|
return this.client === null && this.devMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private require(): SESv2Client {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El envío de correo no está configurado.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a single HTML email. The dev fallback logs to stdout and returns a
|
||||||
|
* synthetic `dev-<timestamp>` message id; the real transport talks to SES
|
||||||
|
* and returns the SES MessageId.
|
||||||
|
*
|
||||||
|
* Throws `ServiceUnavailableException` when no transport is configured and
|
||||||
|
* we are not in dev — the caller (NotificationsService) catches and records
|
||||||
|
* it on the log row so a failed sweep produces a coherent audit trail
|
||||||
|
* instead of an aborted one.
|
||||||
|
*/
|
||||||
|
async send(args: SendArgs): Promise<SendResult> {
|
||||||
|
const from = `${args.fromName ?? this.fromName} <${
|
||||||
|
args.from ?? this.fromAddress ?? ""
|
||||||
|
}>`.trim();
|
||||||
|
|
||||||
|
if (!this.client) {
|
||||||
|
if (!this.devMode) this.require();
|
||||||
|
const fakeId = `dev-${Date.now().toString(36)}-${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.slice(2, 8)}`;
|
||||||
|
this.logger.log(
|
||||||
|
`[dev-mail] to=${args.to} subject="${args.subject}" id=${fakeId} ` +
|
||||||
|
`len=${args.html.length}`,
|
||||||
|
);
|
||||||
|
return { messageId: fakeId, response: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: SendEmailCommandInput = {
|
||||||
|
FromEmailAddress: from,
|
||||||
|
Destination: { ToAddresses: [args.to] },
|
||||||
|
Content: {
|
||||||
|
Simple: {
|
||||||
|
Subject: { Data: args.subject, Charset: "UTF-8" },
|
||||||
|
Body: { Html: { Data: args.html, Charset: "UTF-8" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(this.configurationSet
|
||||||
|
? { ConfigurationSetName: this.configurationSet }
|
||||||
|
: {}),
|
||||||
|
...(args.xTracking
|
||||||
|
? {
|
||||||
|
EmailTags: [
|
||||||
|
{ Name: "X-Tracking", Value: args.xTracking },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const out: SendEmailCommandOutput = await this.client.send(
|
||||||
|
new SendEmailCommand(input),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
messageId: out.MessageId ?? "",
|
||||||
|
response: JSON.stringify({ MessageId: out.MessageId ?? null }).slice(0, 4096),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-1
@@ -60,7 +60,19 @@ async function bootstrap() {
|
|||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
|
|
||||||
app.enableCors({ credentials: true, origin: process.env.WEB_ORIGIN ?? "http://localhost:3000" });
|
// The same deployment is reached under several origins — the office LAN IP,
|
||||||
|
// the tailnet name, the demo domain — and the browser derives the API origin
|
||||||
|
// from whichever one served the page (apps/web/src/lib/api.ts). So WEB_ORIGIN
|
||||||
|
// is a comma-separated LIST, not a single value. A request whose Origin is
|
||||||
|
// not listed gets no CORS headers and the credentialed fetch fails, so add an
|
||||||
|
// entry when a new way of reaching the app is introduced. Same-origin setups
|
||||||
|
// (web and API behind one proxy) never hit CORS at all.
|
||||||
|
const webOrigins = (process.env.WEB_ORIGIN ?? "http://localhost:3000")
|
||||||
|
.split(",")
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
app.enableCors({ credentials: true, origin: webOrigins });
|
||||||
|
|
||||||
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { NotificationLogService } from "./notification-log.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just the log writer, so a feature that sends mail can record it without
|
||||||
|
* importing `NotificationsModule` (which carries the four bulk-job pipelines
|
||||||
|
* and their controller). Imported by `NotificationsModule` and
|
||||||
|
* `RenewalsModule`.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [NotificationLogService],
|
||||||
|
exports: [NotificationLogService],
|
||||||
|
})
|
||||||
|
export class NotificationLogModule {}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
EmailNotificationServicio,
|
||||||
|
EmailNotificationStatus,
|
||||||
|
EmailNotificationType,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { AttemptStatus } from "./notification.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single writer for `email_notification_log`.
|
||||||
|
*
|
||||||
|
* Extracted out of `NotificationsService` so the renewal sweep can write the
|
||||||
|
* same rows as the four bulk jobs without pulling that service (and its four
|
||||||
|
* job pipelines) into `RenewalsModule`. Every outbound email the platform
|
||||||
|
* sends goes through here, which is what makes /notificaciones' "Registro de
|
||||||
|
* envíos" complete rather than per-feature.
|
||||||
|
*/
|
||||||
|
export interface NotificationLogEntry {
|
||||||
|
notificationType: EmailNotificationType;
|
||||||
|
servicio: EmailNotificationServicio;
|
||||||
|
/** Defaults to now(). Pass it when the row must line up exactly with
|
||||||
|
* another record of the same send (the renewal sweep pins it to
|
||||||
|
* `RenewalNotice.sentAt`). */
|
||||||
|
sendDate?: Date;
|
||||||
|
/** Type-dependent discriminator — see the `level` doc on the Prisma model.
|
||||||
|
* 0/1 for ACCOUNT_STATUS, the generation for RENEWAL_NOTICE. */
|
||||||
|
level?: number | null;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
subject: string;
|
||||||
|
bodySnapshot: string;
|
||||||
|
bodyRequestUrl?: string;
|
||||||
|
status: AttemptStatus;
|
||||||
|
debug: boolean;
|
||||||
|
providerMessageId?: string;
|
||||||
|
providerResponse?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `providerResponse` is a VARCHAR(191); anything longer is a provider dump
|
||||||
|
* we only need the head of. Errors go to the TEXT `error` column and get
|
||||||
|
* the 4k cap the schema documents. */
|
||||||
|
const PROVIDER_RESPONSE_MAX = 180;
|
||||||
|
const ERROR_MAX = 4096;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationLogService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async record(entry: NotificationLogEntry): Promise<void> {
|
||||||
|
await this.prisma.emailNotificationLog.create({
|
||||||
|
data: {
|
||||||
|
notificationType: entry.notificationType,
|
||||||
|
servicio: entry.servicio,
|
||||||
|
...(entry.sendDate && { sendDate: entry.sendDate }),
|
||||||
|
level: entry.level ?? null,
|
||||||
|
customerId: entry.customerId,
|
||||||
|
customerName: entry.customerName,
|
||||||
|
customerEmail: entry.customerEmail,
|
||||||
|
subject: entry.subject,
|
||||||
|
bodySnapshot: entry.bodySnapshot,
|
||||||
|
bodyRequestUrl: entry.bodyRequestUrl ?? null,
|
||||||
|
debug: entry.debug,
|
||||||
|
providerMessageId: entry.providerMessageId ?? null,
|
||||||
|
providerResponse:
|
||||||
|
entry.providerResponse?.slice(0, PROVIDER_RESPONSE_MAX) ?? null,
|
||||||
|
status: entry.status as EmailNotificationStatus,
|
||||||
|
error: entry.error?.slice(0, ERROR_MAX) ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
|
import { NotificationScheduleService } from "./notification-schedule.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just the cadence registry, split out for the same reason as
|
||||||
|
* `NotificationLogModule`: both `NotificationsModule` and `RenewalsModule`
|
||||||
|
* need it, and neither may import the other.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [SettingsModule],
|
||||||
|
providers: [NotificationScheduleService],
|
||||||
|
exports: [NotificationScheduleService],
|
||||||
|
})
|
||||||
|
export class NotificationScheduleModule {}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { SchedulerRegistry } from "@nestjs/schedule";
|
||||||
|
import { CronJob } from "cron";
|
||||||
|
import { SettingsService } from "../settings/settings.service";
|
||||||
|
import type { ResolvedSetting } from "../settings/settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the two automatic envíos run.
|
||||||
|
*
|
||||||
|
* Both halves of /notificaciones used to be hardcoded: pólizas swept at 06:00
|
||||||
|
* from a `@Cron` decorator, servicios had no automatic run at all and had to
|
||||||
|
* be clicked. Neither could be changed without a redeploy. This service owns
|
||||||
|
* the cadence for both, stores it in `app_settings`, and re-installs the job
|
||||||
|
* the moment an operator saves — no restart.
|
||||||
|
*
|
||||||
|
* The owning services register their handler at boot rather than this service
|
||||||
|
* importing them: `NotificationsService` and `RenewalsService` would otherwise
|
||||||
|
* have to be injected here, and this file is imported by both.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SCHEDULE_TIME_ZONE = "America/Tijuana";
|
||||||
|
|
||||||
|
export type ScheduleKind = "servicios" | "polizas";
|
||||||
|
|
||||||
|
export const SCHEDULE_KINDS: ScheduleKind[] = ["servicios", "polizas"];
|
||||||
|
|
||||||
|
export interface NotificationSchedule {
|
||||||
|
enabled: boolean;
|
||||||
|
/** Local hour/minute in `SCHEDULE_TIME_ZONE`, not UTC — the office thinks
|
||||||
|
* in Tijuana time and DST would otherwise drift the run by an hour. */
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
/** 0 = Sunday … 6 = Saturday. Empty means every day. */
|
||||||
|
weekdays: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedSchedule extends ResolvedSetting<NotificationSchedule> {
|
||||||
|
/** The cron expression the value compiles to, shown in the UI so the
|
||||||
|
* operator can see exactly what was installed. */
|
||||||
|
cron: string;
|
||||||
|
/** Next fire time, or null when disabled. */
|
||||||
|
nextRun: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defaults preserve what each half did before this existed: pólizas keeps its
|
||||||
|
* 06:00 daily sweep, servicios stays OFF. Turning a mass send on is an
|
||||||
|
* operator decision — a default that starts mailing 260 customers on its own
|
||||||
|
* after a deploy is not a default, it's an incident.
|
||||||
|
*/
|
||||||
|
const DEFAULTS: Record<ScheduleKind, NotificationSchedule> = {
|
||||||
|
servicios: { enabled: false, hour: 7, minute: 0, weekdays: [1, 3, 5] },
|
||||||
|
polizas: { enabled: true, hour: 6, minute: 0, weekdays: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Human label used in log lines and audit entries. */
|
||||||
|
export const SCHEDULE_LABELS: Record<ScheduleKind, string> = {
|
||||||
|
servicios: "envíos de servicios",
|
||||||
|
polizas: "avisos de renovación",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function scheduleCron(schedule: NotificationSchedule): string {
|
||||||
|
const dow = schedule.weekdays.length
|
||||||
|
? [...new Set(schedule.weekdays)].sort((a, b) => a - b).join(",")
|
||||||
|
: "*";
|
||||||
|
return `${schedule.minute} ${schedule.hour} * * ${dow}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject anything that would compile to a cron we can't install. Returns the
|
||||||
|
* normalized value, or a message naming the offending field. */
|
||||||
|
export function parseSchedule(
|
||||||
|
raw: unknown,
|
||||||
|
): { ok: true; value: NotificationSchedule } | { ok: false; error: string } {
|
||||||
|
const v = raw as Partial<NotificationSchedule> | null;
|
||||||
|
if (!v || typeof v !== "object") return { ok: false, error: "Horario inválido." };
|
||||||
|
const hour = Number(v.hour);
|
||||||
|
const minute = Number(v.minute);
|
||||||
|
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||||
|
return { ok: false, error: "La hora debe estar entre 0 y 23." };
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||||
|
return { ok: false, error: "Los minutos deben estar entre 0 y 59." };
|
||||||
|
}
|
||||||
|
const weekdays = Array.isArray(v.weekdays) ? v.weekdays.map(Number) : [];
|
||||||
|
if (weekdays.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
|
||||||
|
return { ok: false, error: "Los días deben estar entre 0 (domingo) y 6." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
enabled: !!v.enabled,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
weekdays: [...new Set(weekdays)].sort((a, b) => a - b),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationScheduleService {
|
||||||
|
private readonly logger = new Logger(NotificationScheduleService.name);
|
||||||
|
private readonly handlers = new Map<ScheduleKind, () => Promise<unknown>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
private readonly registry: SchedulerRegistry,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once per kind at boot by the service that owns the sweep. Installs
|
||||||
|
* the job immediately so a freshly started process honours the stored
|
||||||
|
* cadence without waiting for someone to open the UI.
|
||||||
|
*/
|
||||||
|
async register(kind: ScheduleKind, handler: () => Promise<unknown>) {
|
||||||
|
this.handlers.set(kind, handler);
|
||||||
|
await this.apply(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(kind: ScheduleKind): Promise<ResolvedSchedule> {
|
||||||
|
const resolved = await this.settings.notificationSchedule(
|
||||||
|
kind,
|
||||||
|
DEFAULTS[kind],
|
||||||
|
);
|
||||||
|
const cron = scheduleCron(resolved.value);
|
||||||
|
return { ...resolved, cron, nextRun: this.nextRun(kind) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<Record<ScheduleKind, ResolvedSchedule>> {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
SCHEDULE_KINDS.map(async (k) => [k, await this.get(k)] as const),
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries) as Record<ScheduleKind, ResolvedSchedule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(
|
||||||
|
kind: ScheduleKind,
|
||||||
|
schedule: NotificationSchedule,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ResolvedSchedule> {
|
||||||
|
await this.settings.setNotificationSchedule(kind, schedule, userId);
|
||||||
|
await this.apply(kind);
|
||||||
|
return this.get(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** (Re)install the cron job for one kind from whatever is stored now. */
|
||||||
|
private async apply(kind: ScheduleKind): Promise<void> {
|
||||||
|
const handler = this.handlers.get(kind);
|
||||||
|
if (!handler) return;
|
||||||
|
|
||||||
|
this.remove(kind);
|
||||||
|
|
||||||
|
const { value } = await this.settings.notificationSchedule(
|
||||||
|
kind,
|
||||||
|
DEFAULTS[kind],
|
||||||
|
);
|
||||||
|
if (!value.enabled) {
|
||||||
|
this.logger.log(`Horario de ${SCHEDULE_LABELS[kind]}: desactivado.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cron = scheduleCron(value);
|
||||||
|
const job = new CronJob(
|
||||||
|
cron,
|
||||||
|
() => {
|
||||||
|
void handler().catch((error) =>
|
||||||
|
this.logger.error(
|
||||||
|
`Falló la corrida programada de ${SCHEDULE_LABELS[kind]}: ` +
|
||||||
|
`${(error as Error).message}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
SCHEDULE_TIME_ZONE,
|
||||||
|
);
|
||||||
|
this.registry.addCronJob(this.jobName(kind), job);
|
||||||
|
job.start();
|
||||||
|
this.logger.log(
|
||||||
|
`Horario de ${SCHEDULE_LABELS[kind]}: ${cron} (${SCHEDULE_TIME_ZONE}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private remove(kind: ScheduleKind): void {
|
||||||
|
const name = this.jobName(kind);
|
||||||
|
// `deleteCronJob` throws when the job was never installed, which is the
|
||||||
|
// normal case on first apply — presence check instead of try/catch so a
|
||||||
|
// real failure still surfaces.
|
||||||
|
if (!this.registry.doesExist("cron", name)) return;
|
||||||
|
this.registry.getCronJob(name).stop();
|
||||||
|
this.registry.deleteCronJob(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRun(kind: ScheduleKind): string | null {
|
||||||
|
const name = this.jobName(kind);
|
||||||
|
if (!this.registry.doesExist("cron", name)) return null;
|
||||||
|
const next = this.registry.getCronJob(name).nextDate();
|
||||||
|
return next ? next.toJSDate().toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private jobName(kind: ScheduleKind): string {
|
||||||
|
return `notification-schedule:${kind}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { parseSchedule, scheduleCron } from "./notification-schedule.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cadence editor's only sharp edge: a stored value compiles to a cron
|
||||||
|
* expression that the scheduler installs verbatim. A malformed one either
|
||||||
|
* throws at install time (taking the sweep down) or silently installs the
|
||||||
|
* wrong cadence, so validation happens before anything is written.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("scheduleCron", () => {
|
||||||
|
it("compiles a daily schedule with no weekday filter", () => {
|
||||||
|
expect(
|
||||||
|
scheduleCron({ enabled: true, hour: 6, minute: 0, weekdays: [] }),
|
||||||
|
).toBe("0 6 * * *");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compiles the legacy Mon/Wed/Fri cadence, sorted and de-duplicated", () => {
|
||||||
|
expect(
|
||||||
|
scheduleCron({ enabled: true, hour: 7, minute: 30, weekdays: [5, 1, 3, 1] }),
|
||||||
|
).toBe("30 7 * * 1,3,5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseSchedule", () => {
|
||||||
|
it("normalizes weekdays and coerces enabled to a boolean", () => {
|
||||||
|
const parsed = parseSchedule({
|
||||||
|
enabled: 1,
|
||||||
|
hour: 6,
|
||||||
|
minute: 0,
|
||||||
|
weekdays: [3, 1, 3],
|
||||||
|
});
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
ok: true,
|
||||||
|
value: { enabled: true, hour: 6, minute: 0, weekdays: [1, 3] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults a missing weekday list to every day", () => {
|
||||||
|
const parsed = parseSchedule({ enabled: true, hour: 0, minute: 0 });
|
||||||
|
expect(parsed.ok && parsed.value.weekdays).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ enabled: true, hour: 24, minute: 0 }, "hora"],
|
||||||
|
[{ enabled: true, hour: 6, minute: 60 }, "minutos"],
|
||||||
|
[{ enabled: true, hour: 6, minute: 0, weekdays: [7] }, "días"],
|
||||||
|
[{ enabled: true, hour: 6.5, minute: 0 }, "hora"],
|
||||||
|
])("rejects %p", (input, field) => {
|
||||||
|
const parsed = parseSchedule(input);
|
||||||
|
expect(parsed.ok).toBe(false);
|
||||||
|
expect(!parsed.ok && parsed.error.toLowerCase()).toContain(field);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object", () => {
|
||||||
|
expect(parseSchedule(null).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
EmailNotificationServicio,
|
||||||
|
EmailNotificationType,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { IsBoolean, IsEnum, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where `debug` sends everything. The PHP used `rmancinas@freakma.net`;
|
||||||
|
* same here. Exported because the flag is platform-wide — the renewal
|
||||||
|
* notices honour it too, and two copies of this address would eventually
|
||||||
|
* disagree.
|
||||||
|
*/
|
||||||
|
export const DEBUG_RECIPIENT = "rmancinas@freakma.net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared flags for every notification send — the four servicios jobs and
|
||||||
|
* the pólizas renewal notices alike. Every endpoint takes the same shape
|
||||||
|
* so the UI can offer one set of switches for the whole screen; each flag
|
||||||
|
* is documented inline so the per-job semantics are obvious in one place.
|
||||||
|
*
|
||||||
|
* `debug` — replace every recipient with `DEBUG_RECIPIENT` so a
|
||||||
|
* real customer never receives mail during a test run.
|
||||||
|
* Logged on every row. On the renewal side a debug send
|
||||||
|
* also does NOT write the `RenewalNotice` row, so a test
|
||||||
|
* can't gate the letter the customer is still owed.
|
||||||
|
* `ignoreDayRestriction` — Job 3 only: bypass the Mon/Wed/Fri (red) and
|
||||||
|
* Wed-only (yellow) day gates. Off by default so
|
||||||
|
* the on-demand sweep behaves like the legacy
|
||||||
|
* script.
|
||||||
|
* `useEmailLimit` — Job 3 only: pause the sweep 1 hour after 100
|
||||||
|
* sends (a vestigial SMTP-era throttling limit).
|
||||||
|
* Off by default; SES does not need it.
|
||||||
|
*/
|
||||||
|
export class NotificationFlagsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
debug?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
ignoreDayRestriction?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
useEmailLimit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What we know at job-end and put on the wire. Field names match the
|
||||||
|
* legacy PHP scripts' `echo json_encode(...)` so a downstream log scraper
|
||||||
|
* that already parses `notificationType: "sendPaymentConfirmation"`
|
||||||
|
* keeps working — see `~/Documents/Claude-Memory/email-notifications-spec.md`
|
||||||
|
* for the verbatim PHP shapes. Specifically: Job 1 reports
|
||||||
|
* `notificationType: "sendPaymentConfirmation"` (the legacy literal), and
|
||||||
|
* uses field `result` instead of `request`; the other three use
|
||||||
|
* `notificationType` matching the script's purpose.
|
||||||
|
*
|
||||||
|
* Every variant carries `sent/skipped/failed/debug` for the audit log;
|
||||||
|
* the legacy fields stay where they were so the response shape is
|
||||||
|
* exactly backward-compatible.
|
||||||
|
*/
|
||||||
|
export type NotificationJobResponse =
|
||||||
|
| {
|
||||||
|
// Job 1
|
||||||
|
result: "success";
|
||||||
|
notificationType: "sendPaymentConfirmation";
|
||||||
|
reason: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "OUTSTANDING_PAYMENT";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Job 2
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendPaymentConfirmation";
|
||||||
|
confirmationSent: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "PAYMENT_CONFIRMATION";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Job 3 — sent/skipped/failed included so the audit log can record
|
||||||
|
// totals without depending on (red+yellow) alone.
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendAccountStatus";
|
||||||
|
statusSent: string;
|
||||||
|
statusReport: string;
|
||||||
|
statusCode: 200;
|
||||||
|
red: number;
|
||||||
|
yellow: number;
|
||||||
|
total: number;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "ACCOUNT_STATUS";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
// Job 4
|
||||||
|
request: "success";
|
||||||
|
notificationType: "sendTrustPaymentConfirmation";
|
||||||
|
confirmationSent: string;
|
||||||
|
statusCode: 200;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
debug: boolean;
|
||||||
|
type: "TRUST_PAYMENT_CONFIRMATION";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The four jobs, in the order the "ejecutar todos" sweep runs them. */
|
||||||
|
export type NotificationJobKind =
|
||||||
|
| "outstanding"
|
||||||
|
| "payment"
|
||||||
|
| "account"
|
||||||
|
| "trust";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of the run-all sweep. A job that throws does NOT abort the
|
||||||
|
* sweep — it is recorded with `ok: false` and the next job still runs, so a
|
||||||
|
* single bad query can't silently block the other three envíos.
|
||||||
|
*/
|
||||||
|
export interface NotificationRunAllJobResult {
|
||||||
|
kind: NotificationJobKind;
|
||||||
|
ok: boolean;
|
||||||
|
result?: NotificationJobResponse;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate response for `POST /notifications/run-all`. `sent/skipped/failed`
|
||||||
|
* are the sums across every job that completed; `jobs` keeps each job's own
|
||||||
|
* verbatim legacy response so the UI can still show per-job detail.
|
||||||
|
*/
|
||||||
|
export interface NotificationRunAllResponse {
|
||||||
|
request: "success";
|
||||||
|
notificationType: "runAllNotifications";
|
||||||
|
statusCode: 200;
|
||||||
|
debug: boolean;
|
||||||
|
sent: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
/** Jobs that threw — sweep continued past them. */
|
||||||
|
errors: number;
|
||||||
|
jobs: NotificationRunAllJobResult[];
|
||||||
|
type: "RUN_ALL";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalized record for a single send attempt, fed by all four jobs. */
|
||||||
|
export interface SendAttempt {
|
||||||
|
notificationType: EmailNotificationType;
|
||||||
|
servicio: EmailNotificationServicio;
|
||||||
|
customerId: string | null;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
subject: string;
|
||||||
|
bodySnapshot: string;
|
||||||
|
bodyRequestUrl?: string;
|
||||||
|
/** Account-status-only — 0 yellow / 1 red. Null on the other three jobs. */
|
||||||
|
level?: 0 | 1;
|
||||||
|
/** Account-status-only — DEBAJO DEL TIPO / EN ROJO. */
|
||||||
|
historyTipo?: string;
|
||||||
|
historyBalance?: string;
|
||||||
|
historyTCambio?: string;
|
||||||
|
historySolicitado?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status enum values, mirrored from `EmailNotificationStatus`. */
|
||||||
|
export type AttemptStatus = "SENT" | "FAILED" | "SKIPPED_NO_EMAIL" | "SKIPPED_GATE";
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import {
|
||||||
|
EmailNotificationServicio,
|
||||||
|
EmailNotificationStatus,
|
||||||
|
EmailNotificationType,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
} from "class-validator";
|
||||||
|
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 { invalidEmails, SettingsService } from "../settings/settings.service";
|
||||||
|
import {
|
||||||
|
NotificationScheduleService,
|
||||||
|
parseSchedule,
|
||||||
|
SCHEDULE_KINDS,
|
||||||
|
ScheduleKind,
|
||||||
|
} from "./notification-schedule.service";
|
||||||
|
import { NotificationFlagsDto } from "./notification.types";
|
||||||
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
|
||||||
|
/** Same flags for every job, query-string OR body (the PHP scripts took
|
||||||
|
* both via STDIN vs HTTP-CGI — we accept either for parity). */
|
||||||
|
class RunJobDto extends NotificationFlagsDto {}
|
||||||
|
|
||||||
|
class ListLogDto {
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
|
||||||
|
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType;
|
||||||
|
/** One or more servicios, comma-separated. The /notificaciones tabs each
|
||||||
|
* read their own slice of the one log: Servicios passes
|
||||||
|
* `CUSTOMERS,TRUST`, Pólizas passes `POLICIES`. Omitted = every servicio. */
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === "string"
|
||||||
|
? value.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: value,
|
||||||
|
)
|
||||||
|
@IsEnum(EmailNotificationServicio, { each: true })
|
||||||
|
servicio?: EmailNotificationServicio[];
|
||||||
|
@IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus;
|
||||||
|
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty array is valid and means "send no summaries" — the cap only
|
||||||
|
* exists so a paste accident can't write an unbounded blob. */
|
||||||
|
class AdminEmailsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(50)
|
||||||
|
@IsString({ each: true })
|
||||||
|
emails!: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cadence of one automatic envío. Ranges are re-checked by `parseSchedule`,
|
||||||
|
* which is also what the scheduler itself uses — the decorators here only
|
||||||
|
* reject wrong *types* so a bad payload fails at the edge. */
|
||||||
|
class ScheduleDto {
|
||||||
|
@IsBoolean() enabled!: boolean;
|
||||||
|
@IsInt() @Min(0) @Max(23) hour!: number;
|
||||||
|
@IsInt() @Min(0) @Max(59) minute!: number;
|
||||||
|
@IsOptional() @IsArray() @IsInt({ each: true }) weekdays?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP surface for the mass-notification jobs. Four trigger endpoints +
|
||||||
|
* two read endpoints (list log, stats). All mutations gated by the
|
||||||
|
* `notification:send` ability so a STAFF user can't accidentally fire a
|
||||||
|
* 260-mail sweep.
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("notifications")
|
||||||
|
export class NotificationsController {
|
||||||
|
constructor(
|
||||||
|
private readonly svc: NotificationsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
private readonly schedule: NotificationScheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- triggers */
|
||||||
|
|
||||||
|
@Post("outstanding-payments")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runOutstanding(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runOutstandingPayments(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.outstanding.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("payment-confirmation")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runPaymentConfirm(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runPaymentConfirmation(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.payment-confirm.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("account-status")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runAccountStatus(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runAccountStatus(flags);
|
||||||
|
// Narrow the discriminated union to the ACCOUNT_STATUS variant before
|
||||||
|
// pulling red/yellow/total — TS can't follow this through `await` alone.
|
||||||
|
if (result.type === "ACCOUNT_STATUS") {
|
||||||
|
void this.audit.log(actingId(req), "notification.account-status.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
red: result.red,
|
||||||
|
yellow: result.yellow,
|
||||||
|
total: result.total,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("trust-payment-confirmation")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runTrustConfirm(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runTrustConfirmation(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.trust-confirm.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all four jobs sequentially with one set of flags. Audited as a
|
||||||
|
* single `notification.run-all.run` entry carrying the aggregate totals
|
||||||
|
* plus each job's outcome — the per-job endpoints are NOT re-audited, so
|
||||||
|
* the log has exactly one row per staff click.
|
||||||
|
*/
|
||||||
|
@Post("run-all")
|
||||||
|
@RequireAbility("notification:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
async runAll(
|
||||||
|
@Body() body: RunJobDto,
|
||||||
|
@Query() query: RunJobDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const flags = { ...query, ...body };
|
||||||
|
const result = await this.svc.runAll(flags);
|
||||||
|
void this.audit.log(actingId(req), "notification.run-all.run", {
|
||||||
|
debug: !!flags.debug,
|
||||||
|
ignoreDayRestriction: !!flags.ignoreDayRestriction,
|
||||||
|
useEmailLimit: !!flags.useEmailLimit,
|
||||||
|
sent: result.sent,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
errors: result.errors,
|
||||||
|
jobs: result.jobs.map((j) => ({ kind: j.kind, ok: j.ok })),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- read views */
|
||||||
|
|
||||||
|
@Get("log")
|
||||||
|
listLog(@Query() q: ListLogDto) {
|
||||||
|
const page = q.page ?? 1;
|
||||||
|
const pageSize = q.pageSize ?? 50;
|
||||||
|
return this.svc.listLog({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
type: q.type,
|
||||||
|
servicio: q.servicio,
|
||||||
|
status: this.mapViewStatus(q.view, q.status),
|
||||||
|
customerId: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("stats")
|
||||||
|
stats(@Query() q: ListLogDto) {
|
||||||
|
return this.svc.stats(q.servicio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- settings */
|
||||||
|
|
||||||
|
/** Who receives the per-job summary email. Readable by any logged-in user
|
||||||
|
* so the UI can show the current list; editing needs `setting:manage`. */
|
||||||
|
@Get("settings/admin-emails")
|
||||||
|
adminEmails() {
|
||||||
|
return this.settings.notificationAdminEmails();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put("settings/admin-emails")
|
||||||
|
@RequireAbility("setting:manage")
|
||||||
|
async setAdminEmails(@Body() dto: AdminEmailsDto, @Req() req: Request) {
|
||||||
|
const emails = dto.emails.map((e) => e.trim()).filter(Boolean);
|
||||||
|
const bad = invalidEmails(emails);
|
||||||
|
if (bad.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Correo inválido: ${bad.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await this.settings.setNotificationAdminEmails(
|
||||||
|
emails,
|
||||||
|
actingId(req),
|
||||||
|
);
|
||||||
|
void this.audit.log(actingId(req), "notification.settings.admin-emails", {
|
||||||
|
emails,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- schedule */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cadence of both automatic envíos. Readable by any logged-in user so the
|
||||||
|
* screen can show "próxima corrida" without needing edit rights; changing
|
||||||
|
* it needs `setting:manage`, same as the summary recipients.
|
||||||
|
*/
|
||||||
|
@Get("settings/schedule")
|
||||||
|
schedules() {
|
||||||
|
return this.schedule.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put("settings/schedule/:kind")
|
||||||
|
@RequireAbility("setting:manage")
|
||||||
|
async setSchedule(
|
||||||
|
@Param("kind") kind: string,
|
||||||
|
@Body() dto: ScheduleDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
if (!SCHEDULE_KINDS.includes(kind as ScheduleKind)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Horario desconocido: ${kind}. Use ${SCHEDULE_KINDS.join(" o ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const parsed = parseSchedule({ ...dto, weekdays: dto.weekdays ?? [] });
|
||||||
|
if (!parsed.ok) throw new BadRequestException(parsed.error);
|
||||||
|
|
||||||
|
const result = await this.schedule.set(
|
||||||
|
kind as ScheduleKind,
|
||||||
|
parsed.value,
|
||||||
|
actingId(req),
|
||||||
|
);
|
||||||
|
void this.audit.log(actingId(req), "notification.settings.schedule", {
|
||||||
|
kind,
|
||||||
|
...parsed.value,
|
||||||
|
cron: result.cron,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the UI's coarse view tabs to concrete statuses. An explicit
|
||||||
|
* `status` wins. "Omitidos" covers both SKIPPED_* variants, which is why
|
||||||
|
* this returns a list rather than a single value. */
|
||||||
|
private mapViewStatus(
|
||||||
|
view: ListLogDto["view"],
|
||||||
|
status: ListLogDto["status"],
|
||||||
|
): EmailNotificationStatus[] | undefined {
|
||||||
|
if (status) return [status];
|
||||||
|
if (!view || view === "all") return undefined;
|
||||||
|
if (view === "sent") return [EmailNotificationStatus.SENT];
|
||||||
|
if (view === "failed") return [EmailNotificationStatus.FAILED];
|
||||||
|
if (view === "skipped") {
|
||||||
|
return [
|
||||||
|
EmailNotificationStatus.SKIPPED_NO_EMAIL,
|
||||||
|
EmailNotificationStatus.SKIPPED_GATE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { NotificationLogModule } from "./notification-log.module";
|
||||||
|
import { NotificationScheduleModule } from "./notification-schedule.module";
|
||||||
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
|
import { NotificationsController } from "./notifications.controller";
|
||||||
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mass email notifications. MailModule is global (registered in AppModule),
|
||||||
|
* so this module needs no MailService import — it picks it up by injection.
|
||||||
|
*
|
||||||
|
* The automatic sweep is registered by `NotificationsService` against
|
||||||
|
* `NotificationScheduleService`, which owns the cadence for both halves of
|
||||||
|
* /notificaciones and stores it in `app_settings`.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [NotificationLogModule, NotificationScheduleModule, SettingsModule],
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [NotificationsService],
|
||||||
|
exports: [NotificationsService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
renderAccountStatus,
|
||||||
|
renderOutstanding,
|
||||||
|
renderPaymentConfirm,
|
||||||
|
renderTrustConfirm,
|
||||||
|
} from "./render";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-level tests. The legacy PHP scripts fetched these bodies by URL;
|
||||||
|
* we render server-side and inline. The tests assert the *shape* of each
|
||||||
|
* body — account id, name, subject, balance/tipo, color band — because
|
||||||
|
* the customer base has been seeing these letters for years and a visual
|
||||||
|
* regression costs trust faster than any backend change does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("renderOutstanding", () => {
|
||||||
|
it("includes the customer id, name, total, and per-row table", () => {
|
||||||
|
const html = renderOutstanding({
|
||||||
|
customerId: "C-001",
|
||||||
|
customerName: "Acme & Co.",
|
||||||
|
total: "1234.50",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
date: "2026-07-01",
|
||||||
|
reference: "INV-1",
|
||||||
|
period: "Jul-26",
|
||||||
|
type: "CHECK",
|
||||||
|
amount: "-500.00",
|
||||||
|
balance: "-500.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2026-07-15",
|
||||||
|
reference: "INV-2",
|
||||||
|
period: "Jul-26",
|
||||||
|
type: "CASH",
|
||||||
|
amount: "-734.50",
|
||||||
|
balance: "-1234.50",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("Acme & Co.");
|
||||||
|
expect(html).toContain("ACCOUNT #C-001");
|
||||||
|
expect(html).toContain("$ 1,234.50");
|
||||||
|
expect(html).toContain("INV-1");
|
||||||
|
expect(html).toContain("CHECK");
|
||||||
|
expect(html).toContain("IF YOU ALREADY SENT THE CHECK");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes HTML in the customer name", () => {
|
||||||
|
const html = renderOutstanding({
|
||||||
|
customerId: "x",
|
||||||
|
customerName: "<script>alert(1)</script>",
|
||||||
|
total: "0.00",
|
||||||
|
rows: [],
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).not.toContain("<script>alert(1)</script>");
|
||||||
|
expect(html).toContain("<script>alert(1)</script>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderPaymentConfirm", () => {
|
||||||
|
it("uses the transaction type in the heading and the amount in the body", () => {
|
||||||
|
const html = renderPaymentConfirm({
|
||||||
|
customerId: "C-002",
|
||||||
|
customerName: "Bob",
|
||||||
|
typeOfTrx: "CHECK DEPOSIT",
|
||||||
|
reference: "DEP-99",
|
||||||
|
amount: "500.00",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("CHECK DEPOSIT CONFIRMATION");
|
||||||
|
expect(html).toContain("HI, Bob");
|
||||||
|
expect(html).toContain("REFER# DEP-99");
|
||||||
|
expect(html).toContain("$ 500.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderAccountStatus", () => {
|
||||||
|
it("uses the yellow band and the under-minimum phrasing for level=0", () => {
|
||||||
|
const html = renderAccountStatus({
|
||||||
|
customerId: "C-003",
|
||||||
|
customerName: "Carol",
|
||||||
|
level: 0,
|
||||||
|
balance: "10.00",
|
||||||
|
tipo: "40.00",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("#88D5EE");
|
||||||
|
expect(html).toContain("under our minimum");
|
||||||
|
expect(html).toContain("Carol");
|
||||||
|
expect(html).toContain("$ 10.00");
|
||||||
|
expect(html).toContain("$ 40.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the red band and the rush phrasing for level=1", () => {
|
||||||
|
const html = renderAccountStatus({
|
||||||
|
customerId: "C-003",
|
||||||
|
customerName: "Carol",
|
||||||
|
level: 1,
|
||||||
|
balance: "-25.50",
|
||||||
|
tipo: "25.50",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("#FF8D71");
|
||||||
|
expect(html).toContain("overdrawn");
|
||||||
|
expect(html).toContain("reactivate your payments");
|
||||||
|
expect(html).toContain("$ 25.50");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("renderTrustConfirm", () => {
|
||||||
|
it("labels the trust annual fee and quotes the amount", () => {
|
||||||
|
const html = renderTrustConfirm({
|
||||||
|
customerId: "C-004",
|
||||||
|
customerName: "Dan",
|
||||||
|
amount: "350.00",
|
||||||
|
year: 2026,
|
||||||
|
});
|
||||||
|
expect(html).toContain("Annual Bank Fee Payment Confirmation");
|
||||||
|
expect(html).toContain("$ 350.00");
|
||||||
|
expect(html).toContain("Most banks always request");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* HTML body renderers for the four notification jobs. These are the modern
|
||||||
|
* in-process equivalent of the legacy `getXxxForEmail.php` files the PHP
|
||||||
|
* scripts `fetch()`ed by URL. Rendering server-side and inlining the body
|
||||||
|
* in the response keeps a single SES MessageId tied to one frozen HTML
|
||||||
|
* snapshot (vs. the legacy flow, where the URL kept re-rendering with
|
||||||
|
* whatever the database looked like at click time).
|
||||||
|
*
|
||||||
|
* The visual style mirrors the legacy PHP templates where it makes sense
|
||||||
|
* (the office's customer base has been seeing these letters for years;
|
||||||
|
* gratuitous redesign costs trust). The body shell, table layout and the
|
||||||
|
* canonical contact block are preserved verbatim. English copy because the
|
||||||
|
* legacy letters were English; switching to Spanish is a future decision
|
||||||
|
* (see INSURANCE_FEATURES_SPEC §1.6 "Spanish or English body?").
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HEAD = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<title>{title}</title>
|
||||||
|
</head>`;
|
||||||
|
|
||||||
|
const FOOT_CONTACT = `<p>If you have any questions regarding this notice please contact us at:
|
||||||
|
Tel. 011 52 (661) 612 - 1295 Fax. (661) 612 - 1285
|
||||||
|
For any type of a 24 Hrs. emergencies: please dial 52 (664) 304 - 7778 |
|
||||||
|
<a href="mailto:jorge@jorgecuadros.com">jorge@jorgecuadros.com</a> |
|
||||||
|
<a href="https://www.jorgecuadros.com/contactus.php">Contact Us Form</a></p>`;
|
||||||
|
|
||||||
|
const SIGNED = (year: number) => `<center><span class="small">This message has been generated by the Jorge Cuadros & Assoc. Information Server.<br />Copyright ${year} <a href="http://www.freakma.net/">Developed by FreaKmA.Net</a></span></center>`;
|
||||||
|
|
||||||
|
const esc = (s: string | null | undefined): string =>
|
||||||
|
String(s ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
|
||||||
|
const usd = (n: number | string | null | undefined): string => {
|
||||||
|
if (n === null || n === undefined) return "$ 0.00";
|
||||||
|
const v = typeof n === "string" ? Number(n) : n;
|
||||||
|
if (!isFinite(v)) return "$ 0.00";
|
||||||
|
return `$ ${v.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared shell: a 2-column table that matches the PHP output layout. */
|
||||||
|
function shell(opts: {
|
||||||
|
title: string;
|
||||||
|
bg: string;
|
||||||
|
heading: string;
|
||||||
|
accountId: string | number;
|
||||||
|
accountName: string;
|
||||||
|
body: string;
|
||||||
|
note?: string;
|
||||||
|
statementLink?: string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const { title, bg, heading, accountId, accountName, body, note, statementLink, year } = opts;
|
||||||
|
const stmt = statementLink ?? "https://my.jorgecuadros.com/";
|
||||||
|
return `${HEAD.replace("{title}", esc(title))}
|
||||||
|
<body style="background-color:${bg};color:#333;font-family:'Courier New', Courier, monospace;">
|
||||||
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td width="43%" style="font-size:20px;font-weight:bold;">${esc(heading)}</td>
|
||||||
|
<td width="57%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php">Customer Support</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>${esc(accountName)}<br />ACCOUNT #${esc(String(accountId))}</strong></td>
|
||||||
|
<td><div align="center"><a href="${esc(stmt)}" target="_blank" style="color:#006699;font-weight:bold">Click Here to View Your Account Statement</a></div></td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">${body}</td></tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
${
|
||||||
|
note
|
||||||
|
? `<tr><td colspan="2"><h4>${esc(note)}</h4>${FOOT_CONTACT}</td></tr>`
|
||||||
|
: `<tr><td colspan="2">${FOOT_CONTACT}</td></tr>`
|
||||||
|
}
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">${SIGNED(year)}</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Outstanding payments — Job 1 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface OutstandingRow {
|
||||||
|
date: Date | string;
|
||||||
|
reference: string | null;
|
||||||
|
period: string | null;
|
||||||
|
type: string | null;
|
||||||
|
/** Signed amount (negative for charges). */
|
||||||
|
amount: number | string;
|
||||||
|
/** Running balance in the customer's currency, after this row. */
|
||||||
|
balance: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderOutstanding(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
total: number | string;
|
||||||
|
rows: OutstandingRow[];
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const rows = args.rows
|
||||||
|
.map(
|
||||||
|
(r) => `<tr>
|
||||||
|
<td>${esc(String(r.date))}</td>
|
||||||
|
<td>${esc(r.reference ?? "")}</td>
|
||||||
|
<td>${esc(r.period ?? "")}</td>
|
||||||
|
<td>${esc(r.type ?? "")}</td>
|
||||||
|
<td align="right">${esc(usd(r.amount))}</td>
|
||||||
|
<td align="right">${esc(usd(r.balance))}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const body = `<p>This needs your prompt attention in order to avoid any disruption(s):</p>
|
||||||
|
<p align="center"><strong><font color="#FF0000">TOTAL OF OUTSTANDING BILLS: ${esc(
|
||||||
|
usd(args.total),
|
||||||
|
)} PESOS.</font></strong></p>
|
||||||
|
<table width="100%" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr><th>DATE</th><th>REFER</th><th>PERIOD</th><th>TYPEOFTRX</th><th>CHARGECREDIT</th><th>BALANCE</th></tr>
|
||||||
|
${rows}
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
return shell({
|
||||||
|
title: "Outstanding Payments",
|
||||||
|
bg: "#9CC",
|
||||||
|
heading: "Outstanding Payments",
|
||||||
|
accountId: args.customerId,
|
||||||
|
accountName: args.customerName,
|
||||||
|
body,
|
||||||
|
note: "NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL",
|
||||||
|
year: args.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Payment confirmation — Job 2 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function renderPaymentConfirm(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
typeOfTrx: string;
|
||||||
|
reference: string | null;
|
||||||
|
/** The deposited amount (positive number — credits are positive in the
|
||||||
|
* unified ledger). */
|
||||||
|
amount: number | string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const body = `<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td width="48%" style="font-size:20px;font-weight:bold;">${esc(
|
||||||
|
args.typeOfTrx,
|
||||||
|
)} CONFIRMATION</td>
|
||||||
|
<td width="52%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros & Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php" target="_blank">Customer Support</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>HI, ${esc(args.customerName)}</strong><br/>
|
||||||
|
<strong>ACCOUNT #${esc(args.customerId)}</strong><br/>
|
||||||
|
<strong>REFER# ${esc(args.reference ?? "")}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div align="center" style="padding:20px;">
|
||||||
|
<a href="https://my.jorgecuadros.com/" target="_blank" style="color:#006699;font-weight:bold"><em>Click Here to View Your Account Statement</em></a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">
|
||||||
|
<p>Your account is now current to keep paying your future obligations. If for any reason your next bill is more than what's available; our system will email you our automatic alert requesting more funds. Thank You,</p>
|
||||||
|
<p align="center" style="color:#006600;font-weight:bold;">Your deposit was for ${esc(
|
||||||
|
usd(args.amount),
|
||||||
|
)} PESOS.</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2"><h4>NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL</h4>${FOOT_CONTACT}</td></tr>
|
||||||
|
<tr><td colspan="2"> </td></tr>
|
||||||
|
<tr><td colspan="2">${SIGNED(args.year)}</td></tr>
|
||||||
|
</table>`;
|
||||||
|
return `${HEAD.replace("{title}", "Payment Confirmation")}<body>${body}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Account status — Job 3 (yellow + red) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function renderAccountStatus(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
level: 0 | 1; // 0 = yellow (DEBAJO DEL TIPO), 1 = red (EN ROJO)
|
||||||
|
balance: number | string;
|
||||||
|
/** Amount the customer needs to deposit to clear the threshold. */
|
||||||
|
tipo: number | string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const isYellow = args.level === 0;
|
||||||
|
const body = isYellow
|
||||||
|
? `<p>In order to avoid any disruptions please mail or bring ${esc(
|
||||||
|
usd(args.tipo),
|
||||||
|
)} USD ASAP. As your current Balance ${esc(
|
||||||
|
usd(args.balance),
|
||||||
|
)} is under our minimum required to run this account.</p>`
|
||||||
|
: `<p>Sorry Account is overdrawn and all utility bills are on hold please rush ${esc(
|
||||||
|
usd(args.tipo),
|
||||||
|
)} USD these funds must be on hand ASAP to reactivate your payments.</p>`;
|
||||||
|
return shell({
|
||||||
|
title: "Account Alert",
|
||||||
|
bg: isYellow ? "#88D5EE" : "#FF8D71",
|
||||||
|
heading: "Account Alert",
|
||||||
|
accountId: args.customerId,
|
||||||
|
accountName: args.customerName,
|
||||||
|
body,
|
||||||
|
note: "NOTE : PLEASE MAKE YOUR CHECK PAYABLE TO UMC AND ASSOCIATES. IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL.",
|
||||||
|
year: args.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Trust payment confirmation — Job 4 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function renderTrustConfirm(args: {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
/** Annual fee amount posted (positive, in MXN per the PHP). */
|
||||||
|
amount: number | string;
|
||||||
|
year: number;
|
||||||
|
}): string {
|
||||||
|
const body = `<p>This automatic notice is to confirm, that your Annual Bank Fee has been paid by, and posted in your account. Thank You,</p>
|
||||||
|
<p align="center"><strong>The annual fee was posted for the amount of <font color="#FF0000">${esc(
|
||||||
|
usd(args.amount),
|
||||||
|
)} PESOS.</font></strong></p>`;
|
||||||
|
return shell({
|
||||||
|
title: "Trust Payment Confirmation",
|
||||||
|
bg: "#C0BEA0",
|
||||||
|
heading: "Annual Bank Fee Payment Confirmation",
|
||||||
|
accountId: args.customerId,
|
||||||
|
accountName: args.customerName,
|
||||||
|
body,
|
||||||
|
note: "NOTE : Most banks always request to make such payment in advance.",
|
||||||
|
statementLink: "https://my.jorgecuadros.com/",
|
||||||
|
year: args.year,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { jobProgress } from "./ops.service";
|
||||||
|
|
||||||
|
/** Shape run_all.py emits, with the shell trace lines it interleaves. */
|
||||||
|
const line = (i: number, n: number, name: string) =>
|
||||||
|
`[paso ${i}/${n}] ${name}\n+ /repo/migration/.venv/bin/python /repo/migration/${name} --env prod\n[${name}] target env: prod\n validation: OK`;
|
||||||
|
|
||||||
|
describe("jobProgress", () => {
|
||||||
|
it("returns null before any step marker appears", () => {
|
||||||
|
// The safety backup runs before run_all.py, so this is the real state for
|
||||||
|
// the first stretch of every REIMPORT.
|
||||||
|
expect(jobProgress("== Respaldo de seguridad previo ==\ntablas capturadas: 39", "RUNNING")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for jobs that have no steps at all", () => {
|
||||||
|
// BACKUP/RESTORE are a single mysqldump; a fabricated percentage would be
|
||||||
|
// worse than none.
|
||||||
|
expect(jobProgress("mysqldump ... done", "SUCCESS")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks the most recent marker, not the first", () => {
|
||||||
|
const log = [line(1, 9, "transform_customers.py"), line(2, 9, "transform_properties.py")].join("\n");
|
||||||
|
const p = jobProgress(log, "RUNNING");
|
||||||
|
expect(p).toMatchObject({ step: 2, total: 9, name: "transform_properties.py" });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The point of the whole feature. While RUNNING, step i is IN PROGRESS, so
|
||||||
|
* only i-1 are done. Counting i as complete would show 100% while the final
|
||||||
|
* and slowest step (blob_extract) is still working.
|
||||||
|
*/
|
||||||
|
it("does not claim a running step is finished", () => {
|
||||||
|
expect(jobProgress(line(1, 9, "transform_customers.py"), "RUNNING")?.percent).toBe(0);
|
||||||
|
expect(jobProgress(line(9, 9, "blob_extract.py"), "RUNNING")?.percent).toBe(88);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches 100 only once the job is no longer running", () => {
|
||||||
|
expect(jobProgress(line(9, 9, "blob_extract.py"), "SUCCESS")?.percent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A job that died mid-way must report where it died, not 100%. */
|
||||||
|
it("reports the failed step rather than completion", () => {
|
||||||
|
const p = jobProgress(line(5, 9, "transform_transactions.py"), "FAILED");
|
||||||
|
expect(p).toMatchObject({ step: 5, total: 9 });
|
||||||
|
expect(p!.percent).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the 8-step SYNC list as well as the 9-step REIMPORT one", () => {
|
||||||
|
expect(jobProgress(line(8, 8, "transform_bank.py"), "SUCCESS")?.percent).toBe(100);
|
||||||
|
expect(jobProgress(line(4, 8, "transform_policies.py"), "RUNNING")?.percent).toBe(37);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captured verbatim from `run_all.run(..., step=8, total=9)`. This is the
|
||||||
|
* contract between the Python and this parser; if run_all.py's format
|
||||||
|
* changes, this fails rather than the panel silently showing no progress.
|
||||||
|
*/
|
||||||
|
it("parses the exact line run_all.py emits", () => {
|
||||||
|
const real =
|
||||||
|
"[paso 8/9] transform_bank.py\n+ /repo/migration/.venv/bin/python /repo/migration/transform_bank.py --env prod";
|
||||||
|
expect(jobProgress(real, "RUNNING")).toMatchObject({
|
||||||
|
step: 8,
|
||||||
|
total: 9,
|
||||||
|
name: "transform_bank.py",
|
||||||
|
percent: 77,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a malformed marker instead of reporting NaN", () => {
|
||||||
|
expect(jobProgress("[paso 3/0] x.py", "RUNNING")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The marker must be at line start so log text quoting it cannot spoof it. */
|
||||||
|
it("does not match a marker embedded mid-line", () => {
|
||||||
|
expect(jobProgress("some output mentioning [paso 4/9] fake.py", "RUNNING")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,7 @@ import { AbilityGuard } from "../auth/ability.guard";
|
|||||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
import { OpsService } from "./ops.service";
|
import { OpsService } from "./ops.service";
|
||||||
|
import { ReplicationService } from "./replication.service";
|
||||||
import { StartJobDto } from "./start-job.dto";
|
import { StartJobDto } from "./start-job.dto";
|
||||||
|
|
||||||
/** Every route is ADMIN-only (ability "db:manage"). */
|
/** Every route is ADMIN-only (ability "db:manage"). */
|
||||||
@@ -28,6 +29,7 @@ import { StartJobDto } from "./start-job.dto";
|
|||||||
export class OpsController {
|
export class OpsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly ops: OpsService,
|
private readonly ops: OpsService,
|
||||||
|
private readonly replication: ReplicationService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -96,6 +98,30 @@ export class OpsController {
|
|||||||
|
|
||||||
/* --------------------------------------------------------------- jobs */
|
/* --------------------------------------------------------------- jobs */
|
||||||
|
|
||||||
|
/** Health of the my.jorgecuadros.com read replica. Read-only, no audit entry. */
|
||||||
|
@Get("replication")
|
||||||
|
replicationStatus() {
|
||||||
|
return this.replication.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full row-by-row comparison of the customer-visible tables against the master.
|
||||||
|
*
|
||||||
|
* POST rather than GET despite reading nothing: it is a full scan of both
|
||||||
|
* servers and must not be something a browser prefetch, a retry, or a refresh
|
||||||
|
* can set off. Audited for the same reason — it is a deliberate, costly act,
|
||||||
|
* and "who ran this while the site was slow" is a question worth answering.
|
||||||
|
*/
|
||||||
|
@Post("replication/verify")
|
||||||
|
async verifyReplication(@Req() req: Request) {
|
||||||
|
const result = await this.replication.verify();
|
||||||
|
void this.audit.log(this.actingId(req), "ops.replication.verify", {
|
||||||
|
identical: result.identical,
|
||||||
|
elapsedMs: result.elapsedMs,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("jobs")
|
@Get("jobs")
|
||||||
listJobs() {
|
listJobs() {
|
||||||
return this.ops.listJobs();
|
return this.ops.listJobs();
|
||||||
@@ -109,11 +135,17 @@ export class OpsController {
|
|||||||
@Post("jobs")
|
@Post("jobs")
|
||||||
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||||
const userId = this.actingId(req);
|
const userId = this.actingId(req);
|
||||||
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
const job = await this.ops.startJob(
|
||||||
|
dto.kind,
|
||||||
|
{ file: dto.file, forceFull: dto.forceFull },
|
||||||
|
userId,
|
||||||
|
);
|
||||||
void this.audit.log(userId, "ops.job.start", {
|
void this.audit.log(userId, "ops.job.start", {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
kind: dto.kind,
|
kind: dto.kind,
|
||||||
file: dto.file,
|
file: dto.file,
|
||||||
|
// Recorded because this is the flag that authorised deleting native rows.
|
||||||
|
forceFull: dto.forceFull,
|
||||||
});
|
});
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { OpsController } from "./ops.controller";
|
import { OpsController } from "./ops.controller";
|
||||||
import { OpsService } from "./ops.service";
|
import { OpsService } from "./ops.service";
|
||||||
|
import { ReplicationService } from "./replication.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [OpsController],
|
controllers: [OpsController],
|
||||||
providers: [OpsService],
|
providers: [OpsService, ReplicationService],
|
||||||
})
|
})
|
||||||
export class OpsModule {}
|
export class OpsModule {}
|
||||||
|
|||||||
+220
-19
@@ -31,6 +31,29 @@ export const INGEST_FILES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type IngestName = (typeof INGEST_FILES)[number];
|
export type IngestName = (typeof INGEST_FILES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prior-period archive: one Access snapshot per closed year, named for the
|
||||||
|
* period it holds. `2025.accdb` is UTILITIES as it stood when 2025 was cut.
|
||||||
|
*
|
||||||
|
* The filename is the entire declaration of the period — nothing inside the
|
||||||
|
* file names its year, because a snapshot's `datos2` is indistinguishable from
|
||||||
|
* the live one — so this pattern is both the allowlist and the contract. It is
|
||||||
|
* anchored and allows no separator, which is what keeps an upload from
|
||||||
|
* escaping the ingest directory.
|
||||||
|
*/
|
||||||
|
const PERIOD_FILE_RE = /^(\d{4})\.accdb$/i;
|
||||||
|
|
||||||
|
/** Earliest period we will accept, so a typo'd year cannot mint a bogus one. */
|
||||||
|
const PERIOD_MIN_YEAR = 1990;
|
||||||
|
|
||||||
|
export function periodYearOf(name: string): number | null {
|
||||||
|
const m = PERIOD_FILE_RE.exec(name);
|
||||||
|
if (!m) return null;
|
||||||
|
const year = Number(m[1]);
|
||||||
|
if (year < PERIOD_MIN_YEAR || year > new Date().getUTCFullYear()) return null;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefix for every command containing a pipe. Without it the exit status of
|
* 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
|
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||||
@@ -67,23 +90,83 @@ export class OpsService implements OnModuleInit {
|
|||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
await fs.mkdir(this.ingestDir, { recursive: true });
|
await fs.mkdir(this.ingestDir, { recursive: true });
|
||||||
await fs.mkdir(this.backupDir, { recursive: true });
|
await fs.mkdir(this.backupDir, { recursive: true });
|
||||||
|
await this.reconcileOrphanedJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail any job still marked RUNNING at startup.
|
||||||
|
*
|
||||||
|
* Jobs run as a child of THIS process, so no job can outlive it: if a row says
|
||||||
|
* RUNNING while we are booting, its process died with the previous instance
|
||||||
|
* and nothing will ever finalize it. Since startJob() refuses to start while
|
||||||
|
* any RUNNING row exists, one interrupted job wedges the panel permanently
|
||||||
|
* with no way out from the UI — it took a manual UPDATE against production to
|
||||||
|
* recover the first time this happened, when a deploy landed 110 seconds into
|
||||||
|
* a REIMPORT.
|
||||||
|
*
|
||||||
|
* Deliberately unconditional rather than filtered on age: "started recently"
|
||||||
|
* does not mean "still alive" here, and a fresh boot is proof enough that
|
||||||
|
* nothing survived.
|
||||||
|
*/
|
||||||
|
private async reconcileOrphanedJobs(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Read then write one by one rather than updateMany: the log needs the
|
||||||
|
// reason APPENDED, and a job whose log just stops mid-step with no
|
||||||
|
// explanation is what made the first occurrence hard to diagnose.
|
||||||
|
const orphans = await this.prisma.opsJob.findMany({
|
||||||
|
where: { status: "RUNNING" },
|
||||||
|
select: { id: true, kind: true, log: true },
|
||||||
|
});
|
||||||
|
for (const job of orphans) {
|
||||||
|
await this.prisma.opsJob.update({
|
||||||
|
where: { id: job.id },
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
finishedAt: new Date(),
|
||||||
|
log: {
|
||||||
|
set:
|
||||||
|
job.log +
|
||||||
|
"\n[interrumpido: el contenedor se reinició mientras el trabajo corría; " +
|
||||||
|
"el proceso hijo no sobrevive a un redespliegue. " +
|
||||||
|
"Vuelva a ejecutar la operación desde el principio.]\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.warn(`trabajo ${job.kind} ${job.id} quedó huérfano; marcado FAILED`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Never block startup on this. A failed reconcile leaves the panel
|
||||||
|
// wedged, which is bad, but an API that will not boot is worse.
|
||||||
|
this.logger.error(`no se pudieron reconciliar trabajos huérfanos: ${String(e)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------- ingest */
|
/* -------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
private assertIngestName(name: string): IngestName {
|
private assertIngestName(name: string): string {
|
||||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
if (INGEST_FILES.includes(name as IngestName)) return name;
|
||||||
throw new BadRequestException(
|
if (periodYearOf(name) !== null) return name;
|
||||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
throw new BadRequestException(
|
||||||
);
|
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}` +
|
||||||
}
|
`, o un archivo de periodo anterior con nombre AAAA.accdb (por ejemplo 2025.accdb).`,
|
||||||
return name as IngestName;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listIngest(): Promise<
|
async listIngest(): Promise<
|
||||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
{
|
||||||
|
name: string;
|
||||||
|
present: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
/** Set only on a prior-period archive; null on the four fixed sources. */
|
||||||
|
periodYear: number | null;
|
||||||
|
}[]
|
||||||
> {
|
> {
|
||||||
return Promise.all(
|
// The four fixed sources are listed whether present or not — they are
|
||||||
|
// required, so "missing" is the useful state to show. Period archives are
|
||||||
|
// optional and unbounded, so they are listed only once uploaded, newest
|
||||||
|
// year first.
|
||||||
|
const fixed = await Promise.all(
|
||||||
INGEST_FILES.map(async (name) => {
|
INGEST_FILES.map(async (name) => {
|
||||||
try {
|
try {
|
||||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
@@ -92,12 +175,46 @@ export class OpsService implements OnModuleInit {
|
|||||||
present: true,
|
present: true,
|
||||||
size: st.size,
|
size: st.size,
|
||||||
modifiedAt: st.mtime.toISOString(),
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
periodYear: null as number | null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { name, present: false, size: null, modifiedAt: null };
|
return {
|
||||||
|
name,
|
||||||
|
present: false,
|
||||||
|
size: null,
|
||||||
|
modifiedAt: null,
|
||||||
|
periodYear: null as number | null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(this.ingestDir);
|
||||||
|
} catch {
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
const periods = (
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.map((name) => ({ name, year: periodYearOf(name) }))
|
||||||
|
.filter((e): e is { name: string; year: number } => e.year !== null)
|
||||||
|
.sort((a, b) => b.year - a.year)
|
||||||
|
.map(async ({ name, year }) => {
|
||||||
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
present: true,
|
||||||
|
size: st.size,
|
||||||
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
periodYear: year,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
return [...fixed, ...periods];
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||||
@@ -163,7 +280,9 @@ export class OpsService implements OnModuleInit {
|
|||||||
async getJob(id: string) {
|
async getJob(id: string) {
|
||||||
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
const job = await this.prisma.opsJob.findUnique({ where: { id } });
|
||||||
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
if (!job) throw new NotFoundException("Trabajo no encontrado.");
|
||||||
return job;
|
// Derived, never stored: the log is the single source of truth for how far
|
||||||
|
// a job got, so progress cannot drift out of sync with it.
|
||||||
|
return { ...job, progress: jobProgress(job.log, job.status) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -262,9 +381,26 @@ export class OpsService implements OnModuleInit {
|
|||||||
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
|
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
|
||||||
* and both are listed as restore points by this same screen.
|
* and both are listed as restore points by this same screen.
|
||||||
*
|
*
|
||||||
* --set-gtid-purged=OFF: the production server is the replication SOURCE with
|
* The dumper is probed at runtime rather than assumed. This command runs
|
||||||
* GTID on, so without it every dump embeds SET @@GLOBAL.GTID_PURGED and is
|
* inside the API image, whose `mysql-client` is Alpine's — i.e. MariaDB's —
|
||||||
* unrestorable onto the very server it came from.
|
* 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
|
* 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
|
* ~372-byte output of a mysqldump that died on its first statement, so a
|
||||||
@@ -277,8 +413,12 @@ export class OpsService implements OnModuleInit {
|
|||||||
*/
|
*/
|
||||||
private dumpCommand(flags: string, db: string, out: string): string {
|
private dumpCommand(flags: string, db: string, out: string): string {
|
||||||
return (
|
return (
|
||||||
`( mysqldump ${flags} --single-transaction --routines --triggers ` +
|
`DUMP=mysqldump; GTID=; ` +
|
||||||
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` +
|
`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} && ` +
|
`gzip -t ${out} && ` +
|
||||||
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
|
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
|
||||||
`echo "tablas capturadas: $TABLAS" && ` +
|
`echo "tablas capturadas: $TABLAS" && ` +
|
||||||
@@ -328,7 +468,12 @@ export class OpsService implements OnModuleInit {
|
|||||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
`${this.dumpCommand(flags, db, out)} && ` +
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
// --stage is not optional here. The staged Parquet lives in the image
|
||||||
|
// at migration/output, NOT on a volume, so every redeploy wipes it and
|
||||||
|
// a sync without --stage dies on a missing stg_*/*.parquet. Re-staging
|
||||||
|
// is also the only thing that makes "desde carpeta de ingesta" true:
|
||||||
|
// stale Parquet would sync the previous upload, not the current one.
|
||||||
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage --sync`;
|
||||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,12 +483,19 @@ export class OpsService implements OnModuleInit {
|
|||||||
const out = shq(path.join(this.backupDir, file));
|
const out = shq(path.join(this.backupDir, file));
|
||||||
const py = await this.pythonBin();
|
const py = await this.pythonBin();
|
||||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
|
// run_all.py runs native_guard.py before it truncates anything and exits
|
||||||
|
// without touching the database when the target holds rows that only
|
||||||
|
// exist here — allocated portal NUMids, app-created customers, OCR
|
||||||
|
// captures. --force-full is what the operator ticks to delete them
|
||||||
|
// anyway; without it the job fails with the list.
|
||||||
|
const force = params.forceFull === true;
|
||||||
const cmd =
|
const cmd =
|
||||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
`${this.dumpCommand(flags, db, out)} && ` +
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage` +
|
||||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
(force ? " --force-full" : "");
|
||||||
|
return { cmd, resolvedParams: { safetyBackup: file, forceFull: force } };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||||
@@ -444,3 +596,52 @@ export class OpsService implements OnModuleInit {
|
|||||||
function shq(v: string): string {
|
function shq(v: string): string {
|
||||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Progress derived from a job's log. Null when the job reports no steps. */
|
||||||
|
export interface JobProgress {
|
||||||
|
/** 1-based index of the step currently running (or last reached). */
|
||||||
|
step: number;
|
||||||
|
total: number;
|
||||||
|
/** Script name, e.g. "transform_bank.py". */
|
||||||
|
name: string;
|
||||||
|
/** 0..100, floored. 100 only once the job is no longer RUNNING. */
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the "[paso i/N] name" markers migration/run_all.py emits.
|
||||||
|
*
|
||||||
|
* Progress is DERIVED from the log rather than tracked in a column: the log is
|
||||||
|
* already the record of what happened, and a separate counter could disagree
|
||||||
|
* with it — which is exactly the confusion a progress display is supposed to
|
||||||
|
* remove. run_all.py owns the step count, so adding a step cannot desync this.
|
||||||
|
*
|
||||||
|
* BACKUP and RESTORE are a single mysqldump with no steps, so they return null
|
||||||
|
* and the UI shows an indeterminate spinner. Reporting a fabricated percentage
|
||||||
|
* for them would be worse than showing none.
|
||||||
|
*/
|
||||||
|
export function jobProgress(
|
||||||
|
log: string,
|
||||||
|
status: string,
|
||||||
|
): JobProgress | null {
|
||||||
|
// Last marker wins: the log grows, and the newest line is the current step.
|
||||||
|
const matches = [...log.matchAll(/^\[paso (\d+)\/(\d+)\] (\S+)/gm)];
|
||||||
|
const last = matches[matches.length - 1];
|
||||||
|
if (!last) return null;
|
||||||
|
|
||||||
|
const step = Number(last[1]);
|
||||||
|
const total = Number(last[2]);
|
||||||
|
if (!Number.isFinite(step) || !Number.isFinite(total) || total <= 0) return null;
|
||||||
|
|
||||||
|
// While RUNNING, step i means i is IN PROGRESS, not finished — so report
|
||||||
|
// (i-1) completed. Claiming 100% while the last step is still working is the
|
||||||
|
// classic progress-bar lie, and here the last step (blob_extract) is also the
|
||||||
|
// slowest, so it would sit at "100%" for the longest stretch of the job.
|
||||||
|
const done = status === "RUNNING" ? step - 1 : step;
|
||||||
|
return {
|
||||||
|
step,
|
||||||
|
total,
|
||||||
|
name: last[3],
|
||||||
|
percent: Math.max(0, Math.min(100, Math.floor((done / total) * 100))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { periodYearOf } from "./ops.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `periodYearOf` is the upload allowlist for prior-period archives, so it is
|
||||||
|
* doing two jobs at once: deciding what counts as a period file, and keeping a
|
||||||
|
* caller-supplied name from escaping the ingest directory. Both are pinned here.
|
||||||
|
*/
|
||||||
|
describe("periodYearOf", () => {
|
||||||
|
it("accepts a four-digit year archive", () => {
|
||||||
|
expect(periodYearOf("2025.accdb")).toBe(2025);
|
||||||
|
expect(periodYearOf("1999.accdb")).toBe(1999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the extension", () => {
|
||||||
|
expect(periodYearOf("2025.ACCDB")).toBe(2025);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a path that would escape the ingest directory", () => {
|
||||||
|
// The name is joined onto the ingest path, so anything with a separator or
|
||||||
|
// a parent reference has to fail before it reaches the filesystem.
|
||||||
|
expect(periodYearOf("../2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("../../etc/passwd")).toBeNull();
|
||||||
|
expect(periodYearOf("sub/2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.accdb/../../x")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names that only look like a period", () => {
|
||||||
|
expect(periodYearOf("202.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("20255.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.mdb")).toBeNull();
|
||||||
|
expect(periodYearOf("copia 2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.accdb.bak")).toBeNull();
|
||||||
|
expect(periodYearOf("UTILITIES.accdb")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects years outside the plausible range", () => {
|
||||||
|
// A typo'd year would otherwise mint a period nobody can ever reconcile:
|
||||||
|
// there is no BALANCE FORWARD for the year after it to check against.
|
||||||
|
expect(periodYearOf("1889.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf(`${new Date().getUTCFullYear() + 1}.accdb`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the current year, which is the earliest a period can be cut", () => {
|
||||||
|
expect(periodYearOf(`${new Date().getUTCFullYear()}.accdb`)).toBe(
|
||||||
|
new Date().getUTCFullYear(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the SQL thread is behind the I/O thread, in source binlog bytes.
|
||||||
|
*
|
||||||
|
* This is a different question from `secondsBehind`, and it answers the case
|
||||||
|
* that lag hides: while the SQL thread grinds through one huge transaction,
|
||||||
|
* `Seconds_Behind_Source` can sit still or even read 0, but the relay backlog
|
||||||
|
* is plainly shrinking (or not). It costs nothing extra — every field here
|
||||||
|
* comes out of the same `SHOW REPLICA STATUS` the panel already runs.
|
||||||
|
*
|
||||||
|
* Both positions are coordinates in the SOURCE's binlog, so they are only
|
||||||
|
* comparable while both threads are working on the SAME source file. When they
|
||||||
|
* are not, the replica is whole files behind and the byte delta is meaningless
|
||||||
|
* (positions restart at ~4 in each new file), so `backlogBytes` and `percent`
|
||||||
|
* are null and `sameFile` says why.
|
||||||
|
*/
|
||||||
|
export interface ApplyProgress {
|
||||||
|
/** Source binlog file the I/O thread is currently reading. */
|
||||||
|
sourceLogFile: string | null;
|
||||||
|
/** Position in `sourceLogFile` that the I/O thread has fetched up to. */
|
||||||
|
readPos: number;
|
||||||
|
/** Source binlog file the SQL thread is currently applying. */
|
||||||
|
relayLogFile: string | null;
|
||||||
|
/** Position in `relayLogFile` that the SQL thread has applied up to. */
|
||||||
|
execPos: number;
|
||||||
|
/** True while both threads are on the same source file. */
|
||||||
|
sameFile: boolean;
|
||||||
|
/** Fetched-but-not-yet-applied bytes. Null when the files differ. */
|
||||||
|
backlogBytes: number | null;
|
||||||
|
/**
|
||||||
|
* `execPos / readPos` as a percentage, null when the files differ.
|
||||||
|
*
|
||||||
|
* Deliberately never rounded up to 100 while any backlog remains: binlog
|
||||||
|
* positions are large, so a real backlog of a few KB is 99.99% of the file
|
||||||
|
* and would render as "caught up" when it is not. Read `backlogBytes === 0`
|
||||||
|
* for actually caught up.
|
||||||
|
*/
|
||||||
|
percent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the replica's executed history is from the master's, in transactions.
|
||||||
|
*
|
||||||
|
* This is the check `SHOW REPLICA STATUS` cannot give you, and it is stronger
|
||||||
|
* than everything else on the card for one specific reason: every other field is
|
||||||
|
* self-reported by the replica. `Seconds_Behind_Source` reads 0 both when there
|
||||||
|
* is genuinely nothing to apply AND when the I/O thread is disconnected — with
|
||||||
|
* no incoming event there is nothing to measure staleness against, so a dead
|
||||||
|
* link reports as perfectly current. `GTID_SUBTRACT(master, replica)` asks the
|
||||||
|
* master what it has done and the replica what it has applied, so a silent
|
||||||
|
* disconnect shows up immediately as a growing number.
|
||||||
|
*/
|
||||||
|
export interface GtidDrift {
|
||||||
|
/** Transactions the master executed that the replica has not. 0 = identical. */
|
||||||
|
missingTransactions: number;
|
||||||
|
/** The missing GTID set verbatim. Null when nothing is missing. */
|
||||||
|
missingGtidSet: string | null;
|
||||||
|
/**
|
||||||
|
* Transactions in the replica's `gtid_executed` under its OWN server UUID —
|
||||||
|
* writes that happened here and exist nowhere on the master.
|
||||||
|
*
|
||||||
|
* Reported, never alarmed on. A non-zero count is the expected residue of the
|
||||||
|
* seed load: restoring a dump executes its statements locally, and they take
|
||||||
|
* GTIDs from this server's UUID. They never propagate (`log_replica_updates`
|
||||||
|
* is off and nothing sources from this node), so they are harmless — right up
|
||||||
|
* until someone tries to promote this box, where they become a real divergence.
|
||||||
|
*/
|
||||||
|
localTransactions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One table's row count and content fingerprint, on one side of the link. */
|
||||||
|
export interface TableFingerprint {
|
||||||
|
table: string;
|
||||||
|
masterRows: number;
|
||||||
|
replicaRows: number;
|
||||||
|
/** Order-independent checksum over every column of every row. */
|
||||||
|
masterChecksum: string;
|
||||||
|
replicaChecksum: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
/** True only when every table matched on both count and checksum. */
|
||||||
|
identical: boolean;
|
||||||
|
tables: TableFingerprint[];
|
||||||
|
/** Set instead of `tables` when the comparison could not be run at all. */
|
||||||
|
problem: string | null;
|
||||||
|
checkedAt: string;
|
||||||
|
/** Wall-clock cost, because this is a full scan and the caller should see it. */
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplicationStatus {
|
||||||
|
/** false when the replica is not configured for this environment at all. */
|
||||||
|
configured: boolean;
|
||||||
|
/** true only when both threads run, no error is set, and lag is within bounds. */
|
||||||
|
healthy: boolean;
|
||||||
|
host: string | null;
|
||||||
|
ioRunning: string | null;
|
||||||
|
sqlRunning: string | null;
|
||||||
|
/** null when MySQL reports NULL, which it does whenever a thread is down. */
|
||||||
|
secondsBehind: number | null;
|
||||||
|
lastIoError: string | null;
|
||||||
|
lastSqlError: string | null;
|
||||||
|
sourceHost: string | null;
|
||||||
|
/** Relay-log apply progress. Null when the status output has no positions. */
|
||||||
|
apply: ApplyProgress | null;
|
||||||
|
/** GTID comparison against the master. Null when the master was unreachable. */
|
||||||
|
drift: GtidDrift | null;
|
||||||
|
/** Human-readable reason when healthy is false. */
|
||||||
|
problem: string | null;
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tables `my.jorgecuadros.com` reads through the `web_reader` grant.
|
||||||
|
*
|
||||||
|
* This list is the verification surface, not the replication surface — the
|
||||||
|
* replica carries the whole schema. These are the eight whose divergence would
|
||||||
|
* actually be visible to a customer, so they are the ones worth a full scan.
|
||||||
|
*/
|
||||||
|
export const REPLICATED_TABLES = [
|
||||||
|
"transactions",
|
||||||
|
"customers",
|
||||||
|
"customer_legacy_refs",
|
||||||
|
"type_transactions",
|
||||||
|
"exchange_rates",
|
||||||
|
"properties",
|
||||||
|
"property_services",
|
||||||
|
"trust_accounts",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether the my.jorgecuadros.com read replica is still replicating.
|
||||||
|
*
|
||||||
|
* The replica is what the public site reads once the platformDataSource flag is
|
||||||
|
* on, and a replica that has silently stopped applying serves stale balances
|
||||||
|
* rather than erroring — the failure is invisible from the site itself, which is
|
||||||
|
* why it needs a panel.
|
||||||
|
*
|
||||||
|
* Shells out to the mysql client for the same reason the rest of OpsService
|
||||||
|
* does: there is no MySQL driver in this API's dependencies, and the image
|
||||||
|
* already ships one.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReplicationService {
|
||||||
|
private readonly logger = new Logger(ReplicationService.name);
|
||||||
|
|
||||||
|
/** Lag above this many seconds is reported as unhealthy. */
|
||||||
|
private readonly maxLagSeconds = Number(process.env.REPLICA_MAX_LAG ?? 60);
|
||||||
|
|
||||||
|
async status(): Promise<ReplicationStatus> {
|
||||||
|
const host = process.env.REPLICA_DB_HOST;
|
||||||
|
const user = process.env.REPLICA_DB_USER;
|
||||||
|
const password = process.env.REPLICA_DB_PASS;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const empty: ReplicationStatus = {
|
||||||
|
configured: false,
|
||||||
|
healthy: false,
|
||||||
|
host: host ?? null,
|
||||||
|
ioRunning: null,
|
||||||
|
sqlRunning: null,
|
||||||
|
secondsBehind: null,
|
||||||
|
lastIoError: null,
|
||||||
|
lastSqlError: null,
|
||||||
|
sourceHost: null,
|
||||||
|
apply: null,
|
||||||
|
drift: null,
|
||||||
|
problem: null,
|
||||||
|
checkedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!host || !user || !password) {
|
||||||
|
return { ...empty, problem: "REPLICA_DB_* no configuradas" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await this.onReplica("SHOW REPLICA STATUS\\G");
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
this.logger.warn(`no se pudo consultar la réplica: ${msg}`);
|
||||||
|
return { ...empty, configured: true, problem: `No se pudo conectar: ${msg}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = (name: string): string | null => replicaField(raw, name);
|
||||||
|
|
||||||
|
// An empty result set means the server is not configured as a replica at
|
||||||
|
// all — distinct from "configured but broken", and worth saying plainly.
|
||||||
|
if (!raw.includes("Replica_IO_Running")) {
|
||||||
|
return {
|
||||||
|
...empty,
|
||||||
|
configured: true,
|
||||||
|
problem: "El servidor no está configurado como réplica",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ioRunning = field("Replica_IO_Running");
|
||||||
|
const sqlRunning = field("Replica_SQL_Running");
|
||||||
|
const lagRaw = field("Seconds_Behind_Source");
|
||||||
|
const secondsBehind =
|
||||||
|
lagRaw === null || lagRaw === "NULL" ? null : Number(lagRaw);
|
||||||
|
const lastIoError = field("Last_IO_Error");
|
||||||
|
const lastSqlError = field("Last_SQL_Error");
|
||||||
|
|
||||||
|
// Order matters: report the most specific cause first. Checking lag before
|
||||||
|
// the threads would blame "sin dato de retraso" for what is really a
|
||||||
|
// stopped thread, because MySQL reports NULL lag whenever either is down.
|
||||||
|
let problem: string | null = null;
|
||||||
|
if (ioRunning !== "Yes") problem = "El hilo de E/S no está corriendo";
|
||||||
|
else if (sqlRunning !== "Yes") problem = "El hilo SQL no está corriendo";
|
||||||
|
else if (lastSqlError) problem = `Error SQL: ${lastSqlError}`;
|
||||||
|
else if (lastIoError) problem = `Error de E/S: ${lastIoError}`;
|
||||||
|
else if (secondsBehind === null) problem = "Sin dato de retraso";
|
||||||
|
else if (secondsBehind > this.maxLagSeconds)
|
||||||
|
problem = `Retraso de ${secondsBehind}s (máximo ${this.maxLagSeconds}s)`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
healthy: problem === null,
|
||||||
|
host,
|
||||||
|
ioRunning,
|
||||||
|
sqlRunning,
|
||||||
|
secondsBehind,
|
||||||
|
lastIoError,
|
||||||
|
lastSqlError,
|
||||||
|
sourceHost: field("Source_Host"),
|
||||||
|
// Reported, never folded into `healthy`: a non-zero backlog is the normal
|
||||||
|
// state of a working replica for the instant between fetch and apply, so
|
||||||
|
// alarming on it would cry wolf. It is here to answer "is it moving?"
|
||||||
|
// when the lag counter is stuck.
|
||||||
|
apply: applyProgress(raw),
|
||||||
|
// Also reported rather than alarmed on, for the same reason: a busy master
|
||||||
|
// is always a few transactions ahead for the instant they are in flight.
|
||||||
|
// Null rather than zero when the master could not be reached — "unknown"
|
||||||
|
// and "identical" must not render the same.
|
||||||
|
drift: await this.gtidDrift(),
|
||||||
|
problem,
|
||||||
|
checkedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare executed history between master and replica.
|
||||||
|
*
|
||||||
|
* Two round trips: ask the master what it has executed, then ask the replica
|
||||||
|
* to subtract its own history from that. The subtraction runs on the replica
|
||||||
|
* rather than in TypeScript because `GTID_SUBTRACT` already implements the
|
||||||
|
* interval algebra correctly, and reimplementing set subtraction over binlog
|
||||||
|
* ranges is exactly the kind of thing that looks right and is wrong at the
|
||||||
|
* boundaries.
|
||||||
|
*
|
||||||
|
* @returns null on any failure — a broken drift check must never be mistaken
|
||||||
|
* for a healthy zero.
|
||||||
|
*/
|
||||||
|
private async gtidDrift(): Promise<GtidDrift | null> {
|
||||||
|
try {
|
||||||
|
const masterGtid = (await this.onMaster("SELECT @@gtid_executed")).trim();
|
||||||
|
|
||||||
|
// GTID sets are UUIDs, digits, colons, commas, hyphens, whitespace and
|
||||||
|
// (since 8.4) alphanumeric tags. Nothing else is legal, so rejecting
|
||||||
|
// anything outside that alphabet is a whitelist, not a blacklist: with no
|
||||||
|
// quote and no backslash able to survive it, the value cannot escape the
|
||||||
|
// string literal it is interpolated into below.
|
||||||
|
if (masterGtid && !/^[0-9a-fA-F:,\s_-]+$/.test(masterGtid)) {
|
||||||
|
this.logger.warn("gtid_executed del maestro con formato inesperado");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// An empty set means the master has GTID mode off, and there is nothing
|
||||||
|
// meaningful to compare.
|
||||||
|
if (!masterGtid) return null;
|
||||||
|
|
||||||
|
const flat = masterGtid.replace(/\s+/g, "");
|
||||||
|
// Every GTID set is flattened with REPLACE before it leaves the server.
|
||||||
|
// MySQL wraps `gtid_executed` across lines once it holds more than one
|
||||||
|
// source UUID, and this is read back as tab-separated columns — an
|
||||||
|
// embedded newline would split one row into two and silently truncate the
|
||||||
|
// set at the first UUID.
|
||||||
|
const out = await this.onReplica(
|
||||||
|
"SELECT REPLACE(GTID_SUBTRACT(" +
|
||||||
|
`'${flat}', @@gtid_executed), '\\n', ''), ` +
|
||||||
|
"@@server_uuid, REPLACE(@@gtid_executed, '\\n', '')",
|
||||||
|
["-N"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trailing newline only — never `.trim()`. When nothing is missing the
|
||||||
|
// first column is the empty string, so the line begins with a tab, and
|
||||||
|
// trimming it would shift every column one position left and report the
|
||||||
|
// replica's own UUID as the missing GTID set.
|
||||||
|
const [missingSet = "", serverUuid = "", executed = ""] = out
|
||||||
|
.replace(/\r?\n+$/, "")
|
||||||
|
.split("\t");
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingTransactions: countGtids(missingSet),
|
||||||
|
missingGtidSet: missingSet || null,
|
||||||
|
localTransactions: countGtids(gtidsForUuid(executed, serverUuid)),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
this.logger.warn(`no se pudo comparar GTIDs con el maestro: ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-scan comparison of the customer-visible tables on both sides.
|
||||||
|
*
|
||||||
|
* Deliberately NOT part of `status()`: this reads every row of every table in
|
||||||
|
* `REPLICATED_TABLES` on both servers, so it belongs behind a button, not a
|
||||||
|
* 30-second poll.
|
||||||
|
*
|
||||||
|
* It answers the one question GTID drift cannot. GTIDs prove the replica
|
||||||
|
* applied every transaction the master produced; they say nothing about rows
|
||||||
|
* changed on the replica by some other route. A local write is invisible to
|
||||||
|
* every other field on the card and shows up here as a checksum mismatch.
|
||||||
|
*/
|
||||||
|
async verify(): Promise<VerifyResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const base: VerifyResult = {
|
||||||
|
identical: false,
|
||||||
|
tables: [],
|
||||||
|
problem: null,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
try {
|
||||||
|
sql = await this.fingerprintSql();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ...base, problem: `No se pudo leer el esquema: ${msg}`, elapsedMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
|
||||||
|
let masterOut: string;
|
||||||
|
let replicaOut: string;
|
||||||
|
try {
|
||||||
|
// Sequential, not parallel. Running both at once would have the master
|
||||||
|
// scan under the replica's own read load only sometimes, which makes a
|
||||||
|
// slow run hard to attribute; and the boxes are small enough that two
|
||||||
|
// concurrent full scans is a real memory event on the 946MB replica.
|
||||||
|
masterOut = await this.onMaster(sql);
|
||||||
|
replicaOut = await this.onReplica(sql);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ...base, problem: `No se pudo comparar: ${msg}`, elapsedMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
|
||||||
|
const master = parseFingerprints(masterOut);
|
||||||
|
const replica = parseFingerprints(replicaOut);
|
||||||
|
|
||||||
|
const tables: TableFingerprint[] = REPLICATED_TABLES.map((table) => {
|
||||||
|
const m = master.get(table);
|
||||||
|
const r = replica.get(table);
|
||||||
|
return {
|
||||||
|
table,
|
||||||
|
masterRows: m?.rows ?? -1,
|
||||||
|
replicaRows: r?.rows ?? -1,
|
||||||
|
masterChecksum: m?.checksum ?? "?",
|
||||||
|
replicaChecksum: r?.checksum ?? "?",
|
||||||
|
// Both sides must have answered. A missing row on either side is a
|
||||||
|
// mismatch, never a pass — `undefined === undefined` would otherwise
|
||||||
|
// report two failed reads as agreement.
|
||||||
|
matches:
|
||||||
|
m !== undefined && r !== undefined && m.rows === r.rows && m.checksum === r.checksum,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
identical: tables.every((t) => t.matches),
|
||||||
|
tables,
|
||||||
|
problem: null,
|
||||||
|
checkedAt: base.checkedAt,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the count+checksum query from the live column list.
|
||||||
|
*
|
||||||
|
* The columns come from `information_schema` on the master rather than being
|
||||||
|
* hardcoded, so the check keeps covering the whole row after a migration adds
|
||||||
|
* one. Reading the schema from the master is safe by construction: if the two
|
||||||
|
* schemas had diverged, replication would already be broken.
|
||||||
|
*/
|
||||||
|
private async fingerprintSql(): Promise<string> {
|
||||||
|
const list = REPLICATED_TABLES.map((t) => `'${t}'`).join(",");
|
||||||
|
const raw = await this.onMaster(
|
||||||
|
"SELECT CONCAT(TABLE_NAME, '\\t', COLUMN_NAME) FROM information_schema.COLUMNS " +
|
||||||
|
`WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${list}) ` +
|
||||||
|
"ORDER BY TABLE_NAME, ORDINAL_POSITION",
|
||||||
|
["-N"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols = new Map<string, string[]>();
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const [table, column] = line.trim().split("\t");
|
||||||
|
if (!table || !column) continue;
|
||||||
|
cols.set(table, [...(cols.get(table) ?? []), column]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selects = REPLICATED_TABLES.map((table) => {
|
||||||
|
const columns = cols.get(table);
|
||||||
|
if (!columns?.length) throw new Error(`tabla ${table} sin columnas`);
|
||||||
|
// CONVERT(... USING binary), never CAST(... AS CHAR).
|
||||||
|
//
|
||||||
|
// CAST to CHAR transcodes into the *connection* character set, which is
|
||||||
|
// not the same on the two servers: the mysql client inside the master's
|
||||||
|
// container negotiates latin1, while the replica's negotiates utf8mb4.
|
||||||
|
// Every accented character in a Mexican name, street or note therefore
|
||||||
|
// hashes to different bytes on each side, and the comparison reports a
|
||||||
|
// permanent mismatch on exactly the tables that hold free text — a
|
||||||
|
// verification tool that always cries wolf, which is worse than none.
|
||||||
|
// Comparing the stored bytes sidesteps the session entirely. (Verified
|
||||||
|
// 2026-08-06: with CAST, `customers.name` gave 3344437324815 vs
|
||||||
|
// 3339150372121; with CONVERT both give 3339150372121.)
|
||||||
|
//
|
||||||
|
// 0x1f (unit separator) joins the columns and 0x1e (record separator)
|
||||||
|
// stands in for NULL. Both matter: CONCAT_WS *skips* NULLs rather than
|
||||||
|
// emitting an empty field, so without a placeholder the rows
|
||||||
|
// ('a', NULL, 'b') and ('a', 'b', NULL) produce the same string and a
|
||||||
|
// column-shifting bug would checksum as identical.
|
||||||
|
const expr = columns
|
||||||
|
.map((c) => `IFNULL(CONVERT(\`${c}\` USING binary), 0x1e)`)
|
||||||
|
.join(", 0x1f, ");
|
||||||
|
// SUM, not a running hash: addition is commutative, so the result does not
|
||||||
|
// depend on the order rows come back in. The two servers have no reason to
|
||||||
|
// scan in the same order and are not asked to.
|
||||||
|
return (
|
||||||
|
`SELECT '${table}' AS t, COUNT(*) AS n, ` +
|
||||||
|
`IFNULL(SUM(CRC32(CONCAT_WS(0x1f, ${expr}))), 0) AS c FROM \`${table}\``
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return selects.join(" UNION ALL ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ plumbing */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a statement on the replica.
|
||||||
|
*
|
||||||
|
* --ssl is required: the replica sets require_secure_transport=ON.
|
||||||
|
*
|
||||||
|
* --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off the
|
||||||
|
* website makes. This hop never leaves Tailscale — the replica is reached on
|
||||||
|
* its CGNAT tailnet address and the tailnet ACL admits only this host — so
|
||||||
|
* WireGuard already authenticates the peer. The DreamHost leg crosses the
|
||||||
|
* public internet and therefore pins the CA instead. The client here is
|
||||||
|
* MariaDB's, which rejects our self-signed CA outright unless it is handed the
|
||||||
|
* CA file, which would mean shipping a cert into this image for a link that is
|
||||||
|
* already authenticated.
|
||||||
|
*/
|
||||||
|
private async onReplica(sql: string, extra: string[] = []): Promise<string> {
|
||||||
|
const host = process.env.REPLICA_DB_HOST!;
|
||||||
|
const user = process.env.REPLICA_DB_USER!;
|
||||||
|
const password = process.env.REPLICA_DB_PASS!;
|
||||||
|
const { stdout } = await exec(
|
||||||
|
"mysql",
|
||||||
|
[
|
||||||
|
`--host=${host}`,
|
||||||
|
`--user=${user}`,
|
||||||
|
"--ssl",
|
||||||
|
"--ssl-verify-server-cert=0",
|
||||||
|
"--connect-timeout=5",
|
||||||
|
...extra,
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
],
|
||||||
|
{ env: { ...process.env, MYSQL_PWD: password }, timeout: VERIFY_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a statement on the master, using the application's own DATABASE_URL.
|
||||||
|
*
|
||||||
|
* The app credential is enough here on purpose — everything this class sends
|
||||||
|
* to the master is a SELECT against `information_schema` or a system variable.
|
||||||
|
* Reaching for OPS_DB_ADMIN_* the way OpsService does would hand a monitoring
|
||||||
|
* read path a credential that can also restore a dump.
|
||||||
|
*/
|
||||||
|
private async onMaster(sql: string, extra: string[] = []): Promise<string> {
|
||||||
|
const raw = process.env.DATABASE_URL;
|
||||||
|
if (!raw) throw new Error("DATABASE_URL no está configurada");
|
||||||
|
const u = new URL(raw);
|
||||||
|
const { stdout } = await exec(
|
||||||
|
"mysql",
|
||||||
|
[
|
||||||
|
`--host=${u.hostname}`,
|
||||||
|
`--port=${u.port || "3306"}`,
|
||||||
|
`--user=${decodeURIComponent(u.username)}`,
|
||||||
|
"--connect-timeout=5",
|
||||||
|
...extra,
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
u.pathname.replace(/^\//, ""),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: { ...process.env, MYSQL_PWD: decodeURIComponent(u.password) },
|
||||||
|
timeout: VERIFY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full scans on a 1-vCPU replica are not fast; 15s would cut them off. */
|
||||||
|
const VERIFY_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Parse the `t\tn\tc` rows the fingerprint query emits under `mysql -N`. */
|
||||||
|
function parseFingerprints(raw: string): Map<string, { rows: number; checksum: string }> {
|
||||||
|
const out = new Map<string, { rows: number; checksum: string }>();
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const [table, n, c] = line.trim().split("\t");
|
||||||
|
if (!table || n === undefined || c === undefined) continue;
|
||||||
|
const rows = Number(n);
|
||||||
|
if (!Number.isFinite(rows)) continue;
|
||||||
|
// The checksum stays a string. Sums of CRC32 over 40k rows exceed 2^53, so
|
||||||
|
// parsing it as a number would round and make distinct tables compare equal.
|
||||||
|
out.set(table, { rows, checksum: c });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the transactions in a GTID set.
|
||||||
|
*
|
||||||
|
* Exported for testing. The format is `uuid[:tag]:interval[:interval]...`,
|
||||||
|
* comma-separated, where an interval is `N` or `N-M` inclusive at both ends —
|
||||||
|
* so `1-5` is five transactions, not four.
|
||||||
|
*
|
||||||
|
* MySQL 8.4 added an optional alphanumeric tag between the UUID and the first
|
||||||
|
* interval. It is skipped rather than parsed: any segment that is not a number
|
||||||
|
* or a number range is not an interval, whatever else it may be.
|
||||||
|
*/
|
||||||
|
export function countGtids(set: string): number {
|
||||||
|
if (!set.trim()) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const group of set.split(",")) {
|
||||||
|
for (const part of group.trim().split(":").slice(1)) {
|
||||||
|
const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim());
|
||||||
|
if (!m) continue;
|
||||||
|
const from = Number(m[1]);
|
||||||
|
const to = m[2] === undefined ? from : Number(m[2]);
|
||||||
|
if (Number.isFinite(from) && Number.isFinite(to) && to >= from) total += to - from + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow a GTID set to the intervals belonging to one server UUID.
|
||||||
|
*
|
||||||
|
* Exported for testing. Used to isolate the replica's own writes from the
|
||||||
|
* history it replicated, which are interleaved in the same `gtid_executed`.
|
||||||
|
*/
|
||||||
|
export function gtidsForUuid(set: string, uuid: string): string {
|
||||||
|
if (!uuid.trim()) return "";
|
||||||
|
const wanted = uuid.trim().toLowerCase();
|
||||||
|
return set
|
||||||
|
.split(",")
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter((g) => g.toLowerCase().startsWith(`${wanted}:`))
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive relay-apply progress from `SHOW REPLICA STATUS\G` output.
|
||||||
|
*
|
||||||
|
* Exported for testing. Free in query terms — it re-reads four more fields from
|
||||||
|
* the output the caller already has, with no second round trip to the replica
|
||||||
|
* and no connection to the source.
|
||||||
|
*
|
||||||
|
* @returns null when either position is missing or unparseable, which is what
|
||||||
|
* happens on a server that is not a replica at all.
|
||||||
|
*/
|
||||||
|
export function applyProgress(raw: string): ApplyProgress | null {
|
||||||
|
const num = (name: string): number | null => {
|
||||||
|
const v = replicaField(raw, name);
|
||||||
|
if (v === null || v === "NULL") return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readPos = num("Read_Source_Log_Pos");
|
||||||
|
const execPos = num("Exec_Source_Log_Pos");
|
||||||
|
if (readPos === null || execPos === null) return null;
|
||||||
|
|
||||||
|
const sourceLogFile = replicaField(raw, "Source_Log_File");
|
||||||
|
const relayLogFile = replicaField(raw, "Relay_Source_Log_File");
|
||||||
|
const sameFile =
|
||||||
|
sourceLogFile !== null && relayLogFile !== null && sourceLogFile === relayLogFile;
|
||||||
|
|
||||||
|
// Clamped at 0: the SQL thread cannot be ahead of the I/O thread, but the two
|
||||||
|
// fields are sampled independently, so a rotation racing this read can print
|
||||||
|
// a momentarily negative delta. Zero is the honest floor, not a bug.
|
||||||
|
const backlogBytes = sameFile ? Math.max(0, readPos - execPos) : null;
|
||||||
|
|
||||||
|
let percent: number | null = null;
|
||||||
|
if (backlogBytes !== null && readPos > 0) {
|
||||||
|
// Truncate rather than round, and hold short of 100 while bytes remain —
|
||||||
|
// see the doc on ApplyProgress.percent.
|
||||||
|
const p = Math.floor((execPos / readPos) * 10_000) / 100;
|
||||||
|
percent = backlogBytes === 0 ? 100 : Math.min(p, 99.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sourceLogFile, readPos, relayLogFile, execPos, sameFile, backlogBytes, percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one field out of `SHOW REPLICA STATUS\G` output.
|
||||||
|
*
|
||||||
|
* Exported for testing, and worth testing: the obvious regex is wrong.
|
||||||
|
* `\s` matches newlines in JavaScript, so `^\s*NAME:\s*(.*)$` lets the `\s*`
|
||||||
|
* after the colon swallow the line break of an EMPTY field and capture the
|
||||||
|
* following line instead. Last_SQL_Error is empty on a healthy replica, so that
|
||||||
|
* version reported the next line ("Replicate_Ignore_Server_Ids:") as a SQL
|
||||||
|
* error and rendered a perfectly healthy replica as broken.
|
||||||
|
*
|
||||||
|
* Hence `[^\S\n]` — horizontal whitespace only — on both sides of the name.
|
||||||
|
*
|
||||||
|
* @returns the trimmed value, or null when the field is absent OR empty. Empty
|
||||||
|
* and absent mean the same thing to every caller here: MySQL prints
|
||||||
|
* error fields as blank rather than omitting them.
|
||||||
|
*/
|
||||||
|
export function replicaField(raw: string, name: string): string | null {
|
||||||
|
const m = raw.match(new RegExp(`^[^\\S\\n]*${name}:[^\\S\\n]*(.*)$`, "m"));
|
||||||
|
const v = m?.[1]?.trim();
|
||||||
|
return v === undefined || v === "" ? null : v;
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import {
|
||||||
|
applyProgress,
|
||||||
|
countGtids,
|
||||||
|
gtidsForUuid,
|
||||||
|
replicaField,
|
||||||
|
} from "./replication.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to
|
||||||
|
* the fields the panel reads plus the neighbours that matter.
|
||||||
|
*
|
||||||
|
* The empty `Last_SQL_Error:` immediately followed by
|
||||||
|
* `Replicate_Ignore_Server_Ids:` is the whole point of the fixture — that exact
|
||||||
|
* adjacency is what the first implementation misread.
|
||||||
|
*/
|
||||||
|
const HEALTHY = [
|
||||||
|
"*************************** 1. row ***************************",
|
||||||
|
" Replica_IO_State: Waiting for source to send event",
|
||||||
|
" Source_Host: 100.103.77.46",
|
||||||
|
" Source_User: repl",
|
||||||
|
" Source_Log_File: binlog.000042",
|
||||||
|
" Read_Source_Log_Pos: 194884231",
|
||||||
|
" Relay_Source_Log_File: binlog.000042",
|
||||||
|
" Exec_Source_Log_Pos: 194884231",
|
||||||
|
" Replica_IO_Running: Yes",
|
||||||
|
" Replica_SQL_Running: Yes",
|
||||||
|
" Replicate_Do_DB: ",
|
||||||
|
" Last_Errno: 0",
|
||||||
|
" Last_Error: ",
|
||||||
|
" Seconds_Behind_Source: 0",
|
||||||
|
" Last_IO_Errno: 0",
|
||||||
|
" Last_IO_Error: ",
|
||||||
|
" Last_SQL_Errno: 0",
|
||||||
|
" Last_SQL_Error: ",
|
||||||
|
" Replicate_Ignore_Server_Ids: ",
|
||||||
|
" Source_Server_Id: 1",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const BROKEN = [
|
||||||
|
" Replica_IO_Running: Yes",
|
||||||
|
" Replica_SQL_Running: No",
|
||||||
|
" Seconds_Behind_Source: NULL",
|
||||||
|
" Last_IO_Error: ",
|
||||||
|
" Last_SQL_Error: Could not execute Write_rows event on table jorgecuadros.customers",
|
||||||
|
" Replicate_Ignore_Server_Ids: ",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("replicaField", () => {
|
||||||
|
it("reads plain values", () => {
|
||||||
|
expect(replicaField(HEALTHY, "Replica_IO_Running")).toBe("Yes");
|
||||||
|
expect(replicaField(HEALTHY, "Replica_SQL_Running")).toBe("Yes");
|
||||||
|
expect(replicaField(HEALTHY, "Source_Host")).toBe("100.103.77.46");
|
||||||
|
expect(replicaField(HEALTHY, "Seconds_Behind_Source")).toBe("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regression this file exists for. `\s` matches newlines in JavaScript,
|
||||||
|
* so `^\s*NAME:\s*(.*)$` walks past an empty field's line break and captures
|
||||||
|
* the NEXT line — turning a healthy replica into
|
||||||
|
* "Error SQL: Replicate_Ignore_Server_Ids:" in the admin panel.
|
||||||
|
*/
|
||||||
|
it("returns null for an empty field instead of the following line", () => {
|
||||||
|
expect(replicaField(HEALTHY, "Last_SQL_Error")).toBeNull();
|
||||||
|
expect(replicaField(HEALTHY, "Last_IO_Error")).toBeNull();
|
||||||
|
expect(replicaField(HEALTHY, "Last_Error")).toBeNull();
|
||||||
|
expect(replicaField(HEALTHY, "Replicate_Do_DB")).toBeNull();
|
||||||
|
expect(replicaField(HEALTHY, "Replicate_Ignore_Server_Ids")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reads a real error when there is one", () => {
|
||||||
|
expect(replicaField(BROKEN, "Last_SQL_Error")).toBe(
|
||||||
|
"Could not execute Write_rows event on table jorgecuadros.customers",
|
||||||
|
);
|
||||||
|
expect(replicaField(BROKEN, "Replica_SQL_Running")).toBe("No");
|
||||||
|
});
|
||||||
|
|
||||||
|
/** NULL is a distinct state from empty and must survive as the literal. */
|
||||||
|
it("preserves the literal NULL that MySQL prints for unknown lag", () => {
|
||||||
|
expect(replicaField(BROKEN, "Seconds_Behind_Source")).toBe("NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a field that is not present at all", () => {
|
||||||
|
expect(replicaField(HEALTHY, "Nonexistent_Field")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field names are matched at the start of a line. Without the line anchor,
|
||||||
|
* "Last_Error" would also match inside "Last_SQL_Error" and read the wrong
|
||||||
|
* value — the two carry different things and both feed the panel.
|
||||||
|
*/
|
||||||
|
it("does not match a field name that is a suffix of another", () => {
|
||||||
|
const raw = " Last_SQL_Error: boom\n Last_Error: ";
|
||||||
|
expect(replicaField(raw, "Last_Error")).toBeNull();
|
||||||
|
expect(replicaField(raw, "Last_SQL_Error")).toBe("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Builds the four position fields the apply-progress reader cares about. */
|
||||||
|
function positions(
|
||||||
|
sourceFile: string,
|
||||||
|
readPos: number | string,
|
||||||
|
relayFile: string,
|
||||||
|
execPos: number | string,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
` Source_Log_File: ${sourceFile}`,
|
||||||
|
` Read_Source_Log_Pos: ${readPos}`,
|
||||||
|
` Relay_Source_Log_File: ${relayFile}`,
|
||||||
|
` Exec_Source_Log_Pos: ${execPos}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyProgress", () => {
|
||||||
|
it("reports zero backlog and 100% when both positions match", () => {
|
||||||
|
const p = applyProgress(HEALTHY)!;
|
||||||
|
expect(p.sameFile).toBe(true);
|
||||||
|
expect(p.sourceLogFile).toBe("binlog.000042");
|
||||||
|
expect(p.readPos).toBe(194884231);
|
||||||
|
expect(p.execPos).toBe(194884231);
|
||||||
|
expect(p.backlogBytes).toBe(0);
|
||||||
|
expect(p.percent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the byte delta when the SQL thread trails inside one file", () => {
|
||||||
|
const p = applyProgress(positions("binlog.000042", 2_000_000, "binlog.000042", 1_500_000))!;
|
||||||
|
expect(p.backlogBytes).toBe(500_000);
|
||||||
|
expect(p.percent).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reason the byte delta exists at all. `Seconds_Behind_Source` holds at 0
|
||||||
|
* while the SQL thread is mid-transaction, so the backlog is the only field
|
||||||
|
* that moves — and the only one that says the replica is not caught up.
|
||||||
|
*/
|
||||||
|
it("shows a backlog even when the lag counter reads zero", () => {
|
||||||
|
const raw = [
|
||||||
|
" Seconds_Behind_Source: 0",
|
||||||
|
positions("binlog.000042", 900, "binlog.000042", 400),
|
||||||
|
].join("\n");
|
||||||
|
expect(replicaField(raw, "Seconds_Behind_Source")).toBe("0");
|
||||||
|
expect(applyProgress(raw)!.backlogBytes).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Positions restart near 4 in every new binlog file, so subtracting across
|
||||||
|
* files produces a number that is not a backlog — here it would be a large
|
||||||
|
* NEGATIVE one, which would render as "ahead of the source".
|
||||||
|
*/
|
||||||
|
it("refuses to compare positions across different binlog files", () => {
|
||||||
|
const p = applyProgress(positions("binlog.000043", 500, "binlog.000042", 194_000_000))!;
|
||||||
|
expect(p.sameFile).toBe(false);
|
||||||
|
expect(p.backlogBytes).toBeNull();
|
||||||
|
expect(p.percent).toBeNull();
|
||||||
|
expect(p.sourceLogFile).toBe("binlog.000043");
|
||||||
|
expect(p.relayLogFile).toBe("binlog.000042");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percent must not round up to 100 while bytes remain: binlog positions are
|
||||||
|
* large, so a genuine backlog is a rounding error away from the whole file
|
||||||
|
* and would otherwise render as "caught up" on a replica that is not.
|
||||||
|
*/
|
||||||
|
it("stops short of 100% while any backlog remains", () => {
|
||||||
|
const p = applyProgress(positions("binlog.000042", 194_884_231, "binlog.000042", 194_884_230))!;
|
||||||
|
expect(p.backlogBytes).toBe(1);
|
||||||
|
expect(p.percent).toBe(99.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sampled independently, so a rotation racing the read can invert them. */
|
||||||
|
it("clamps a momentarily negative delta to zero", () => {
|
||||||
|
const p = applyProgress(positions("binlog.000042", 400, "binlog.000042", 500))!;
|
||||||
|
expect(p.backlogBytes).toBe(0);
|
||||||
|
expect(p.percent).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the server is not a replica and prints no positions", () => {
|
||||||
|
expect(applyProgress("")).toBeNull();
|
||||||
|
expect(applyProgress(BROKEN)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A stopped thread makes MySQL print NULL, which is not a position. */
|
||||||
|
it("returns null when a position is NULL", () => {
|
||||||
|
expect(applyProgress(positions("binlog.000042", "NULL", "binlog.000042", 400))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real GTID sets from the live pair, captured 2026-08-06. The replica's own
|
||||||
|
* server UUID (3b103283…) carries the transactions the seed dump load executed
|
||||||
|
* locally; the master's UUID (defc34e2…) carries the replicated history.
|
||||||
|
*/
|
||||||
|
const REPLICA_EXECUTED =
|
||||||
|
"3b103283-8f15-11f1-a52b-020017027b33:1-513," +
|
||||||
|
"defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-525";
|
||||||
|
const REPLICA_UUID = "3b103283-8f15-11f1-a52b-020017027b33";
|
||||||
|
|
||||||
|
describe("countGtids", () => {
|
||||||
|
it("counts an inclusive range at both ends", () => {
|
||||||
|
// 1-5 is five transactions. Off-by-one here understates the gap, which is
|
||||||
|
// the direction that hides a problem.
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a bare single transaction", () => {
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:7")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums several intervals under one UUID", () => {
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5:8:10-12")).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums across UUIDs, including the wrapped form MySQL prints", () => {
|
||||||
|
expect(countGtids(REPLICA_EXECUTED)).toBe(513 + 525);
|
||||||
|
// `gtid_executed` comes back wrapped once it holds more than one UUID.
|
||||||
|
expect(countGtids(REPLICA_EXECUTED.replace(",", ",\n"))).toBe(513 + 525);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An empty subtraction result is the caught-up case and must be zero. */
|
||||||
|
it("returns 0 for an empty or blank set", () => {
|
||||||
|
expect(countGtids("")).toBe(0);
|
||||||
|
expect(countGtids(" \n ")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 8.4 allows an alphanumeric tag between the UUID and the intervals.
|
||||||
|
* It is not an interval and must not be counted as one.
|
||||||
|
*/
|
||||||
|
it("skips a tag without counting it", () => {
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:mytag:1-3")).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gtidsForUuid", () => {
|
||||||
|
it("isolates the replica's own transactions from the replicated history", () => {
|
||||||
|
expect(countGtids(gtidsForUuid(REPLICA_EXECUTED, REPLICA_UUID))).toBe(513);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a UUID that is not in the set", () => {
|
||||||
|
expect(gtidsForUuid(REPLICA_EXECUTED, "00000000-0000-0000-0000-000000000000")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colon matters. Without it a UUID prefix would match a longer UUID that
|
||||||
|
* merely starts the same way, and the replica's local writes would be
|
||||||
|
* over-reported.
|
||||||
|
*/
|
||||||
|
it("does not match on a bare prefix", () => {
|
||||||
|
expect(gtidsForUuid(REPLICA_EXECUTED, "3b103283")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when the UUID is blank", () => {
|
||||||
|
expect(gtidsForUuid(REPLICA_EXECUTED, "")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||||
import { OpsJobKind } from "@jorgecuadros/database";
|
import { OpsJobKind } from "@jorgecuadros/database";
|
||||||
|
|
||||||
export class StartJobDto {
|
export class StartJobDto {
|
||||||
@@ -9,4 +9,13 @@ export class StartJobDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
file?: string;
|
file?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REIMPORT only: proceed even though the rebuild deletes rows that exist only
|
||||||
|
* in the platform. Off by default, so the guard in run_all.py stops the job
|
||||||
|
* and lists what would be lost rather than the operator finding out after.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
forceFull?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { Currency } from "@jorgecuadros/database";
|
|||||||
// Each child DTO covers create; updates reuse the same shape with all fields
|
// Each child DTO covers create; updates reuse the same shape with all fields
|
||||||
// optional via the corresponding Update class. Route supplies the policyId.
|
// optional via the corresponding Update class. Route supplies the policyId.
|
||||||
|
|
||||||
|
// A policy split into several exhibiciones prices each payment on its own —
|
||||||
|
// the Access form printed the whole money row once per pago — so the premium
|
||||||
|
// breakdown repeats here. `amount` remains what was actually collected and is
|
||||||
|
// never recomputed from the breakdown; the two differ by rounding in the books.
|
||||||
export class InstallmentDto {
|
export class InstallmentDto {
|
||||||
@IsInt() sequence!: number;
|
@IsInt() sequence!: number;
|
||||||
@IsOptional() @IsNumber() amount?: number;
|
@IsOptional() @IsNumber() amount?: number;
|
||||||
@@ -20,6 +24,13 @@ export class InstallmentDto {
|
|||||||
@IsOptional() @IsString() paidDate?: string;
|
@IsOptional() @IsString() paidDate?: string;
|
||||||
@IsOptional() @IsString() checkNumber?: string;
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||||
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
}
|
}
|
||||||
export class UpdateInstallmentDto {
|
export class UpdateInstallmentDto {
|
||||||
@IsOptional() @IsInt() sequence?: number;
|
@IsOptional() @IsInt() sequence?: number;
|
||||||
@@ -29,6 +40,13 @@ export class UpdateInstallmentDto {
|
|||||||
@IsOptional() @IsString() paidDate?: string;
|
@IsOptional() @IsString() paidDate?: string;
|
||||||
@IsOptional() @IsString() checkNumber?: string;
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||||
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VehicleDto {
|
export class VehicleDto {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { PoliciesService } from "./policies.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a lookup row that policies still reference used to succeed and
|
||||||
|
* silently blank the field on every one of them, because both FKs are
|
||||||
|
* `ON DELETE SET NULL` (`0000_init`). That is not a hypothetical: it is how
|
||||||
|
* the `M_EMPR` policy type disappeared from the dev database and left 5
|
||||||
|
* policies with a null `policyTypeId`, found only by querying months later.
|
||||||
|
*
|
||||||
|
* These tests pin the refusal. They drive the service with a stub client
|
||||||
|
* rather than a database because what is being asserted is the guard, not
|
||||||
|
* Prisma — and a test that needed a live MySQL would not run in CI.
|
||||||
|
*/
|
||||||
|
function serviceWith(counts: {
|
||||||
|
policies?: number;
|
||||||
|
claims?: number;
|
||||||
|
}): { service: PoliciesService; deleted: string[] } {
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const prisma = {
|
||||||
|
policy: { count: async () => counts.policies ?? 0 },
|
||||||
|
claim: { count: async () => counts.claims ?? 0 },
|
||||||
|
insuranceProvider: {
|
||||||
|
findUnique: async () => ({ id: "p1", name: "ANA SEGUROS" }),
|
||||||
|
delete: async () => {
|
||||||
|
deleted.push("provider");
|
||||||
|
return { id: "p1" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policyType: {
|
||||||
|
findUnique: async () => ({ id: "t1", name: "M_EMPR" }),
|
||||||
|
delete: async () => {
|
||||||
|
deleted.push("policyType");
|
||||||
|
return { id: "t1" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adjuster: {
|
||||||
|
findUnique: async () => ({ id: "a1", name: "JUAN PEREZ" }),
|
||||||
|
delete: async () => {
|
||||||
|
deleted.push("adjuster");
|
||||||
|
return { id: "a1" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const storage = {} as never;
|
||||||
|
return {
|
||||||
|
service: new PoliciesService(prisma as never, storage),
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lookup deletes refuse while the row is in use", () => {
|
||||||
|
it("refuses a policy type that policies still carry, and names the count", () => {
|
||||||
|
const { service, deleted } = serviceWith({ policies: 5 });
|
||||||
|
return service.removePolicyType("t1").then(
|
||||||
|
() => {
|
||||||
|
throw new Error("expected the delete to be refused");
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
expect(err).toBeInstanceOf(BadRequestException);
|
||||||
|
// The operator has to be told WHICH row and HOW MANY, or the message
|
||||||
|
// is not actionable.
|
||||||
|
expect((err as Error).message).toContain("M_EMPR");
|
||||||
|
expect((err as Error).message).toContain("5");
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a carrier that policies still carry", async () => {
|
||||||
|
const { service, deleted } = serviceWith({ policies: 738 });
|
||||||
|
await expect(service.removeProvider("p1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an adjuster still assigned to claims", async () => {
|
||||||
|
// Same `ON DELETE SET NULL` trap, on `claims.adjusterId`.
|
||||||
|
const { service, deleted } = serviceWith({ claims: 2 });
|
||||||
|
await expect(service.removeAdjuster("a1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the delete once nothing references the row", async () => {
|
||||||
|
const { service, deleted } = serviceWith({ policies: 0, claims: 0 });
|
||||||
|
await service.removePolicyType("t1");
|
||||||
|
await service.removeProvider("p1");
|
||||||
|
await service.removeAdjuster("a1");
|
||||||
|
expect(deleted).toEqual(["policyType", "provider", "adjuster"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, MinLength } from "class-validator";
|
import { IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator";
|
||||||
|
|
||||||
export class ProviderDto {
|
export class ProviderDto {
|
||||||
@IsString() @MinLength(1) name!: string;
|
@IsString() @MinLength(1) name!: string;
|
||||||
@@ -7,13 +7,19 @@ export class UpdateProviderDto {
|
|||||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `taxRate` is the IVA fraction for this line of business (0.08 = 8%), the
|
||||||
|
// legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made editable. Bounded at
|
||||||
|
// 1 because a rate is a fraction, not a percentage: 8 entered here would tax a
|
||||||
|
// $600 premium $4,800, and the mistake is easy to make.
|
||||||
export class PolicyTypeDto {
|
export class PolicyTypeDto {
|
||||||
@IsString() @MinLength(1) name!: string;
|
@IsString() @MinLength(1) name!: string;
|
||||||
@IsOptional() @IsString() shortDescription?: string;
|
@IsOptional() @IsString() shortDescription?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||||
}
|
}
|
||||||
export class UpdatePolicyTypeDto {
|
export class UpdatePolicyTypeDto {
|
||||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
@IsOptional() @IsString() shortDescription?: string;
|
@IsOptional() @IsString() shortDescription?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdjusterDto {
|
export class AdjusterDto {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
@@ -250,7 +250,16 @@ export class PoliciesService {
|
|||||||
const [types, providers] = await this.prisma.$transaction([
|
const [types, providers] = await this.prisma.$transaction([
|
||||||
this.prisma.policyType.findMany({
|
this.prisma.policyType.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortDescription: true,
|
||||||
|
// The capture form computes IVA client-side as the operator types,
|
||||||
|
// so the rate has to travel with the type list it already loads —
|
||||||
|
// an extra round-trip per keystroke is not an option.
|
||||||
|
taxRate: true,
|
||||||
|
_count: { select: { policies: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.insuranceProvider.findMany({
|
this.prisma.insuranceProvider.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
@@ -259,7 +268,13 @@ export class PoliciesService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
types: types.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
shortDescription: t.shortDescription,
|
||||||
|
taxRate: t.taxRate,
|
||||||
|
count: t._count.policies,
|
||||||
|
})),
|
||||||
providers: providers.map((p) => ({
|
providers: providers.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -358,6 +373,7 @@ export class PoliciesService {
|
|||||||
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
|
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async ensurePolicy(id: string) {
|
private async ensurePolicy(id: string) {
|
||||||
const found = await this.prisma.policy.findUnique({
|
const found = await this.prisma.policy.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -396,6 +412,13 @@ export class PoliciesService {
|
|||||||
paidDate: toDate(dto.paidDate) ?? undefined,
|
paidDate: toDate(dto.paidDate) ?? undefined,
|
||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
isCash: dto.isCash,
|
isCash: dto.isCash,
|
||||||
|
netPremium: dto.netPremium,
|
||||||
|
surcharge: dto.surcharge,
|
||||||
|
policyFee: dto.policyFee,
|
||||||
|
tax: dto.tax,
|
||||||
|
taxRate: dto.taxRate,
|
||||||
|
total: dto.total,
|
||||||
|
commission: dto.commission,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -411,6 +434,13 @@ export class PoliciesService {
|
|||||||
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
isCash: dto.isCash,
|
isCash: dto.isCash,
|
||||||
|
netPremium: dto.netPremium,
|
||||||
|
surcharge: dto.surcharge,
|
||||||
|
policyFee: dto.policyFee,
|
||||||
|
tax: dto.tax,
|
||||||
|
taxRate: dto.taxRate,
|
||||||
|
total: dto.total,
|
||||||
|
commission: dto.commission,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -553,7 +583,40 @@ export class PoliciesService {
|
|||||||
updateProvider(id: string, dto: UpdateProviderDto) {
|
updateProvider(id: string, dto: UpdateProviderDto) {
|
||||||
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
removeProvider(id: string) {
|
/**
|
||||||
|
* Deleting a lookup row that policies still point at is silent data loss.
|
||||||
|
*
|
||||||
|
* Both FKs are `ON DELETE SET NULL` (see `0000_init`), so the delete
|
||||||
|
* succeeds, returns 200, and blanks the field on every policy that used it
|
||||||
|
* — with no error and nothing in the UI to suggest anything happened. That
|
||||||
|
* is how the `M_EMPR` policy type disappeared and left 5 policies with a
|
||||||
|
* null `policyTypeId`, only found later by querying.
|
||||||
|
*
|
||||||
|
* Refusing is the whole fix. There is no "are you sure": the operator
|
||||||
|
* reassigns those policies first, which is work the app cannot do for them
|
||||||
|
* because only they know which type is correct.
|
||||||
|
*/
|
||||||
|
private async assertLookupUnused(
|
||||||
|
kind: "provider" | "policyType",
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const where = kind === "provider" ? { insuranceProviderId: id } : { policyTypeId: id };
|
||||||
|
const count = await this.prisma.policy.count({ where });
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
kind === "provider"
|
||||||
|
? (await this.prisma.insuranceProvider.findUnique({ where: { id } }))?.name
|
||||||
|
: (await this.prisma.policyType.findUnique({ where: { id } }))?.name;
|
||||||
|
const noun = kind === "provider" ? "La aseguradora" : "El tipo de póliza";
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${noun} «${label ?? id}» está en uso por ${count} póliza(s). ` +
|
||||||
|
"Reasígnelas antes de eliminarlo.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeProvider(id: string) {
|
||||||
|
await this.assertLookupUnused("provider", id);
|
||||||
return this.prisma.insuranceProvider.delete({ where: { id } });
|
return this.prisma.insuranceProvider.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,7 +626,8 @@ export class PoliciesService {
|
|||||||
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
||||||
return this.prisma.policyType.update({ where: { id }, data: dto });
|
return this.prisma.policyType.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
removePolicyType(id: string) {
|
async removePolicyType(id: string) {
|
||||||
|
await this.assertLookupUnused("policyType", id);
|
||||||
return this.prisma.policyType.delete({ where: { id } });
|
return this.prisma.policyType.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +637,17 @@ export class PoliciesService {
|
|||||||
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
||||||
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
removeAdjuster(id: string) {
|
/** Same `ON DELETE SET NULL` trap as the two above, on `claims.adjusterId`:
|
||||||
|
* deleting a busy adjuster would quietly strip them off their claims. */
|
||||||
|
async removeAdjuster(id: string) {
|
||||||
|
const count = await this.prisma.claim.count({ where: { adjusterId: id } });
|
||||||
|
if (count > 0) {
|
||||||
|
const row = await this.prisma.adjuster.findUnique({ where: { id } });
|
||||||
|
throw new BadRequestException(
|
||||||
|
`El ajustador «${row?.name ?? id}» está asignado a ${count} siniestro(s). ` +
|
||||||
|
"Reasígnelos antes de eliminarlo.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.prisma.adjuster.delete({ where: { id } });
|
return this.prisma.adjuster.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Currency } from "@jorgecuadros/database";
|
import { Currency, PaymentFrequency } from "@jorgecuadros/database";
|
||||||
import { IsEnum } from "class-validator";
|
import { IsEnum } from "class-validator";
|
||||||
|
|
||||||
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
||||||
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
||||||
* service. `total` is legacy-dead data — the UI uses netPremium. */
|
* service. `total` is legacy-dead data on migrated rows — list and sort code
|
||||||
|
* still uses netPremium — but the capture form writes it going forward, along
|
||||||
|
* with `tax`, from the arithmetic in premium.ts. Both arrive as plain numbers
|
||||||
|
* rather than being recomputed server-side: the printed policy is the record
|
||||||
|
* of truth and staff must be able to key its rounding verbatim. */
|
||||||
export class CreatePolicyDto {
|
export class CreatePolicyDto {
|
||||||
@IsString() @MinLength(1) policyNumber!: string;
|
@IsString() @MinLength(1) policyNumber!: string;
|
||||||
@IsString() @MinLength(1) customerId!: string;
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
@@ -24,10 +28,14 @@ export class CreatePolicyDto {
|
|||||||
@IsOptional() @IsString() policyTo?: string;
|
@IsOptional() @IsString() policyTo?: string;
|
||||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||||
@IsOptional() @IsNumber() netPremium?: number;
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
@IsOptional() @IsNumber() policyFee?: number;
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
@IsOptional() @IsNumber() commission?: number;
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
@IsOptional() @IsString() observations?: string;
|
@IsOptional() @IsString() observations?: string;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
@@ -48,10 +56,14 @@ export class UpdatePolicyDto {
|
|||||||
@IsOptional() @IsString() policyTo?: string;
|
@IsOptional() @IsString() policyTo?: string;
|
||||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||||
@IsOptional() @IsNumber() netPremium?: number;
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
@IsOptional() @IsNumber() policyFee?: number;
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
@IsOptional() @IsNumber() commission?: number;
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
@IsOptional() @IsString() observations?: string;
|
@IsOptional() @IsString() observations?: string;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_TAX_RATE,
|
||||||
|
computeTax,
|
||||||
|
computeTotal,
|
||||||
|
resolveTaxRate,
|
||||||
|
surchargeApplies,
|
||||||
|
taxableBase,
|
||||||
|
} from "./premium";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reference case is policy 7006785 (MULT, semestral, GMX, two payments) as
|
||||||
|
* it stands in the Access books — the screen Jorge sent. Both of its money
|
||||||
|
* rows are asserted, because the second one is the case that proves the
|
||||||
|
* surcharge belongs in the taxable base and that a zero policy fee is a real
|
||||||
|
* value rather than a missing one.
|
||||||
|
*/
|
||||||
|
describe("premium arithmetic", () => {
|
||||||
|
it("matches the first payment of policy 7006785", () => {
|
||||||
|
const parts = { netPremium: 610.86, surcharge: 8.55, policyFee: 31.0 };
|
||||||
|
expect(taxableBase(parts)).toBe(650.41);
|
||||||
|
expect(computeTax(parts, 0.08)).toBe(52.03);
|
||||||
|
expect(computeTotal(parts, 0.08)).toBe(702.44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the second payment of policy 7006785", () => {
|
||||||
|
const parts = { netPremium: 589.71, surcharge: 8.26, policyFee: 0 };
|
||||||
|
expect(computeTax(parts, 0.08)).toBe(47.84);
|
||||||
|
expect(computeTotal(parts, 0.08)).toBe(645.81);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excluding the surcharge does NOT reconcile", () => {
|
||||||
|
// Guards the one decision in this module that is easy to get wrong: the
|
||||||
|
// spoken-language version of the rule ("prima neta + derecho * 8%") gives
|
||||||
|
// 51.35, and the printed policy says 52.03.
|
||||||
|
const withoutSurcharge = { netPremium: 610.86, surcharge: 0, policyFee: 31.0 };
|
||||||
|
expect(computeTax(withoutSurcharge, 0.08)).not.toBe(52.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats blank and null money as zero, not NaN", () => {
|
||||||
|
expect(taxableBase({ netPremium: "610.86", surcharge: null, policyFee: "" })).toBe(
|
||||||
|
610.86,
|
||||||
|
);
|
||||||
|
expect(computeTax({ netPremium: undefined, surcharge: null, policyFee: null }, 0.08))
|
||||||
|
.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds half-up to cents", () => {
|
||||||
|
// 100.06 * 0.08 = 8.0048 -> 8.00; 100.13 * 0.08 = 8.0104 -> 8.01.
|
||||||
|
expect(computeTax({ netPremium: 100.06, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8);
|
||||||
|
expect(computeTax({ netPremium: 100.13, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("surchargeApplies", () => {
|
||||||
|
it("is false for the two single-payment frequencies", () => {
|
||||||
|
expect(surchargeApplies("ANNUAL")).toBe(false);
|
||||||
|
expect(surchargeApplies("SINGLE")).toBe(false);
|
||||||
|
});
|
||||||
|
it("is true for every split frequency", () => {
|
||||||
|
expect(surchargeApplies("SEMIANNUAL")).toBe(true);
|
||||||
|
expect(surchargeApplies("QUARTERLY")).toBe(true);
|
||||||
|
expect(surchargeApplies("MONTHLY")).toBe(true);
|
||||||
|
});
|
||||||
|
it("allows it when the frequency is unknown", () => {
|
||||||
|
// Every migrated policy is null here — the original ETL dropped FORMA
|
||||||
|
// PAGO — and those rows DO carry recargo figures in the legacy data.
|
||||||
|
expect(surchargeApplies(null)).toBe(true);
|
||||||
|
expect(surchargeApplies(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTaxRate", () => {
|
||||||
|
it("prefers the rate the policy was issued at", () => {
|
||||||
|
expect(resolveTaxRate(0.16, 0.08)).toBe(0.16);
|
||||||
|
});
|
||||||
|
it("falls back to the line of business", () => {
|
||||||
|
expect(resolveTaxRate(null, 0.08)).toBe(0.08);
|
||||||
|
});
|
||||||
|
it("falls back to the default when nothing is configured", () => {
|
||||||
|
expect(resolveTaxRate(null, null)).toBe(DEFAULT_TAX_RATE);
|
||||||
|
expect(resolveTaxRate(undefined, "")).toBe(DEFAULT_TAX_RATE);
|
||||||
|
});
|
||||||
|
it("accepts a zero rate as a real choice, not as absent", () => {
|
||||||
|
// An exempt line of business must read 0, not silently fall through to 8%.
|
||||||
|
expect(resolveTaxRate(null, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
it("accepts Prisma's decimal strings", () => {
|
||||||
|
expect(resolveTaxRate(null, "0.0800")).toBe(0.08);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* The premium arithmetic the Access capture form did in unbound calculated
|
||||||
|
* controls, moved somewhere it can be tested.
|
||||||
|
*
|
||||||
|
* Two figures are derived, everything else is keyed by hand:
|
||||||
|
*
|
||||||
|
* base = netPremium + surcharge + policyFee
|
||||||
|
* tax = round(base * rate)
|
||||||
|
* total = base + tax
|
||||||
|
*
|
||||||
|
* The surcharge IS part of the taxable base. That is not an assumption — it is
|
||||||
|
* the only reading that reconciles the books. Policy 7006785 (MULT, semestral,
|
||||||
|
* two payments) prints IVA 52.03 and 47.84 against net premiums 610.86 / 589.71,
|
||||||
|
* surcharges 8.55 / 8.26 and policy fees 31.00 / 0.00; excluding the surcharge
|
||||||
|
* gives 51.35, which matches nothing on the page.
|
||||||
|
*
|
||||||
|
* The surcharge itself is NEVER derived. It is the carrier's financing charge
|
||||||
|
* for paying in installments, quoted per policy, so staff key it in. It only
|
||||||
|
* ever appears on a policy that is not paid annually or in a single exhibición
|
||||||
|
* — `surchargeApplies` is what the UI uses to grey the field out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Used when neither the policy nor its type carries a rate. Matches the
|
||||||
|
* single row both legacy IMPUESTOS tables held (0.08 = 8%). */
|
||||||
|
export const DEFAULT_TAX_RATE = 0.08;
|
||||||
|
|
||||||
|
export type PaymentFrequencyValue =
|
||||||
|
| "ANNUAL"
|
||||||
|
| "SEMIANNUAL"
|
||||||
|
| "QUARTERLY"
|
||||||
|
| "MONTHLY"
|
||||||
|
| "SINGLE";
|
||||||
|
|
||||||
|
/** Paying in more than one exhibición is what earns a surcharge. A null
|
||||||
|
* frequency (every migrated row — Access's FORMA PAGO was dropped by the
|
||||||
|
* original ETL) is treated as "unknown, allow it" rather than "annual":
|
||||||
|
* refusing to show a figure that is sitting in the legacy data would hide it. */
|
||||||
|
export function surchargeApplies(
|
||||||
|
frequency: PaymentFrequencyValue | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: unknown): number {
|
||||||
|
if (v === null || v === undefined || v === "") return 0;
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-up to cents, the way the printed policy rounds. */
|
||||||
|
export function round2(n: number): number {
|
||||||
|
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PremiumParts {
|
||||||
|
netPremium?: unknown;
|
||||||
|
surcharge?: unknown;
|
||||||
|
policyFee?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taxableBase(p: PremiumParts): number {
|
||||||
|
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTax(p: PremiumParts, rate: number): number {
|
||||||
|
return round2(taxableBase(p) * rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||||
|
return round2(taxableBase(p) + computeTax(p, rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rate ladder: the figure stored on the policy (so an old policy keeps the
|
||||||
|
* rate it was issued at even after the catalog changes), else the rate on its
|
||||||
|
* line of business, else the shipped default. */
|
||||||
|
export function resolveTaxRate(
|
||||||
|
policyRate: unknown,
|
||||||
|
policyTypeRate: unknown,
|
||||||
|
): number {
|
||||||
|
for (const candidate of [policyRate, policyTypeRate]) {
|
||||||
|
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||||
|
const n = Number(candidate);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return DEFAULT_TAX_RATE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
nameTokens,
|
||||||
|
suggestCustomersByName,
|
||||||
|
suggestionNote,
|
||||||
|
type CustomerNameRow,
|
||||||
|
} from "./name-matcher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row here is a real name out of the customer book (1536 rows, dev
|
||||||
|
* mirror of production), chosen because it is one of the shapes that breaks
|
||||||
|
* naive matching: surname-first ordering, a middle initial, a Spanish double
|
||||||
|
* surname, a joint account, a missing comma, and the `(SIN NOMBRE)`
|
||||||
|
* placeholder the migration left for customers whose DATGRAL row had no name.
|
||||||
|
*/
|
||||||
|
const BOOK: CustomerNameRow[] = [
|
||||||
|
{ id: "c1", name: "WAGONER, PAMELA" },
|
||||||
|
{ id: "c2", name: "MCWILLIAMS, BRIAN MICHAEL" },
|
||||||
|
{ id: "c3", name: "MCWILLIAMS, BRIAN" },
|
||||||
|
{ id: "c4", name: "WEAKLAND, RICHARD E." },
|
||||||
|
{ id: "c5", name: "ESTRADA, JERRY & MARILYN" },
|
||||||
|
{ id: "c6", name: "CABALLERO PRIETO, GUILLERMO" },
|
||||||
|
{ id: "c7", name: "GREENE STEPHANIE" },
|
||||||
|
{ id: "c8", name: "(SIN NOMBRE)" },
|
||||||
|
{ id: "c9", name: "MUÑOZ, LUIS ALBERTO" },
|
||||||
|
{ id: "c10", name: "SMITH, DANIEL" },
|
||||||
|
{ id: "c11", name: "SMITH, JOHN" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("nameTokens", () => {
|
||||||
|
it("makes the two orderings the same set", () => {
|
||||||
|
expect(nameTokens("PAMELA WAGONER").sort()).toEqual(
|
||||||
|
nameTokens("WAGONER, PAMELA").sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops initials, particles and corporate suffixes", () => {
|
||||||
|
expect(nameTokens("WEAKLAND, RICHARD E.")).toEqual(["WEAKLAND", "RICHARD"]);
|
||||||
|
expect(nameTokens("GARCIA DE LA TORRE, ANA")).toEqual(["GARCIA", "TORRE", "ANA"]);
|
||||||
|
expect(nameTokens("CONSTRUCTORA BAJA S.A. DE C.V.")).toEqual([
|
||||||
|
"CONSTRUCTORA",
|
||||||
|
"BAJA",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds accents so OCR's MUNOZ reaches the book's MUÑOZ", () => {
|
||||||
|
expect(nameTokens("MUÑOZ")).toEqual(["MUNOZ"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the phone number ANA prints against the insured name", () => {
|
||||||
|
// Observed verbatim from the ANA automobile face.
|
||||||
|
expect(nameTokens("MARIA GARCIA Ph.3102001538")).toEqual([
|
||||||
|
"MARIA",
|
||||||
|
"GARCIA",
|
||||||
|
"PH",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestCustomersByName", () => {
|
||||||
|
it("matches the reversed name exactly", () => {
|
||||||
|
const [top] = suggestCustomersByName("PAMELA WAGONER", BOOK);
|
||||||
|
expect(top).toMatchObject({ customerId: "c1", tier: "EXACT", score: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a printed middle name the book lacks as a partial hit", () => {
|
||||||
|
const hits = suggestCustomersByName("PAMELA DENISE WAGONER", BOOK);
|
||||||
|
expect(hits[0]).toMatchObject({ customerId: "c1", tier: "PARTIAL" });
|
||||||
|
expect(hits[0].score).toBeCloseTo(2 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the exact row above the row that merely contains it", () => {
|
||||||
|
// Both MCWILLIAMS rows are reachable from this name; the one that holds
|
||||||
|
// the middle name is the exact set and must come first.
|
||||||
|
const hits = suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK);
|
||||||
|
expect(hits.map((h) => h.customerId)).toEqual(["c2", "c3"]);
|
||||||
|
expect(hits[0].tier).toBe("EXACT");
|
||||||
|
expect(hits[1].tier).toBe("PARTIAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches a joint account from the one spouse the carrier printed", () => {
|
||||||
|
const hits = suggestCustomersByName("JERRY ESTRADA", BOOK);
|
||||||
|
expect(hits[0]).toMatchObject({ customerId: "c5", tier: "PARTIAL" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not reach a joint account on given names alone", () => {
|
||||||
|
// No surname printed: `JERRY MARILYN` overlaps ESTRADA, JERRY & MARILYN
|
||||||
|
// on two tokens, and matching on that would book a stranger's policy.
|
||||||
|
expect(suggestCustomersByName("JERRY MARILYN", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a Spanish double surname regardless of where the comma fell", () => {
|
||||||
|
const [top] = suggestCustomersByName("GUILLERMO CABALLERO PRIETO", BOOK);
|
||||||
|
expect(top).toMatchObject({ customerId: "c6", tier: "EXACT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still matches a book row that has no comma", () => {
|
||||||
|
const [top] = suggestCustomersByName("STEPHANIE GREENE", BOOK);
|
||||||
|
expect(top).toMatchObject({ customerId: "c7", tier: "EXACT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never suggests the (SIN NOMBRE) placeholder", () => {
|
||||||
|
expect(suggestCustomersByName("SIN NOMBRE", BOOK)).toEqual([]);
|
||||||
|
expect(suggestCustomersByName("NOMBRE DEL ASEGURADO", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing on a shared surname alone", () => {
|
||||||
|
// 185 surnames are shared by 524 customers; one token is not evidence.
|
||||||
|
expect(suggestCustomersByName("SMITH", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a different person with the same surname", () => {
|
||||||
|
expect(suggestCustomersByName("ROBERT SMITH", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a page-sized blob", () => {
|
||||||
|
// GMX's especificación has no field labels and the parser has handed its
|
||||||
|
// whole first page over as the insured name.
|
||||||
|
const blob =
|
||||||
|
"ESPECIFICACION DE LA POLIZA DE SEGURO DE RESPONSABILIDAD CIVIL " +
|
||||||
|
"EXPEDIDA A FAVOR DE PAMELA WAGONER CON VIGENCIA DEL 01 DE ENERO";
|
||||||
|
expect(suggestCustomersByName(blob, BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the list", () => {
|
||||||
|
expect(suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK, 1)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a null insured name", () => {
|
||||||
|
expect(suggestCustomersByName(null, BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestionNote", () => {
|
||||||
|
it("says nothing when there is nothing", () => {
|
||||||
|
expect(suggestionNote([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a single exact hit", () => {
|
||||||
|
expect(suggestionNote(suggestCustomersByName("PAMELA WAGONER", BOOK))).toBe(
|
||||||
|
"posible cliente por nombre: WAGONER, PAMELA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a tie rather than picking one", () => {
|
||||||
|
// The book really does hold EMERY, LAURA twice and KIRCHHOFF, CINDY
|
||||||
|
// three times.
|
||||||
|
const dupes: CustomerNameRow[] = [
|
||||||
|
{ id: "d1", name: "EMERY, LAURA" },
|
||||||
|
{ id: "d2", name: "EMERY, LAURA" },
|
||||||
|
];
|
||||||
|
expect(suggestionNote(suggestCustomersByName("LAURA EMERY", dupes))).toBe(
|
||||||
|
"2 clientes tienen ese mismo nombre; elija cuál",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists partial hits", () => {
|
||||||
|
expect(suggestionNote(suggestCustomersByName("PAMELA DENISE WAGONER", BOOK))).toBe(
|
||||||
|
"posibles clientes por nombre: WAGONER, PAMELA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Suggests which existing customer a printed insured name belongs to.
|
||||||
|
*
|
||||||
|
* The office books customers surname-first ("WAGONER, PAMELA") and carriers
|
||||||
|
* print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare
|
||||||
|
* never hits. Comparing *token sets* does, and it is order-insensitive by
|
||||||
|
* construction — which is the whole trick.
|
||||||
|
*
|
||||||
|
* **These are suggestions, never matches.** Nothing here sets
|
||||||
|
* `matchedCustomerId` or `confident`; the review screen offers the ranked
|
||||||
|
* names and a human picks. That line is not caution, it is what the book
|
||||||
|
* measures out to: of 1536 customers, 1487 have a distinct normalized token
|
||||||
|
* set — but loosen the rule to surname + first given name only and 131 of
|
||||||
|
* them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL`
|
||||||
|
* *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three
|
||||||
|
* `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a
|
||||||
|
* surname alone carries no information at all.
|
||||||
|
*
|
||||||
|
* The two tiers below are drawn at the two places that measurement puts a
|
||||||
|
* cliff: full token-set equality, where cross-person collisions are
|
||||||
|
* effectively zero, and strict containment, where they are common enough
|
||||||
|
* that the result can only ever be a hint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A customer row as the matcher needs it — id and the book's name. */
|
||||||
|
export interface CustomerNameRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NameMatchTier = "EXACT" | "PARTIAL";
|
||||||
|
|
||||||
|
export interface CustomerNameSuggestion {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
/**
|
||||||
|
* `EXACT` — the two names carry the same tokens, in any order.
|
||||||
|
* `PARTIAL` — one name's tokens are all present in the other's, plus the
|
||||||
|
* surname. A printed middle name the book does not hold, or a joint
|
||||||
|
* account where the carrier named one spouse, both land here.
|
||||||
|
*/
|
||||||
|
tier: NameMatchTier;
|
||||||
|
/** Shared tokens over the longer name's token count, 0..1. */
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words that carry no identity. Spanish particles and the ampersand joining
|
||||||
|
* a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.`
|
||||||
|
* does not make every company look alike.
|
||||||
|
*/
|
||||||
|
const NOISE = new Set([
|
||||||
|
"DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA",
|
||||||
|
"JR", "SR", "II", "III", "IV",
|
||||||
|
"SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder rows the migration left behind. Fourteen customers are named
|
||||||
|
* literally `(SIN NOMBRE)`; without this they would be one 14-way tie on
|
||||||
|
* every unreadable name.
|
||||||
|
*/
|
||||||
|
const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name blob longer than this is not a name. GMX's PVL especificación has
|
||||||
|
* no field labels, and the parser has been seen handing its entire first
|
||||||
|
* page over as `insuredName`; matching that against the book would find
|
||||||
|
* a surname somewhere in the prose and suggest a stranger.
|
||||||
|
*/
|
||||||
|
const MAX_TOKENS = 8;
|
||||||
|
const MAX_CHARS = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a name into comparable tokens.
|
||||||
|
*
|
||||||
|
* Accents go first, and deliberately in both directions: the book holds
|
||||||
|
* `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same
|
||||||
|
* ASCII makes that a hit rather than a miss.
|
||||||
|
*
|
||||||
|
* Tokens containing digits are dropped outright. ANA's automobile face
|
||||||
|
* prints the phone number hard against the insured name — the parser has
|
||||||
|
* emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise
|
||||||
|
* be an extra token forever blocking `EXACT`.
|
||||||
|
*
|
||||||
|
* Single letters are dropped as initials: the book is full of
|
||||||
|
* `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in
|
||||||
|
* full should still match the row that abbreviates it.
|
||||||
|
*/
|
||||||
|
export function nameTokens(raw: string): string[] {
|
||||||
|
const cleaned = raw
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const tokens = cleaned
|
||||||
|
.split(" ")
|
||||||
|
.filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t));
|
||||||
|
|
||||||
|
return [...new Set(tokens)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The surname tokens — everything before the comma the book writes. */
|
||||||
|
function surnameTokens(bookName: string): string[] {
|
||||||
|
const comma = bookName.indexOf(",");
|
||||||
|
// 54 of 1536 rows have no comma at all ("GREENE STEPHANIE",
|
||||||
|
// "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring
|
||||||
|
// a surname we cannot identify would silently exclude those rows, so they
|
||||||
|
// fall back to requiring nothing beyond the containment rule.
|
||||||
|
if (comma < 0) return [];
|
||||||
|
return nameTokens(bookName.slice(0, comma));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaceholder(tokens: string[]): boolean {
|
||||||
|
return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsAll(haystack: Set<string>, needles: string[]): boolean {
|
||||||
|
return needles.every((n) => haystack.has(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranks the book against one printed name.
|
||||||
|
*
|
||||||
|
* Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher
|
||||||
|
* score first. An empty array means the printed name was unusable (too
|
||||||
|
* long, too few real tokens) or nothing in the book came close — both of
|
||||||
|
* which leave the review screen exactly as it is today.
|
||||||
|
*/
|
||||||
|
export function suggestCustomersByName(
|
||||||
|
printedName: string | null | undefined,
|
||||||
|
customers: CustomerNameRow[],
|
||||||
|
limit = 3,
|
||||||
|
): CustomerNameSuggestion[] {
|
||||||
|
if (!printedName || printedName.length > MAX_CHARS) return [];
|
||||||
|
|
||||||
|
const printed = nameTokens(printedName);
|
||||||
|
// One usable token is a surname or a given name on its own, and 34% of the
|
||||||
|
// book shares a surname with someone. Nothing useful can come of it.
|
||||||
|
if (printed.length < 2 || printed.length > MAX_TOKENS) return [];
|
||||||
|
|
||||||
|
const printedSet = new Set(printed);
|
||||||
|
const out: CustomerNameSuggestion[] = [];
|
||||||
|
|
||||||
|
for (const c of customers) {
|
||||||
|
const book = nameTokens(c.name);
|
||||||
|
if (isPlaceholder(book) || book.length < 2) continue;
|
||||||
|
|
||||||
|
const bookSet = new Set(book);
|
||||||
|
const overlap = printed.filter((t) => bookSet.has(t)).length;
|
||||||
|
// Two shared tokens is the floor: one is a bare surname collision.
|
||||||
|
if (overlap < 2) continue;
|
||||||
|
|
||||||
|
const bookInPrinted = containsAll(printedSet, book);
|
||||||
|
const printedInBook = containsAll(bookSet, printed);
|
||||||
|
if (!bookInPrinted && !printedInBook) continue;
|
||||||
|
|
||||||
|
// When the book's name is the shorter one, containment already proves
|
||||||
|
// the surname was printed. When the printed name is shorter — the book
|
||||||
|
// holds a middle name or a second spouse the carrier omitted — the
|
||||||
|
// surname must be there explicitly, or `JERRY MARILYN` would match
|
||||||
|
// `ESTRADA, JERRY & MARILYN` on given names alone.
|
||||||
|
if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue;
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
customerId: c.id,
|
||||||
|
customerName: c.name,
|
||||||
|
tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL",
|
||||||
|
score: overlap / Math.max(book.length, printed.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => {
|
||||||
|
if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1;
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return a.customerName.localeCompare(b.customerName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return out.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Review-queue wording for what the suggestions amount to. */
|
||||||
|
export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null {
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
const exact = suggestions.filter((s) => s.tier === "EXACT");
|
||||||
|
// More than one exact hit is the duplicate-customer case the book really
|
||||||
|
// has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is
|
||||||
|
// more useful than naming whichever one sorted first.
|
||||||
|
if (exact.length > 1) {
|
||||||
|
return `${exact.length} clientes tienen ese mismo nombre; elija cuál`;
|
||||||
|
}
|
||||||
|
if (exact.length === 1) {
|
||||||
|
return `posible cliente por nombre: ${exact[0].customerName}`;
|
||||||
|
}
|
||||||
|
return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,934 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coverages keyed by their risk label, so an assertion names the coverage
|
||||||
|
* it is about instead of an array index that shifts when one is added. */
|
||||||
|
const byRisk = (p: ReturnType<typeof parsePolicy>): Record<string, ParsedCoverage> =>
|
||||||
|
Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
||||||
|
|
||||||
|
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("names the product MULT for confirm to resolve", () => {
|
||||||
|
// The caratula's own header reads "Multiple Policy / Home". MULT is the
|
||||||
|
// legacy discriminator for that multi-line home policy; INCENDIO is
|
||||||
|
// fire-only and no policy in the book has ever used it.
|
||||||
|
expect(parsePolicy(GMX_FULL).policyTypeName).toBe("MULT");
|
||||||
|
});
|
||||||
|
|
||||||
|
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%");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second GMX document family: the Spanish PVL "especificación" the office
|
||||||
|
* receives as `…-CondicionesParticulares.pdf`. Verbatim excerpts from
|
||||||
|
* `007_LGS-HGMX_07006957_01_0-CondicionesParticulares.pdf` through
|
||||||
|
* `pdftotext -layout`, indentation included — the column positions and the
|
||||||
|
* blank lines between blocks are what the parser reads, so a cleaned-up
|
||||||
|
* fixture would test nothing.
|
||||||
|
*/
|
||||||
|
describe("parsePolicy / GMX especificación (PVL Hogar)", () => {
|
||||||
|
const HEADER =
|
||||||
|
" ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA\n" +
|
||||||
|
" 07-037-07006957-00000-01\n" +
|
||||||
|
"\n";
|
||||||
|
|
||||||
|
const GMX_ESPEC = page(
|
||||||
|
HEADER +
|
||||||
|
"\n" +
|
||||||
|
" Nombre del asegurado EMMER . KATHLEEN\n" +
|
||||||
|
"\n" +
|
||||||
|
" Tipo Persona Asegurada Propietario\n" +
|
||||||
|
"\n" +
|
||||||
|
" Ubicación del riesgo LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS\n" +
|
||||||
|
" DE ROSARITO BAJA CALIFORNIA 22713\n" +
|
||||||
|
"\n" +
|
||||||
|
" Características del Inmueble Casa Tipo constructivo Combinado: Macizo y Madera.\n" +
|
||||||
|
" Consta de 2 pisos incluyendo sótanos y planta baja.\n" +
|
||||||
|
"\n" +
|
||||||
|
" -500 mts.cuerpo agua SI\n" +
|
||||||
|
"\n" +
|
||||||
|
" Asegurado Adicional\n" +
|
||||||
|
"\n" +
|
||||||
|
"PVL Hogar - GMX Seguros Página: 1 de 10\n" +
|
||||||
|
HEADER +
|
||||||
|
"\n" +
|
||||||
|
" SECCIÓN INCENDIO EDIFICIO Y CONTENIDOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" EDIFICIO\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" $200,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" Quedan amparados los muros de contención y bardas, así como puertas y portones, hasta un sublimite de $ 50,000.00 M.N. o su\n" +
|
||||||
|
" equivalente en dólares americanos, o hasta el 10% de la suma asegurada de la sección de Edificio, lo que resulte menor.\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" CONTENIDOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" $20,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" 2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO\n" +
|
||||||
|
"\n" +
|
||||||
|
" 3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" Riesgos adicionales.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Remoción de escombros\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" Edificio\n" +
|
||||||
|
" $20,000.00 USD\n" +
|
||||||
|
" Contenidos\n" +
|
||||||
|
" $2,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" Gastos extraordinarios para casa habitación\n" +
|
||||||
|
"\n" +
|
||||||
|
" En caso de siniestro por los riesgos cubiertos en esta póliza, GMX Seguros pagará la renta de casa o departamento, casa de\n" +
|
||||||
|
" huéspedes u hotel cuando se asegure el inmueble, así como los gastos de mudanza, seguro de transporte del menaje de casa y\n" +
|
||||||
|
" efectuados.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" $22,000.00 USD\n" +
|
||||||
|
" Periodo de indemnización: 4 meses.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Bienes a la Intemperie:\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA\n" +
|
||||||
|
" Y CADA PÉRDIDA.\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad: $10,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" DEDUCIBLES:\n" +
|
||||||
|
"\n" +
|
||||||
|
" El procedimiento que se seguirá para la aplicación de deducibles en caso de que la póliza cuente con cláusula inflacionaria en todas\n" +
|
||||||
|
" y/o en algunas de sus coberturas será como sigue:\n" +
|
||||||
|
"\n" +
|
||||||
|
" Fenómenos hidrometeorológicos\n" +
|
||||||
|
" Zona: A2\n" +
|
||||||
|
" Deducible\n" +
|
||||||
|
" Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||||
|
" Coaseguro:\n" +
|
||||||
|
" Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||||
|
" Zona 2: Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Deducible\n" +
|
||||||
|
" Contenidos: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||||
|
"\n" +
|
||||||
|
" II.- SECCIÓN DIVERSOS MISCELÁNEOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" ROBO DE CONTENIDOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite de Responsabilidad:\n" +
|
||||||
|
" $4,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Deducible:\n" +
|
||||||
|
" Sin deducible\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Sublímites:\n" +
|
||||||
|
" Joyas, artículos de oro y plata, armas, relojes, pieles, piedras preciosas montadas, colecciones, obras de arte y demás que por su\n" +
|
||||||
|
"\n" +
|
||||||
|
"PVL Hogar - GMX Seguros Página: 7 de 10\n" +
|
||||||
|
HEADER +
|
||||||
|
"\n" +
|
||||||
|
" naturaleza se consideran como objetos de difícil o imposible reposición\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite de Responsabilidad:\n" +
|
||||||
|
"\n" +
|
||||||
|
" $2,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Deducible:\n" +
|
||||||
|
" Sin deducible\n" +
|
||||||
|
"\n" +
|
||||||
|
" Las condiciones generales que forman parte de la presente póliza son las identificadas bajo el nombre:\n" +
|
||||||
|
" W_HogarGMX_12.11.2025.pdf\n" +
|
||||||
|
"\n" +
|
||||||
|
"PVL Hogar - GMX Seguros Página: 10 de 10\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reads a policy number whose groups are not the caratula's widths", () => {
|
||||||
|
// 2-3-8-5-2 here vs 3-3-8-4-2 on the English caratula. Pinning the widths
|
||||||
|
// reads one family and returns null on the other.
|
||||||
|
expect(parsePolicy(GMX_ESPEC).policyNumber).toBe("07-037-07006957-00000-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the insured, the risk location across its wrapped line, and the ZIP", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
expect(p.provider).toBe("GMX");
|
||||||
|
expect(p.insuredName).toBe("EMMER . KATHLEEN");
|
||||||
|
expect(p.legalAddress).toBe(
|
||||||
|
"LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS DE ROSARITO BAJA CALIFORNIA 22713",
|
||||||
|
);
|
||||||
|
expect(p.zip).toBe("22713");
|
||||||
|
// The cell is printed but empty on this policy — an empty label must not
|
||||||
|
// capture the next line of the form.
|
||||||
|
expect(p.additionalInsured).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the fields this document does not carry null, and says so", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
expect(p.policyFrom).toBeNull();
|
||||||
|
expect(p.policyTo).toBeNull();
|
||||||
|
expect(p.policyDate).toBeNull();
|
||||||
|
expect(p.agentName).toBeNull();
|
||||||
|
expect(p.netPremium).toBeNull();
|
||||||
|
expect(p.total).toBeNull();
|
||||||
|
// The note must tell the reviewer to key them in — those three are
|
||||||
|
// captured by hand on this layout — and must say what silently breaks if
|
||||||
|
// the vigencia is left empty.
|
||||||
|
const notes = p.notes.join(" ");
|
||||||
|
expect(notes).toMatch(/no trae vigencia, agente ni prima/i);
|
||||||
|
expect(notes).toMatch(/captúrelos a mano/i);
|
||||||
|
expect(notes).toMatch(/avisos de renovación/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the currency from the printed limits, not from the M.N. sublimits", () => {
|
||||||
|
// The body prose quotes sublimits in pesos ("$ 50,000.00 M.N."); every
|
||||||
|
// limit is in USD, and only the limits vote.
|
||||||
|
expect(parsePolicy(GMX_ESPEC).currency).toBe("USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads each coverage under its own heading", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c.EDIFICIO?.insuredAmount).toBe(200000);
|
||||||
|
expect(c.CONTENIDOS?.insuredAmount).toBe(20000);
|
||||||
|
expect(c["ROBO DE CONTENIDOS"]?.insuredAmount).toBe(4000);
|
||||||
|
expect(c["ROBO DE CONTENIDOS"]?.deductible).toBe("Sin deducible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a limit printed under Edificio / Contenidos sub-labels", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Remoción de escombros — Edificio"]?.insuredAmount).toBe(20000);
|
||||||
|
expect(c["Remoción de escombros — Contenidos"]?.insuredAmount).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a coverage after its heading, not after the wrapped tail of the prose above it", () => {
|
||||||
|
// Walking back from the limit hits "efectuados." — short, and the only
|
||||||
|
// thing separating it from a heading is that it is not preceded by a
|
||||||
|
// blank line.
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Gastos extraordinarios para casa habitación"]?.insuredAmount).toBe(22000);
|
||||||
|
expect(c["efectuados."]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a limit printed on the label's own line", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Bienes a la Intemperie"]?.insuredAmount).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a deductible stated as a sentence above the limit", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Bienes a la Intemperie"]?.deductible).toBe(
|
||||||
|
"5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA Y CADA PÉRDIDA.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never borrows a neighbouring coverage's prose as a deductible", () => {
|
||||||
|
// "…o hasta el 10% de la suma asegurada de la sección de Edificio" is a
|
||||||
|
// sublimit rule for EDIFICIO, printed two paragraphs above CONTENIDOS.
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c.CONTENIDOS?.deductible).toBeNull();
|
||||||
|
expect(c.EDIFICIO?.deductible).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read the page-level DEDUCIBLES paragraph as a deductible", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
expect(
|
||||||
|
p.coverages.some((c) => (c.deductible ?? "").includes("cláusula inflacionaria")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a sublimit block as a sublimit OF the coverage above it", () => {
|
||||||
|
// The amount sits after a blank line AND a page break, and the block's
|
||||||
|
// own heading ("Sublímites:") names no risk.
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["ROBO DE CONTENIDOS — sublímite"]?.insuredAmount).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records an excluded catastrophic risk as excluded, never as zero", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
const quake = p.coverages.filter((c) => /Terremoto/i.test(c.risk));
|
||||||
|
expect(quake).toHaveLength(2);
|
||||||
|
for (const c of quake) {
|
||||||
|
expect(c.risk).toMatch(/EXCLUIDO/);
|
||||||
|
// A coverage insured for $0 and an excluded coverage are the same
|
||||||
|
// number and very different facts.
|
||||||
|
expect(c.insuredAmount).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the hydrometeorological deductible and coinsurance from its own block", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
const building = c["Fenómenos hidrometeorológicos — Sección Edificio"];
|
||||||
|
expect(building?.insuredAmount).toBe(200000);
|
||||||
|
expect(building?.deductible).toBe("1 POR CIENTO SOBRE SUMA ASEGURADA");
|
||||||
|
expect(building?.lossParticipation).toBe("10%");
|
||||||
|
expect(c["Fenómenos hidrometeorológicos — Sección Contenidos"]?.insuredAmount).toBe(20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the same product as the caratula — one policy, two artifacts", () => {
|
||||||
|
expect(parsePolicy(GMX_ESPEC).policyTypeName).toBe("MULT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the underwriting context the fields have no home for", () => {
|
||||||
|
const notes = parsePolicy(GMX_ESPEC).notes.join(" | ");
|
||||||
|
expect(notes).toMatch(/tipo de persona asegurada: Propietario/);
|
||||||
|
expect(notes).toMatch(/características del inmueble: Casa/);
|
||||||
|
expect(notes).toMatch(/cuerpo de agua/);
|
||||||
|
expect(notes).toMatch(/zona catastrófica declarada: A2/);
|
||||||
|
expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ ANA */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbatim `pdftotext -layout` output of the PDFs A.N.A.'s portal produced
|
||||||
|
* for three real policies, cut at the end of the risk table (the legal
|
||||||
|
* boilerplate and the repeated AGENT COPY below it are not parsed, and the
|
||||||
|
* repeats are covered by their own test).
|
||||||
|
*
|
||||||
|
* The column padding is load-bearing on the driver's policy, which
|
||||||
|
* distinguishes SUM INSURED from PREMIUM by horizontal position alone — do
|
||||||
|
* not reflow these strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANA_AUTO_AMPLIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||||
|
LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA
|
||||||
|
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||||
|
www.anaseguros.com.mx
|
||||||
|
AUTOMOBILE
|
||||||
|
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||||
|
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS
|
||||||
|
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||||
|
WHATSAPP + 52-55-80-50-3633
|
||||||
|
No. 700489651
|
||||||
|
ISSUED BY: DATE ISSUED TERM OF INSURANCE
|
||||||
|
DAYS
|
||||||
|
JORGE HUMBERTO CUADROS DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||||
|
BENITO JUAREZ 25 No.50 INT 38 CENTRO
|
||||||
|
04 08 2026 FROM 07 08 2026 12:01
|
||||||
|
365
|
||||||
|
ROSARITO, BAJA CALIFORNIA 22710
|
||||||
|
. 70175 TO 07 08 2027 12:01
|
||||||
|
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||||
|
- 298.61 30.00 26.29 0.00 354.90
|
||||||
|
|
||||||
|
INSURED RAY DEAN II AND SUSAN ROCKHOLD
|
||||||
|
LICENSE P0066762
|
||||||
|
ADDRESS 10308 DONNA AVE EMAIL PROLABSALE@AOL.COM
|
||||||
|
CITY & STATE NORTHRIDGE, CA 91326 TELEPHONE 8184453524
|
||||||
|
PAYMENT DEADLINE
|
||||||
|
INSURANCE COMPANY LIEN HOLDER
|
||||||
|
IMMEDIATE
|
||||||
|
|
||||||
|
ITEM YEAR MAKE BODY SERIAL No. PLATES
|
||||||
|
VEHICLE 2017 CHRYSLER PACIFICA 2C4RC1DG7HR654698 8BPX206
|
||||||
|
TRAILER . .
|
||||||
|
TOWING . .
|
||||||
|
*** VALUE STATED MUST NOT EXCEED MARKET VALUE ***
|
||||||
|
***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING
|
||||||
|
AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE
|
||||||
|
SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY.
|
||||||
|
SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY
|
||||||
|
MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 8,000.00 DLLS.
|
||||||
|
1 DEDUCTIBLE: WITH MINIMUM OF $500.00 ON AUTOS
|
||||||
|
TRAILER
|
||||||
|
(SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED
|
||||||
|
AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES). 0.00 DLLS.
|
||||||
|
|
||||||
|
TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING
|
||||||
|
2 DEDUCTIBLE: WITH MINIMUM OF $1,000.00 ON AUTOS 0.00 DLLS.
|
||||||
|
(SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED
|
||||||
|
AND $1,000.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES).
|
||||||
|
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES
|
||||||
|
3 100,000.00 DLLS.
|
||||||
|
|
||||||
|
|
||||||
|
BODILY INJURY LIABILITY PER PER
|
||||||
|
4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS.
|
||||||
|
|
||||||
|
MEDICAL EXPENSES PER PER
|
||||||
|
5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS.
|
||||||
|
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
6 A.N.A.'s LEGAL AID
|
||||||
|
COVERED 40.00
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
7 A.N.A.'s ROADSIDE ASSISTANCE
|
||||||
|
COVERED 40.00
|
||||||
|
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM
|
||||||
|
8 EXCLUDED
|
||||||
|
PARTIES DLLS. 0.00
|
||||||
|
ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED
|
||||||
|
9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00
|
||||||
|
VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
ISSUED ONLINE`);
|
||||||
|
|
||||||
|
const ANA_AUTO_RC_DIAS = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||||
|
LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA
|
||||||
|
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||||
|
www.anaseguros.com.mx
|
||||||
|
AUTOMOBILE
|
||||||
|
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||||
|
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS
|
||||||
|
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||||
|
WHATSAPP + 52-55-80-50-3633
|
||||||
|
No. 700487807
|
||||||
|
ISSUED BY: DATE ISSUED TERM OF INSURANCE
|
||||||
|
DAYS
|
||||||
|
JORGE HUMBERTO CUADROS DIARIA DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||||
|
BENITO JUAREZ 25 NO50 INT 38 COL CENTRO
|
||||||
|
22 07 2026 FROM 23 07 2026 12:01
|
||||||
|
3
|
||||||
|
ROSARITO BAJA CALIFORNIA 22710
|
||||||
|
(661) 612 12 55 70175 TO 26 07 2026 12:01
|
||||||
|
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||||
|
- 10.77 25.00 2.86 0.00 38.63
|
||||||
|
|
||||||
|
INSURED STEPHEN RUPAN SHATAFIAN
|
||||||
|
LICENSE C1394198
|
||||||
|
ADDRESS 13181 CROSSROADS PARKWAY NORTH STE 300 EMAIL sshatafian@lee-associates.com
|
||||||
|
|
||||||
|
|
||||||
|
CITY & STATE CITY OF INDUSTRY, CA 91746 TELEPHONE 7143221072
|
||||||
|
PAYMENT DEADLINE
|
||||||
|
INSURANCE COMPANY LIEN HOLDER
|
||||||
|
IMMEDIATE
|
||||||
|
|
||||||
|
ITEM YEAR MAKE BODY SERIAL No. PLATES
|
||||||
|
VEHICLE 2022 FORD TRANSIT 1FBAX2CG3NKA69091 EC46T99
|
||||||
|
TRAILER . .
|
||||||
|
TOWING . .
|
||||||
|
*** VALUE STATED MUST NOT EXCEED MARKET VALUE ***
|
||||||
|
***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING
|
||||||
|
AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE
|
||||||
|
SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY.
|
||||||
|
SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY
|
||||||
|
MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 0.00 DLLS.
|
||||||
|
1 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND
|
||||||
|
TRAILER
|
||||||
|
STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED
|
||||||
|
SUV´s AND MOTOR HOMES). 0.00 DLLS.
|
||||||
|
|
||||||
|
TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING
|
||||||
|
2 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND 0.00 DLLS.
|
||||||
|
STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED
|
||||||
|
SUV´s AND MOTOR HOMES).
|
||||||
|
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES
|
||||||
|
3 100,000.00 DLLS.
|
||||||
|
|
||||||
|
|
||||||
|
BODILY INJURY LIABILITY PER PER
|
||||||
|
4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS.
|
||||||
|
|
||||||
|
MEDICAL EXPENSES PER PER
|
||||||
|
5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS.
|
||||||
|
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
6 A.N.A.'s LEGAL AID
|
||||||
|
COVERED 2.25
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
7 A.N.A.'s ROADSIDE ASSISTANCE
|
||||||
|
COVERED 2.25
|
||||||
|
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM
|
||||||
|
8 EXCLUDED
|
||||||
|
PARTIES DLLS. 0.00
|
||||||
|
ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED
|
||||||
|
9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00
|
||||||
|
VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
ISSUED ONLINE`);
|
||||||
|
|
||||||
|
const ANA_LICENCIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||||
|
LUIS CABRERA #2033 INT. 201, Col.4 ZONA URBANA RIO TIJUANA
|
||||||
|
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||||
|
www.anaseguros.com.mx
|
||||||
|
DRIVER´S POLICY FOR AUTOMOBILE
|
||||||
|
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||||
|
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9
|
||||||
|
SPECIAL POLICY FOR TOURISTS
|
||||||
|
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||||
|
WHATSAPP + 52-55-80-50-3633 No. 700489616
|
||||||
|
ISSUED BY: DATE ISSUED & TIME TERM OF INSURANCE
|
||||||
|
JORGE HUMBERTO CUADROS
|
||||||
|
DAYS
|
||||||
|
DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||||
|
BENITO JUAREZ 25 No.50 INT 38 CENTRO 04 08 2026 FROM 06 08 2026 12:01
|
||||||
|
365
|
||||||
|
ROSARITO, BAJA CALIFORNIA 22710 TO 06 08 2027 12:01
|
||||||
|
. 70175
|
||||||
|
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||||
|
- 142.78 30.00 13.82 0.00 186.60
|
||||||
|
|
||||||
|
LICENSE N0017668 EMAIL PWAGONER49@AOL.COM TELEPHONE 3102001538
|
||||||
|
POLICY HOLDER
|
||||||
|
1. NAME : PAMELA DENISE WAGONER Ph.3102001538
|
||||||
|
ADDRESS : 49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260,
|
||||||
|
DRIVER LICENSE : N0017668
|
||||||
|
2. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE :
|
||||||
|
NONE
|
||||||
|
3. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE :
|
||||||
|
NONE
|
||||||
|
4. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE : NONE
|
||||||
|
5. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE : NONE
|
||||||
|
SPECIFICATION OF RISKS SUM INSURED PREMIUM
|
||||||
|
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES 100,000.00 usd. 18.70 usd.
|
||||||
|
BODILY INJURY LIABILITY ( EXCLUDING OCCUPANTS OF THE VEHICLE ) 100,000.00 usd. Per Person
|
||||||
|
54.27 usd.
|
||||||
|
200,000.00 usd. Per Accident
|
||||||
|
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES 0.00 usd. 0.00 usd.
|
||||||
|
|
||||||
|
MEDICAL EXPENSES 4,000.00 usd. Per Person
|
||||||
|
9.81 usd.
|
||||||
|
20,000.00 usd. Per Accident
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
LEGAL AID
|
||||||
|
COVERED 30.00 usd.
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
AUTOMOBILE ASSISTANCE
|
||||||
|
COVERED 30.00 usd.
|
||||||
|
|
||||||
|
The following risks are excluded Collision, overtuning and glass breakage, fire, total theft and natural disasters, partial theft and vandalism.`);
|
||||||
|
|
||||||
|
|
||||||
|
describe("detectPolicyProvider / ANA", () => {
|
||||||
|
it("claims ANA from the letterhead", () => {
|
||||||
|
expect(
|
||||||
|
detectPolicyProvider("A.N.A. COMPAÑIA DE SEGUROS SA DE CV\nwww.anaseguros.com.mx"),
|
||||||
|
).toBe("ANA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let GMX's layout rules claim an ANA page", () => {
|
||||||
|
// Both books print "MATERIAL DAMAGE"-ish headings; the brand pass runs
|
||||||
|
// before any layout rule precisely so this can't go the other way.
|
||||||
|
expect(detectPolicyProvider(ANA_AUTO_AMPLIA.text)).toBe("ANA");
|
||||||
|
expect(detectPolicyProvider(ANA_LICENCIA.text)).toBe("ANA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy / ANA automobile", () => {
|
||||||
|
const p = parsePolicy(ANA_AUTO_AMPLIA);
|
||||||
|
|
||||||
|
it("reads the header band", () => {
|
||||||
|
expect(p.provider).toBe("ANA");
|
||||||
|
expect(p.policyNumber).toBe("700489651");
|
||||||
|
expect(p.insuredName).toBe("RAY DEAN II AND SUSAN ROCKHOLD");
|
||||||
|
expect(p.agentName).toBe("JORGE HUMBERTO CUADROS");
|
||||||
|
expect(p.legalAddress).toBe("10308 DONNA AVE, NORTHRIDGE, CA 91326");
|
||||||
|
expect(p.zip).toBe("91326");
|
||||||
|
expect(p.currency).toBe("USD");
|
||||||
|
expect(p.premiumPayment).toBe("IMMEDIATE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads DD MM YYYY out of the three date column cells", () => {
|
||||||
|
expect(p.policyDate?.toISOString().slice(0, 10)).toBe("2026-08-04");
|
||||||
|
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-08-07");
|
||||||
|
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2027-08-07");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the six money cells positionally, not by finding six amounts", () => {
|
||||||
|
// DISCOUNT prints as a bare "-" here. A "take the amounts in order"
|
||||||
|
// reading would shift every value one column left.
|
||||||
|
expect(p.netPremium).toBe(298.61);
|
||||||
|
expect(p.policyFee).toBe(30);
|
||||||
|
expect(p.tax).toBe(26.29);
|
||||||
|
expect(p.total).toBe(354.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a TAX that reconciles against the rest of the row", () => {
|
||||||
|
// 298.61 + 30.00 = 328.61, taxed at 8% -> 26.29, totalling 354.90. The
|
||||||
|
// whole row agreeing is what proves the positional mapping landed on the
|
||||||
|
// right cells rather than merely on six numbers.
|
||||||
|
const base = p.netPremium! + p.policyFee!;
|
||||||
|
expect(Math.round(base * 0.08 * 100) / 100).toBe(p.tax);
|
||||||
|
expect(Math.round((base + p.tax!) * 100) / 100).toBe(p.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fold LOCAL TAX into the IVA", () => {
|
||||||
|
// It prints 0.00 here, so nothing to fold — but the guard is that a
|
||||||
|
// non-zero one would surface as a note instead of inflating `tax`.
|
||||||
|
expect(p.notes.join(" | ")).not.toMatch(/impuesto local/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the vehicle by token role, not by column", () => {
|
||||||
|
expect(p.vehicles).toHaveLength(1);
|
||||||
|
expect(p.vehicles[0]).toEqual({
|
||||||
|
item: "VEHICLE",
|
||||||
|
modelYear: "2017",
|
||||||
|
make: "CHRYSLER",
|
||||||
|
bodyType: "PACIFICA",
|
||||||
|
vinNumber: "2C4RC1DG7HR654698",
|
||||||
|
licensePlate: "8BPX206",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a two-word BODY cell without losing the VIN", () => {
|
||||||
|
// "GENESIS SEDAN" is two tokens where "PACIFICA" is one — the VIN shape
|
||||||
|
// is the anchor, not the token count.
|
||||||
|
const v = parsePolicy(ANA_AUTO_RC_DIAS).vehicles[0];
|
||||||
|
expect(v.make).toBe("FORD");
|
||||||
|
expect(v.vinNumber).toBe("1FBAX2CG3NKA69091");
|
||||||
|
expect(v.licensePlate).toBe("EC46T99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the empty TRAILER and TOWING slots", () => {
|
||||||
|
// Both print a "." per cell rather than being absent.
|
||||||
|
expect(p.vehicles.map((v) => v.item)).toEqual(["VEHICLE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the insured as a named driver with their licence", () => {
|
||||||
|
expect(p.drivers).toHaveLength(1);
|
||||||
|
expect(p.drivers[0].fullName).toBe("RAY DEAN II AND SUSAN ROCKHOLD");
|
||||||
|
expect(p.drivers[0].licenseNumber).toBe("P0066762");
|
||||||
|
expect(p.drivers[0].email).toBe("PROLABSALE@AOL.COM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read the agent's own street number as the policy number", () => {
|
||||||
|
// "BENITO JUAREZ 25 No.50 INT 38" sits three lines above the No. cell.
|
||||||
|
expect(p.policyNumber).not.toBe("50");
|
||||||
|
expect(p.notes.join(" | ")).not.toMatch(/formas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the agent clave without picking up their postal code", () => {
|
||||||
|
// "ROSARITO, BAJA CALIFORNIA 22710" is five digits in the same band.
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/);
|
||||||
|
expect(p.notes.join(" | ")).not.toMatch(/22710/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the declared values by their printed item slot", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["MATERIAL DAMAGE — VEHICLE"]?.insuredAmount).toBe(8000);
|
||||||
|
expect(c["MATERIAL DAMAGE — TRAILER"]?.insuredAmount).toBe(0);
|
||||||
|
expect(c["TOTAL THEFT — TOWING"]?.insuredAmount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the deductible sentence out of the value columns", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["MATERIAL DAMAGE — VEHICLE"]?.deductible).toBe(
|
||||||
|
"WITH MINIMUM OF $500.00 ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND " +
|
||||||
|
"STATION WAGONS) AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND " +
|
||||||
|
"MOTOR HOMES).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake the $500.00 inside the deductible for a sum insured", () => {
|
||||||
|
// It is the one amount in the block not suffixed "DLLS.".
|
||||||
|
const amounts = p.coverages.map((c) => c.insuredAmount);
|
||||||
|
expect(amounts).not.toContain(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the per-person and per-accident limits", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(5000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(25000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records an add-on's figure as a premium, never as a sum insured", () => {
|
||||||
|
// $40 is what legal aid COST. As `insuredAmount` it would read on the
|
||||||
|
// review screen as a $40 liability limit.
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["LEGAL AID"]?.premium).toBe(40);
|
||||||
|
expect(c["LEGAL AID"]?.insuredAmount).toBeNull();
|
||||||
|
expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpacks section 9's parenthesised limit and deductible", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
const theft = c["ELITE / ELITE PLUS — PARTIAL THEFT: EXCLUDED"];
|
||||||
|
expect(theft?.insuredAmount).toBe(0);
|
||||||
|
expect(theft?.deductible).toBe("0.00 DLLS. POR EVENTO");
|
||||||
|
expect(c["ELITE / ELITE PLUS — VANDALISM: EXCLUDED"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits each coverage once even though the PDF prints the face twice", () => {
|
||||||
|
// The real upload is ORIGINAL + AGENT COPY + receipt + three travel
|
||||||
|
// cards, all concatenated into one string before parsing.
|
||||||
|
const doubled = page(ANA_AUTO_AMPLIA.text + "\n\n" + ANA_AUTO_AMPLIA.text);
|
||||||
|
expect(parsePolicy(doubled).coverages).toHaveLength(p.coverages.length);
|
||||||
|
expect(parsePolicy(doubled).vehicles).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("policy type, as a name for confirm to resolve", () => {
|
||||||
|
it("names ANA's two faces after the legacy tables they belong to", () => {
|
||||||
|
expect(parsePolicy(ANA_AUTO_AMPLIA).policyTypeName).toBe("AUTO");
|
||||||
|
expect(parsePolicy(ANA_AUTO_RC_DIAS).policyTypeName).toBe("AUTO");
|
||||||
|
expect(parsePolicy(ANA_LICENCIA).policyTypeName).toBe("LICENCIAS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a NAME, never an id — the parser must not need a database", () => {
|
||||||
|
// Anything id-shaped here would mean the parser had reached for the DB.
|
||||||
|
for (const p of [ANA_AUTO_AMPLIA, ANA_AUTO_RC_DIAS, ANA_LICENCIA]) {
|
||||||
|
expect(parsePolicy(p).policyTypeName).toMatch(/^[A-Z_]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the type unnamed when no parser claimed the page", () => {
|
||||||
|
expect(parsePolicy(page("a laundry receipt")).policyTypeName).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy / ANA responsabilidad civil por días", () => {
|
||||||
|
const p = parsePolicy(ANA_AUTO_RC_DIAS);
|
||||||
|
|
||||||
|
it("reads a by-the-day term rather than defaulting to a year", () => {
|
||||||
|
// Left at the schema's 365 default this weekend policy would sit in the
|
||||||
|
// renewals window a year out.
|
||||||
|
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-07-23");
|
||||||
|
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2026-07-26");
|
||||||
|
expect(p.coveragePeriodDays).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the clave when the agent's phone occupies the left cell", () => {
|
||||||
|
// The by-the-day products print "(661) 612 12 55" ahead of the clave, so
|
||||||
|
// it is no longer the first thing on its line.
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the excluded sections as excluded, not as insured for zero", () => {
|
||||||
|
const risks = p.coverages.map((c) => c.risk);
|
||||||
|
expect(risks).toContain("MATERIAL DAMAGE — VEHICLE: EXCLUDED");
|
||||||
|
expect(risks).toContain("TOTAL THEFT — TOWING: EXCLUDED");
|
||||||
|
// The liability sections are what this product actually sells, and they
|
||||||
|
// are NOT excluded.
|
||||||
|
expect(risks).toContain("LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy / ANA driver's policy (licencia)", () => {
|
||||||
|
const p = parsePolicy(ANA_LICENCIA);
|
||||||
|
|
||||||
|
it("reads the holder off the numbered POLICY HOLDER list", () => {
|
||||||
|
expect(p.policyNumber).toBe("700489616");
|
||||||
|
expect(p.insuredName).toBe("PAMELA DENISE WAGONER");
|
||||||
|
expect(p.legalAddress).toBe("49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260");
|
||||||
|
expect(p.zip).toBe("92260");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists one driver, not one per printed copy of the page", () => {
|
||||||
|
// The face renders three times in the real PDF; an unbounded walk
|
||||||
|
// returns the same person three times, which reads as a three-driver
|
||||||
|
// policy rather than as a parse bug.
|
||||||
|
const tripled = page([ANA_LICENCIA.text, ANA_LICENCIA.text, ANA_LICENCIA.text].join("\n\n"));
|
||||||
|
expect(p.drivers).toHaveLength(1);
|
||||||
|
expect(parsePolicy(tripled).drivers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the phone off the name even without the printed column gap", () => {
|
||||||
|
// The phone shares the name cell, and the only thing marking it off is
|
||||||
|
// white space — which the OCR seam is free to collapse. Depending on the
|
||||||
|
// gap surviving is what put "PAMELA DENISE WAGONER Ph.3102001538" in the
|
||||||
|
// insured field, where it matched no customer.
|
||||||
|
const collapsed = page(ANA_LICENCIA.text.replace(/ {2,}/g, " "));
|
||||||
|
expect(parsePolicy(collapsed).insuredName).toBe("PAMELA DENISE WAGONER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the four empty driver slots", () => {
|
||||||
|
// Slots 2-5 print an empty NAME and a bare "NONE" licence.
|
||||||
|
expect(p.drivers.map((d) => d.fullName)).toEqual(["PAMELA DENISE WAGONER"]);
|
||||||
|
expect(p.drivers[0].licenseNumber).toBe("N0017668");
|
||||||
|
expect(p.drivers[0].phone).toBe("3102001538");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insures no vehicle", () => {
|
||||||
|
expect(p.vehicles).toEqual([]);
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/no ampara un veh[íi]culo determinado/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates the SUM INSURED and PREMIUM columns by position", () => {
|
||||||
|
// Both columns print the same shape ("100,000.00 usd." / "18.70 usd.")
|
||||||
|
// and neither is labelled per row — only the offset tells them apart.
|
||||||
|
const c = byRisk(p);
|
||||||
|
const pd = c["LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES"];
|
||||||
|
expect(pd?.insuredAmount).toBe(100000);
|
||||||
|
expect(pd?.premium).toBe(18.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the trailing Per Person / Per Accident labels on this layout", () => {
|
||||||
|
// They FOLLOW their amount here and PRECEDE it on the automobile face.
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(4000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges a section's premium once, not once per limit", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.premium).toBe(54.27);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.premium).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the section order this layout uses", () => {
|
||||||
|
// CATASTROPHIC LIABILITY prints ABOVE MEDICAL EXPENSES here and below it
|
||||||
|
// on the automobile face; blocks are keyed by where the labels land.
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES"]?.insuredAmount).toBe(0);
|
||||||
|
expect(c["LEGAL AID"]?.premium).toBe(30);
|
||||||
|
expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the excluded-risk sentence that defines the product", () => {
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/riesgos excluidos: Collision, overtuning/);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||||
|
import type { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||||
|
|
||||||
|
function parsed(over: Partial<ParsedPolicy> = {}): ParsedPolicy {
|
||||||
|
return {
|
||||||
|
provider: "GMX",
|
||||||
|
policyNumber: null,
|
||||||
|
insuredName: null,
|
||||||
|
notes: [],
|
||||||
|
coverages: [],
|
||||||
|
vehicles: [],
|
||||||
|
drivers: [],
|
||||||
|
...over,
|
||||||
|
} as unknown as ParsedPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prismaStub(policies: unknown[], customers: { id: string; name: string }[]) {
|
||||||
|
const findManyPolicy = jest.fn().mockResolvedValue(policies);
|
||||||
|
const findManyCustomer = jest.fn().mockResolvedValue(customers);
|
||||||
|
return {
|
||||||
|
prisma: {
|
||||||
|
policy: { findMany: findManyPolicy },
|
||||||
|
customer: { findMany: findManyCustomer },
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
findManyPolicy,
|
||||||
|
findManyCustomer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOK = [
|
||||||
|
{ id: "cust-1", name: "WAGONER, PAMELA" },
|
||||||
|
{ id: "cust-2", name: "SMITH, JOHN" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("PolicyMatcherService name suggestions", () => {
|
||||||
|
it("suggests a customer when the policy number is new", async () => {
|
||||||
|
const { prisma } = prismaStub([], BOOK);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
const r = await svc.match(
|
||||||
|
parsed({ policyNumber: "P-999", insuredName: "PAMELA DENISE WAGONER" } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.customerSuggestions).toEqual([
|
||||||
|
expect.objectContaining({ customerId: "cust-1", tier: "PARTIAL" }),
|
||||||
|
]);
|
||||||
|
// The suggestion is surfaced, never applied.
|
||||||
|
expect(r.customerId).toBeNull();
|
||||||
|
expect(r.confident).toBe(false);
|
||||||
|
expect(r.note).toContain("posibles clientes por nombre: WAGONER, PAMELA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests when the policy number could not be read at all", async () => {
|
||||||
|
const { prisma } = prismaStub([], BOOK);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
const r = await svc.match(parsed({ insuredName: "PAMELA WAGONER" } as never));
|
||||||
|
|
||||||
|
expect(r.customerSuggestions[0]).toMatchObject({ customerId: "cust-1", tier: "EXACT" });
|
||||||
|
expect(r.customerId).toBeNull();
|
||||||
|
expect(r.note).toBe(
|
||||||
|
"no se pudo leer el número de póliza; posible cliente por nombre: WAGONER, PAMELA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch the book when the policy number hits", async () => {
|
||||||
|
const { prisma, findManyCustomer } = prismaStub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "pol-1",
|
||||||
|
policyNumber: "P-1",
|
||||||
|
customerId: "cust-2",
|
||||||
|
customer: { name: "SMITH, JOHN" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
BOOK,
|
||||||
|
);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
const r = await svc.match(
|
||||||
|
parsed({ policyNumber: "P-1", insuredName: "PAMELA WAGONER" } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.confident).toBe(true);
|
||||||
|
expect(r.customerId).toBe("cust-2");
|
||||||
|
expect(r.customerSuggestions).toEqual([]);
|
||||||
|
expect(findManyCustomer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the customer book once across a batch", async () => {
|
||||||
|
const { prisma, findManyCustomer } = prismaStub([], BOOK);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
await svc.match(parsed({ policyNumber: "A", insuredName: "PAMELA WAGONER" } as never));
|
||||||
|
await svc.match(parsed({ policyNumber: "B", insuredName: "JOHN SMITH" } as never));
|
||||||
|
|
||||||
|
expect(findManyCustomer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||||
|
import {
|
||||||
|
suggestCustomersByName,
|
||||||
|
suggestionNote,
|
||||||
|
type CustomerNameRow,
|
||||||
|
type CustomerNameSuggestion,
|
||||||
|
} from "./name-matcher";
|
||||||
|
|
||||||
|
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 }[];
|
||||||
|
/**
|
||||||
|
* Customers whose name resembles the printed insured name. Populated only
|
||||||
|
* when the policy number resolved to nothing, and never used to set
|
||||||
|
* `customerId` or `confident` — see the class comment.
|
||||||
|
*/
|
||||||
|
customerSuggestions: CustomerNameSuggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the customer book is reused across documents in a batch.
|
||||||
|
*
|
||||||
|
* A twenty-page batch would otherwise read all 1536 rows twenty times. The
|
||||||
|
* only cost of the staleness is that a customer created in the last minute
|
||||||
|
* is not suggested — the picker still finds them, so nothing is lost that a
|
||||||
|
* reviewer cannot do in one click.
|
||||||
|
*/
|
||||||
|
const BOOK_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* On that zero-hit path only, the printed name is used to *rank the picker*
|
||||||
|
* — see `name-matcher.ts`. That is not a walk-back of the rule above: the
|
||||||
|
* suggestion never reaches `customerId` or `confident`, a human still picks,
|
||||||
|
* and the ranking exists because the office writes names surname-first
|
||||||
|
* ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
|
||||||
|
* DENISE WAGONER"), so the reviewer is retyping a name the machine could
|
||||||
|
* have offered.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PolicyMatcherService {
|
||||||
|
private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
||||||
|
if (!parsed.policyNumber) {
|
||||||
|
// No number to search on, so the page goes to review with a picker —
|
||||||
|
// the same place the name suggestions help.
|
||||||
|
return this.unmatched(
|
||||||
|
"no se pudo leer el número de póliza",
|
||||||
|
await this.suggestByName(parsed.insuredName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const suggestions = await this.suggestByName(parsed.insuredName);
|
||||||
|
const hint = suggestionNote(suggestions);
|
||||||
|
return {
|
||||||
|
policyId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: [
|
||||||
|
`no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||||
|
hint,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; "),
|
||||||
|
confident: false,
|
||||||
|
candidates: [],
|
||||||
|
customerSuggestions: suggestions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length > 1) {
|
||||||
|
// The policy number did find rows; the reviewer picks among those, and
|
||||||
|
// adding name guesses on top would only add noise.
|
||||||
|
return {
|
||||||
|
policyId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
customerSuggestions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
policyId: candidates[0].policyId,
|
||||||
|
customerId: candidates[0].customerId,
|
||||||
|
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
||||||
|
confident: true,
|
||||||
|
candidates,
|
||||||
|
customerSuggestions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async suggestByName(
|
||||||
|
insuredName: string | null | undefined,
|
||||||
|
): Promise<CustomerNameSuggestion[]> {
|
||||||
|
if (!insuredName) return [];
|
||||||
|
return suggestCustomersByName(insuredName, await this.customerBook());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole customer book, held briefly. 1536 rows of `{id, name}` is a
|
||||||
|
* few hundred kilobytes and the comparison is pure token-set work, so
|
||||||
|
* scanning it beats any SQL approximation — and a `LIKE` search would in
|
||||||
|
* any case have to guess which token is the surname, which is the one
|
||||||
|
* thing the office's own data does not agree on.
|
||||||
|
*/
|
||||||
|
private async customerBook(): Promise<CustomerNameRow[]> {
|
||||||
|
if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
|
||||||
|
return this.book.rows;
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.customer.findMany({
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
this.book = { rows, loadedAt: Date.now() };
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unmatched(
|
||||||
|
note: string,
|
||||||
|
customerSuggestions: CustomerNameSuggestion[] = [],
|
||||||
|
): MatchResult {
|
||||||
|
const hint = suggestionNote(customerSuggestions);
|
||||||
|
return {
|
||||||
|
policyId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: [note, hint].filter(Boolean).join("; "),
|
||||||
|
confident: false,
|
||||||
|
candidates: [],
|
||||||
|
customerSuggestions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abandon a batch pending review — rejects every unapplied page. */
|
||||||
|
@Post("batches/:id/discard")
|
||||||
|
@RequireAbility("policy:ocr-review")
|
||||||
|
async discard(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const result = await this.policyOcr.discardBatch(id, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "policyOcr.batch.discard", {
|
||||||
|
batchId: id,
|
||||||
|
rejected: result.rejected,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,99 @@
|
|||||||
|
import { Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
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;
|
||||||
|
/** Reviewer's explicit lookup picks. Both beat the parsed name; omitted,
|
||||||
|
* the service resolves `policy_types` / `insurance_providers` by name and
|
||||||
|
* leaves the FK null when there is no such row. */
|
||||||
|
@IsOptional() @IsString() policyTypeId?: string;
|
||||||
|
@IsOptional() @IsString() insuranceProviderId?: 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() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsString() premiumPayment?: string;
|
||||||
|
/** Printed term in days. Omitted leaves the parsed value (or the schema's
|
||||||
|
* 365 default) in place; ANA sells 3- and 4-day tourist policies. */
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||||
|
/** 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() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsString() premiumPayment?: string;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||||
|
@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 {}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
|||||||
|
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
||||||
|
import { renderRenewalEmail } from "./renewal-email";
|
||||||
|
|
||||||
|
function letter(overrides: Partial<RenewalLetterRow> = {}): RenewalLetterRow {
|
||||||
|
return {
|
||||||
|
__kind: "letter",
|
||||||
|
policyId: "policy-1",
|
||||||
|
policyNumber: "POL-123",
|
||||||
|
policyType: "AUTO",
|
||||||
|
customerName: "Ana Pérez",
|
||||||
|
customerEmail: "ana@example.com",
|
||||||
|
customerPhone: "664-111-2222",
|
||||||
|
customerMobile: null,
|
||||||
|
customerAddress: ["Calle Uno 123", "Tijuana, BC, 22000"],
|
||||||
|
provider: "Aseguradora Uno",
|
||||||
|
policyTo: "2026-09-01",
|
||||||
|
netPremium: "1200.00",
|
||||||
|
policyFee: null,
|
||||||
|
total: "1392.00",
|
||||||
|
currency: "MXN",
|
||||||
|
coverageDays: null,
|
||||||
|
cslLimit: null,
|
||||||
|
medicalCoverage: null,
|
||||||
|
propertyDamage: null,
|
||||||
|
perPersonLiability: null,
|
||||||
|
additionalService: null,
|
||||||
|
vehicle: null,
|
||||||
|
generation: 1,
|
||||||
|
sentAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderRenewalEmail", () => {
|
||||||
|
it("includes policy, premium, expiration, type, and customer information", () => {
|
||||||
|
const result = renderRenewalEmail(letter());
|
||||||
|
|
||||||
|
expect(result.subject).toContain("POL-123");
|
||||||
|
expect(result.html).toContain("primer aviso");
|
||||||
|
expect(result.html).toContain("AUTO");
|
||||||
|
expect(result.html).toContain("01/09/2026");
|
||||||
|
expect(result.html).toContain("1,392.00");
|
||||||
|
expect(result.html).toContain("Ana Pérez");
|
||||||
|
expect(result.html).toContain("ana@example.com");
|
||||||
|
expect(result.html).toContain("664-111-2222");
|
||||||
|
expect(result.html).toContain("Calle Uno 123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses overdue wording for generation three", () => {
|
||||||
|
const result = renderRenewalEmail(letter({ generation: 3 }));
|
||||||
|
|
||||||
|
expect(result.subject).toContain("Póliza vencida");
|
||||||
|
expect(result.html).toContain("está vencida");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes customer-provided HTML", () => {
|
||||||
|
const result = renderRenewalEmail(
|
||||||
|
letter({ customerName: '<img src=x onerror="alert(1)">' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.html).not.toContain("<img");
|
||||||
|
expect(result.html).toContain("<img");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
||||||
|
|
||||||
|
const GENERATION_TEXT: Record<number, string> = {
|
||||||
|
1: "Le enviamos el primer aviso para renovar su póliza.",
|
||||||
|
2: "Le enviamos el segundo aviso para renovar su póliza.",
|
||||||
|
3: "Le informamos que su póliza está vencida.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value: unknown): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDate(value: string): string {
|
||||||
|
if (value === "—") return value;
|
||||||
|
const [year, month, day] = value.split("-");
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: string | null, currency: string): string {
|
||||||
|
if (!value) return "No disponible";
|
||||||
|
return new Intl.NumberFormat("es-MX", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(label: string, value: string): string {
|
||||||
|
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRenewalEmail(letter: RenewalLetterRow): {
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
} {
|
||||||
|
const expired = letter.generation === 3;
|
||||||
|
const subject = expired
|
||||||
|
? `Póliza vencida: ${letter.policyNumber}`
|
||||||
|
: `Aviso de renovación: póliza ${letter.policyNumber}`;
|
||||||
|
const phone = letter.customerMobile ?? letter.customerPhone ?? "No disponible";
|
||||||
|
const address = letter.customerAddress.join(", ") || "No disponible";
|
||||||
|
const premium = letter.total ?? letter.netPremium;
|
||||||
|
|
||||||
|
const details = [
|
||||||
|
row("Número de póliza", letter.policyNumber),
|
||||||
|
row("Tipo de póliza", letter.policyType),
|
||||||
|
row("Aseguradora", letter.provider),
|
||||||
|
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
||||||
|
row("Prima", money(premium, letter.currency)),
|
||||||
|
row("Cliente", letter.customerName),
|
||||||
|
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||||
|
row("Teléfono", phone),
|
||||||
|
row("Dirección", address),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
html: `<div style="font-family:Arial,sans-serif;color:#222;line-height:1.5"><p>Estimado(a) ${escapeHtml(letter.customerName)}:</p><p>${escapeHtml(GENERATION_TEXT[letter.generation] ?? "Le enviamos un aviso sobre la renovación de su póliza.")}</p><table style="border-collapse:collapse;width:100%;max-width:680px">${details}</table><p>Por favor, comuníquese con Jorge Cuadros & Asociados para revisar su renovación.</p><p>Atentamente,<br>Jorge Cuadros & Asociados</p></div>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { RenewalsService } from "./renewals.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renewal sweep's half of the unified notification log.
|
||||||
|
*
|
||||||
|
* `RenewalNotice` only records that a policy WAS notified — it has no way to
|
||||||
|
* say a send failed or that a customer had no address. Those rows exist only
|
||||||
|
* in `email_notification_log`, so they are what these tests pin down.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const POLICY_ID = "policy-1";
|
||||||
|
const CUSTOMER_ID = "cust-1";
|
||||||
|
|
||||||
|
function makePolicy(email: string | null) {
|
||||||
|
return {
|
||||||
|
id: POLICY_ID,
|
||||||
|
policyNumber: "700442181",
|
||||||
|
policyTo: new Date("2026-09-01T00:00:00.000Z"),
|
||||||
|
netPremium: null,
|
||||||
|
policyFee: null,
|
||||||
|
total: null,
|
||||||
|
currency: "MXN",
|
||||||
|
coveragesJson: null,
|
||||||
|
customer: {
|
||||||
|
id: CUSTOMER_ID,
|
||||||
|
name: "ACME SA DE CV",
|
||||||
|
nameMissing: false,
|
||||||
|
email,
|
||||||
|
phone: null,
|
||||||
|
mobile: null,
|
||||||
|
addressLine1: null,
|
||||||
|
addressLine2: null,
|
||||||
|
city: null,
|
||||||
|
state: null,
|
||||||
|
zipCode: null,
|
||||||
|
country: null,
|
||||||
|
},
|
||||||
|
policyType: { name: "AUTO" },
|
||||||
|
insuranceProvider: { name: "GMX" },
|
||||||
|
vehicles: [],
|
||||||
|
renewalNotices: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(overrides: {
|
||||||
|
policies?: ReturnType<typeof makePolicy>[];
|
||||||
|
sendImpl?: () => Promise<{ messageId: string; response: string }>;
|
||||||
|
}) {
|
||||||
|
const policies = overrides.policies ?? [makePolicy("cliente@example.com")];
|
||||||
|
|
||||||
|
const record = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const send =
|
||||||
|
overrides.sendImpl ??
|
||||||
|
jest.fn().mockResolvedValue({ messageId: "ses-1", response: "{}" });
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
// Only generation 1 has a candidate; the other two cadences return none,
|
||||||
|
// so a sweep produces exactly one outcome to assert on.
|
||||||
|
policy: {
|
||||||
|
findMany: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(policies)
|
||||||
|
.mockResolvedValue([]),
|
||||||
|
findFirst: jest.fn().mockResolvedValue(policies[0]),
|
||||||
|
},
|
||||||
|
renewalNotice: { upsert: jest.fn().mockResolvedValue({}) },
|
||||||
|
scheduledJobState: {
|
||||||
|
upsert: jest.fn().mockResolvedValue({}),
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
findUniqueOrThrow: jest.fn().mockResolvedValue({ lastSuccessfulAt: null }),
|
||||||
|
update: jest.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// `register` is a no-op here: these tests drive the sweep directly, so no
|
||||||
|
// cron job is ever installed.
|
||||||
|
const schedule = { register: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const service = new RenewalsService(
|
||||||
|
prisma as never,
|
||||||
|
{ available: true, send } as never,
|
||||||
|
{ log: jest.fn() } as never,
|
||||||
|
{ record } as never,
|
||||||
|
schedule as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, record, send, prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renewal notices write the shared notification log", () => {
|
||||||
|
it("records a SENT row tagged RENEWAL_NOTICE / POLICIES", async () => {
|
||||||
|
const { service, record, prisma } = build({});
|
||||||
|
|
||||||
|
await service.sweep("user-1");
|
||||||
|
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
const row = record.mock.calls[0][0];
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
notificationType: "RENEWAL_NOTICE",
|
||||||
|
servicio: "POLICIES",
|
||||||
|
status: "SENT",
|
||||||
|
customerId: CUSTOMER_ID,
|
||||||
|
customerEmail: "cliente@example.com",
|
||||||
|
providerMessageId: "ses-1",
|
||||||
|
debug: false,
|
||||||
|
});
|
||||||
|
// `level` carries the aviso generation, not an alert colour.
|
||||||
|
expect(row.level).toBe(1);
|
||||||
|
expect(row.subject).toContain("700442181");
|
||||||
|
expect(row.bodySnapshot).toContain("ACME SA DE CV");
|
||||||
|
// The gating row is still written — the log does not replace it.
|
||||||
|
expect(prisma.renewalNotice.upsert).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a FAILED row and no gating row when the send throws", async () => {
|
||||||
|
const { service, record, prisma } = build({
|
||||||
|
sendImpl: jest.fn().mockRejectedValue(new Error("SES rejected")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.sweep("user-1");
|
||||||
|
|
||||||
|
expect(result.sent).toBe(0);
|
||||||
|
expect(result.failed).toBe(1);
|
||||||
|
expect(record).toHaveBeenCalledTimes(1);
|
||||||
|
expect(record.mock.calls[0][0]).toMatchObject({
|
||||||
|
status: "FAILED",
|
||||||
|
error: "SES rejected",
|
||||||
|
notificationType: "RENEWAL_NOTICE",
|
||||||
|
});
|
||||||
|
// Nothing was delivered, so nothing may gate tomorrow's retry.
|
||||||
|
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records SKIPPED_NO_EMAIL for a candidate with no address", async () => {
|
||||||
|
const { service, record, send, prisma } = build({
|
||||||
|
policies: [makePolicy(" ")],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.sweep("user-1");
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(send).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||||
|
expect(record.mock.calls[0][0]).toMatchObject({
|
||||||
|
status: "SKIPPED_NO_EMAIL",
|
||||||
|
customerEmail: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("diverts a debug sweep and leaves the notice pending", async () => {
|
||||||
|
const { service, record, send, prisma } = build({});
|
||||||
|
|
||||||
|
const result = await service.sweep("user-1", { debug: true });
|
||||||
|
|
||||||
|
expect(result.sent).toBe(1);
|
||||||
|
expect(result.debug).toBe(true);
|
||||||
|
// The customer's own address is never contacted.
|
||||||
|
expect(jest.mocked(send).mock.calls[0][0]).toMatchObject({
|
||||||
|
to: "rmancinas@freakma.net",
|
||||||
|
xTracking: "debug",
|
||||||
|
});
|
||||||
|
expect(record.mock.calls[0][0]).toMatchObject({
|
||||||
|
status: "SENT",
|
||||||
|
customerEmail: "rmancinas@freakma.net",
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
// The letter is still owed, so nothing may gate it: no RenewalNotice row,
|
||||||
|
// and `lastSuccessfulAt` must not advance past the days we only tested.
|
||||||
|
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||||
|
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||||
|
expect(release.data.lastSuccessfulAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends one notice on demand in debug without marking it sent", async () => {
|
||||||
|
const { service, send, prisma } = build({});
|
||||||
|
|
||||||
|
const result = await service.sendOne(POLICY_ID, 1, "user-1", {
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.debug).toBe(true);
|
||||||
|
expect(result.to).toBe("rmancinas@freakma.net");
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fail a delivered notice when the log write throws", async () => {
|
||||||
|
const { service, record } = build({});
|
||||||
|
record.mockRejectedValue(new Error("log table gone"));
|
||||||
|
|
||||||
|
const result = await service.sweep("user-1");
|
||||||
|
|
||||||
|
// The mail went out and the gating row was written; a lost audit row must
|
||||||
|
// not report that as a failure, which would re-send tomorrow.
|
||||||
|
expect(result.sent).toBe(1);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Request } from "express";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from "class-validator";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { RenewalsService } from "./renewals.service";
|
||||||
|
|
||||||
|
/** The pólizas half of the shared "Flags del envío" panel. Only `debug`
|
||||||
|
* means anything here — the day gate and the send limit are estado-de-cuenta
|
||||||
|
* concepts — so the other two are simply not accepted. */
|
||||||
|
class RenewalFlagsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SendRenewalDto extends RenewalFlagsDto {
|
||||||
|
@IsString()
|
||||||
|
policyId!: string;
|
||||||
|
|
||||||
|
/** 1 = 30 días antes, 2 = 15 días antes, 3 = 7 días después. */
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(3)
|
||||||
|
generation!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
@Controller("renewals")
|
||||||
|
export class RenewalsController {
|
||||||
|
constructor(private readonly renewals: RenewalsService) {}
|
||||||
|
|
||||||
|
@Get("pending")
|
||||||
|
pending(@Query("days") days?: string) {
|
||||||
|
return this.renewals.pending(
|
||||||
|
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("sweep")
|
||||||
|
@RequireAbility("renewal:send")
|
||||||
|
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) {
|
||||||
|
return this.renewals.sweep((req.user as { id: string }).id, {
|
||||||
|
debug: dto?.debug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a single pending notice from the /notificaciones list. */
|
||||||
|
@Post("send")
|
||||||
|
@RequireAbility("renewal:send")
|
||||||
|
@HttpCode(200)
|
||||||
|
send(@Body() dto: SendRenewalDto, @Req() req: Request) {
|
||||||
|
return this.renewals.sendOne(
|
||||||
|
dto.policyId,
|
||||||
|
dto.generation,
|
||||||
|
(req.user as { id: string }).id,
|
||||||
|
{ debug: dto.debug },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { NotificationLogModule } from "../notifications/notification-log.module";
|
||||||
|
import { NotificationScheduleModule } from "../notifications/notification-schedule.module";
|
||||||
|
import { RenewalsController } from "./renewals.controller";
|
||||||
|
import { RenewalsService } from "./renewals.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
// Renewal sends write to the same `email_notification_log` the four bulk
|
||||||
|
// jobs write, so /notificaciones has one send history across both tabs, and
|
||||||
|
// take their cadence from the same operator-editable schedule.
|
||||||
|
imports: [NotificationLogModule, NotificationScheduleModule],
|
||||||
|
controllers: [RenewalsController],
|
||||||
|
providers: [RenewalsService],
|
||||||
|
})
|
||||||
|
export class RenewalsModule {}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
addUtcDays,
|
||||||
|
dateInTimeZone,
|
||||||
|
renewalWindow,
|
||||||
|
RENEWAL_CADENCE,
|
||||||
|
} from "./renewals.service";
|
||||||
|
|
||||||
|
describe("renewal scheduling dates", () => {
|
||||||
|
it("uses the America/Tijuana calendar date", () => {
|
||||||
|
expect(dateInTimeZone(new Date("2026-08-01T05:00:00.000Z"))).toEqual(
|
||||||
|
new Date("2026-07-31T00:00:00.000Z"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps generations to 30 days, 15 days, and 7 days overdue", () => {
|
||||||
|
const today = new Date("2026-08-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
RENEWAL_CADENCE.map(({ generation, offsetDays }) => ({
|
||||||
|
generation,
|
||||||
|
target: addUtcDays(today, offsetDays).toISOString().slice(0, 10),
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ generation: 1, target: "2026-08-31" },
|
||||||
|
{ generation: 2, target: "2026-08-16" },
|
||||||
|
{ generation: 3, target: "2026-07-25" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an inclusive catch-up window after a missed run", () => {
|
||||||
|
const window = renewalWindow(
|
||||||
|
new Date("2026-08-10T00:00:00.000Z"),
|
||||||
|
30,
|
||||||
|
new Date("2026-08-07T18:00:00.000Z"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(window).toEqual({
|
||||||
|
from: new Date("2026-09-07T00:00:00.000Z"),
|
||||||
|
to: new Date("2026-09-09T00:00:00.000Z"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
OnModuleInit,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import { MailService } from "../mail/mail.service";
|
||||||
|
import { NotificationLogService } from "../notifications/notification-log.service";
|
||||||
|
import {
|
||||||
|
NotificationScheduleService,
|
||||||
|
SCHEDULE_TIME_ZONE,
|
||||||
|
} from "../notifications/notification-schedule.service";
|
||||||
|
import { DEBUG_RECIPIENT } from "../notifications/notification.types";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import {
|
||||||
|
RenewalLetterPolicy,
|
||||||
|
renewalLetterSelect,
|
||||||
|
toRenewalLetterRow,
|
||||||
|
} from "../reports/renewal-letter";
|
||||||
|
import { renderRenewalEmail } from "./renewal-email";
|
||||||
|
|
||||||
|
export const RENEWAL_CADENCE = [
|
||||||
|
{ generation: 1, offsetDays: 30 },
|
||||||
|
{ generation: 2, offsetDays: 15 },
|
||||||
|
{ generation: 3, offsetDays: -7 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const JOB_NAME = "renewal-email-sweep";
|
||||||
|
/** The window maths runs in office time; the cadence itself is owned by
|
||||||
|
* `NotificationScheduleService`, which uses the same zone. */
|
||||||
|
const TIME_ZONE = SCHEDULE_TIME_ZONE;
|
||||||
|
const DAY_MS = 86400000;
|
||||||
|
|
||||||
|
export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).formatToParts(now);
|
||||||
|
const value = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
|
Number(parts.find((part) => part.type === type)?.value);
|
||||||
|
return new Date(Date.UTC(value("year"), value("month") - 1, value("day")));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addUtcDays(date: Date, days: number): Date {
|
||||||
|
return new Date(date.getTime() + days * DAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renewalWindow(
|
||||||
|
today: Date,
|
||||||
|
offsetDays: number,
|
||||||
|
lastSuccessfulAt?: Date | null,
|
||||||
|
): { from: Date; to: Date } {
|
||||||
|
const to = addUtcDays(today, offsetDays);
|
||||||
|
if (!lastSuccessfulAt) return { from: to, to };
|
||||||
|
const previousDay = dateInTimeZone(lastSuccessfulAt);
|
||||||
|
if (previousDay >= today) return { from: to, to };
|
||||||
|
return { from: addUtcDays(previousDay, offsetDays + 1), to };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RenewalsService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(RenewalsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly mail: MailService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
private readonly notificationLog: NotificationLogService,
|
||||||
|
private readonly schedule: NotificationScheduleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The cadence used to be a `@Cron("0 6 * * *")` literal here; it is now
|
||||||
|
* operator-editable, and the stored value defaults to that same 06:00
|
||||||
|
* daily run. */
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
await this.schedule.register("polizas", () => this.scheduledSweep());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The unattended run always sends for real: `debug` is a per-click switch
|
||||||
|
* in the UI, never persisted, so the schedule cannot inherit a forgotten
|
||||||
|
* test toggle and silently stop mailing customers. */
|
||||||
|
async scheduledSweep(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.sweep();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pending(days = 30) {
|
||||||
|
const today = dateInTimeZone(new Date());
|
||||||
|
const state = await this.prisma.scheduledJobState.findUnique({
|
||||||
|
where: { name: JOB_NAME },
|
||||||
|
select: { lastSuccessfulAt: true },
|
||||||
|
});
|
||||||
|
const cadence = RENEWAL_CADENCE.filter(
|
||||||
|
(item) => item.offsetDays < 0 || item.offsetDays <= days,
|
||||||
|
);
|
||||||
|
const groups = await Promise.all(
|
||||||
|
cadence.map(async (item) => ({
|
||||||
|
generation: item.generation,
|
||||||
|
rows: await this.findCandidates(
|
||||||
|
item,
|
||||||
|
today,
|
||||||
|
state?.lastSuccessfulAt ?? null,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
return groups.flatMap(({ generation, rows }) =>
|
||||||
|
rows
|
||||||
|
.filter((policy) => Boolean(policy.customer.email?.trim()))
|
||||||
|
.map((policy) => toRenewalLetterRow(policy, generation)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sweep(userId?: string, flags: { debug?: boolean } = {}) {
|
||||||
|
const debug = !!flags.debug;
|
||||||
|
const now = new Date();
|
||||||
|
const state = await this.acquireLock(now);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.mail.available) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El servicio de correo no está configurado.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = dateInTimeZone(now);
|
||||||
|
let eligible = 0;
|
||||||
|
let sent = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const failures: Array<{ policyId: string; generation: number; error: string }> = [];
|
||||||
|
|
||||||
|
for (const cadence of RENEWAL_CADENCE) {
|
||||||
|
const policies = await this.findCandidates(
|
||||||
|
cadence,
|
||||||
|
today,
|
||||||
|
state.lastSuccessfulAt,
|
||||||
|
);
|
||||||
|
eligible += policies.length;
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
const to = policy.customer.email?.trim();
|
||||||
|
if (!to) {
|
||||||
|
// Logged rather than silently counted: "we had nobody to mail"
|
||||||
|
// is a finding the office acts on, and only the log survives the
|
||||||
|
// HTTP response.
|
||||||
|
await this.recordLog(policy, cadence.generation, "", {
|
||||||
|
status: "SKIPPED_NO_EMAIL",
|
||||||
|
debug,
|
||||||
|
});
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.deliver(policy, cadence.generation, to, userId, debug);
|
||||||
|
sent++;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
policyId: policy.id,
|
||||||
|
generation: cadence.generation,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
eligible,
|
||||||
|
sent,
|
||||||
|
skipped,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
|
debug,
|
||||||
|
};
|
||||||
|
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
||||||
|
// RenewalNotice rows, so the days it "covered" are still owed, and
|
||||||
|
// narrowing tomorrow's window back to a single day would drop them.
|
||||||
|
await this.releaseLock(!debug && failures.length === 0 ? now : null);
|
||||||
|
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await this.releaseLock(null);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send one pending renewal notice on demand, from the /notificaciones
|
||||||
|
* list. Same path the sweep takes — render, send, then record the notice —
|
||||||
|
* so a letter sent by hand is marked exactly like a swept one and drops
|
||||||
|
* off the pending list. Refuses a generation already sent so a double
|
||||||
|
* click can't mail the customer twice.
|
||||||
|
*
|
||||||
|
* Under `debug` the notice is NOT marked as sent, so the row stays in the
|
||||||
|
* pending list — the customer has still not been told anything.
|
||||||
|
*/
|
||||||
|
async sendOne(
|
||||||
|
policyId: string,
|
||||||
|
generation: number,
|
||||||
|
userId?: string,
|
||||||
|
flags: { debug?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const debug = !!flags.debug;
|
||||||
|
if (!this.mail.available) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El servicio de correo no está configurado.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = await this.prisma.policy.findFirst({
|
||||||
|
where: { id: policyId, archivedAt: null },
|
||||||
|
select: renewalLetterSelect(generation),
|
||||||
|
});
|
||||||
|
if (!policy) {
|
||||||
|
throw new NotFoundException("Póliza no encontrada.");
|
||||||
|
}
|
||||||
|
if (policy.renewalNotices.some((notice) => notice.sentAt)) {
|
||||||
|
throw new ConflictException("Este aviso ya fue enviado.");
|
||||||
|
}
|
||||||
|
const to = policy.customer.email?.trim();
|
||||||
|
if (!to) {
|
||||||
|
throw new BadRequestException("El cliente no tiene correo registrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
||||||
|
policy,
|
||||||
|
generation,
|
||||||
|
to,
|
||||||
|
userId,
|
||||||
|
debug,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
policyId,
|
||||||
|
generation,
|
||||||
|
// The address the mail actually went to — under debug that is the
|
||||||
|
// override inbox, and the UI says so rather than claiming the customer
|
||||||
|
// was notified.
|
||||||
|
to: addressedTo,
|
||||||
|
debug,
|
||||||
|
sentAt: sentAt.toISOString(),
|
||||||
|
providerMessageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render + send + record one notice. Shared by the sweep and `sendOne`.
|
||||||
|
*
|
||||||
|
* Two records come out of a send: the `RenewalNotice` row, which gates the
|
||||||
|
* pending list, and an `email_notification_log` row, which is the send
|
||||||
|
* history the /notificaciones "Registro de envíos" reads. A failed send
|
||||||
|
* writes only the second — there is no notice to gate on — and rethrows so
|
||||||
|
* the sweep counts it as a failure.
|
||||||
|
*
|
||||||
|
* Under `debug` the mail is diverted to `DEBUG_RECIPIENT` and the
|
||||||
|
* `RenewalNotice` row is deliberately skipped: the customer was not
|
||||||
|
* notified, so nothing may gate the letter they are still owed. Only the
|
||||||
|
* log row is written, flagged `debug`. */
|
||||||
|
private async deliver(
|
||||||
|
policy: RenewalLetterPolicy,
|
||||||
|
generation: number,
|
||||||
|
to: string,
|
||||||
|
userId?: string,
|
||||||
|
debug = false,
|
||||||
|
) {
|
||||||
|
const letter = toRenewalLetterRow(policy, generation);
|
||||||
|
const message = renderRenewalEmail(letter);
|
||||||
|
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
||||||
|
|
||||||
|
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||||
|
try {
|
||||||
|
result = await this.mail.send({
|
||||||
|
to: addressedTo,
|
||||||
|
toName: letter.customerName,
|
||||||
|
subject: message.subject,
|
||||||
|
html: message.html,
|
||||||
|
xTracking: debug ? "debug" : "renewals",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
await this.recordLog(policy, generation, addressedTo, {
|
||||||
|
status: "FAILED",
|
||||||
|
error: detail,
|
||||||
|
debug,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentAt = new Date();
|
||||||
|
|
||||||
|
if (!debug) {
|
||||||
|
await this.prisma.renewalNotice.upsert({
|
||||||
|
where: {
|
||||||
|
policyId_generation: { policyId: policy.id, generation },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
policyId: policy.id,
|
||||||
|
generation,
|
||||||
|
channel: "EMAIL",
|
||||||
|
sentAt,
|
||||||
|
sentById: userId,
|
||||||
|
providerMessageId: result.messageId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
channel: "EMAIL",
|
||||||
|
sentAt,
|
||||||
|
sentById: userId,
|
||||||
|
providerMessageId: result.messageId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.recordLog(policy, generation, addressedTo, {
|
||||||
|
status: "SENT",
|
||||||
|
providerMessageId: result.messageId || undefined,
|
||||||
|
providerResponse: result.response || undefined,
|
||||||
|
sendDate: sentAt,
|
||||||
|
debug,
|
||||||
|
});
|
||||||
|
void this.audit.log(userId, "renewalNotice.send", {
|
||||||
|
policyId: policy.id,
|
||||||
|
generation,
|
||||||
|
debug,
|
||||||
|
providerMessageId: result.messageId,
|
||||||
|
});
|
||||||
|
return { sentAt, providerMessageId: result.messageId, addressedTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write one row to the shared notification log.
|
||||||
|
*
|
||||||
|
* Never throws: the mail is already gone (or already failed) by the time we
|
||||||
|
* get here, and losing the audit row must not turn a delivered notice into
|
||||||
|
* a reported failure — which on the SENT path would also strand the
|
||||||
|
* `RenewalNotice` we just wrote and re-send tomorrow.
|
||||||
|
*/
|
||||||
|
private async recordLog(
|
||||||
|
policy: RenewalLetterPolicy,
|
||||||
|
generation: number,
|
||||||
|
/** Recipient as addressed. Empty on the SKIPPED_NO_EMAIL path — that
|
||||||
|
* emptiness IS the reason the row exists. */
|
||||||
|
to: string,
|
||||||
|
outcome: {
|
||||||
|
status: "SENT" | "FAILED" | "SKIPPED_NO_EMAIL";
|
||||||
|
providerMessageId?: string;
|
||||||
|
providerResponse?: string;
|
||||||
|
error?: string;
|
||||||
|
sendDate?: Date;
|
||||||
|
debug?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const letter = toRenewalLetterRow(policy, generation);
|
||||||
|
const message = renderRenewalEmail(letter);
|
||||||
|
try {
|
||||||
|
await this.notificationLog.record({
|
||||||
|
notificationType: "RENEWAL_NOTICE",
|
||||||
|
servicio: "POLICIES",
|
||||||
|
sendDate: outcome.sendDate,
|
||||||
|
// `level` carries the aviso generation for RENEWAL_NOTICE rows — see
|
||||||
|
// the column doc on the Prisma model.
|
||||||
|
level: generation,
|
||||||
|
customerId: policy.customer.id,
|
||||||
|
customerName: letter.customerName,
|
||||||
|
customerEmail: to,
|
||||||
|
subject: message.subject,
|
||||||
|
bodySnapshot: message.html,
|
||||||
|
status: outcome.status,
|
||||||
|
debug: !!outcome.debug,
|
||||||
|
providerMessageId: outcome.providerMessageId,
|
||||||
|
providerResponse: outcome.providerResponse,
|
||||||
|
error: outcome.error,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`No se pudo registrar el aviso de renovación en el log ` +
|
||||||
|
`(póliza ${policy.id}, aviso ${generation}): ` +
|
||||||
|
`${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findCandidates(
|
||||||
|
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||||
|
today: Date,
|
||||||
|
lastSuccessfulAt: Date | null,
|
||||||
|
) {
|
||||||
|
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||||
|
return this.prisma.policy.findMany({
|
||||||
|
where: {
|
||||||
|
archivedAt: null,
|
||||||
|
policyTo: { gte: window.from, lte: window.to },
|
||||||
|
customer: {
|
||||||
|
archivedAt: null,
|
||||||
|
emailOptOut: false,
|
||||||
|
email: { not: "" },
|
||||||
|
},
|
||||||
|
renewalNotices: {
|
||||||
|
none: { generation: cadence.generation, sentAt: { not: null } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ policyTo: "asc" }, { policyNumber: "asc" }],
|
||||||
|
select: renewalLetterSelect(cadence.generation),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async acquireLock(now: Date) {
|
||||||
|
await this.prisma.scheduledJobState.upsert({
|
||||||
|
where: { name: JOB_NAME },
|
||||||
|
create: { name: JOB_NAME },
|
||||||
|
update: { updatedAt: now },
|
||||||
|
});
|
||||||
|
|
||||||
|
const acquired = await this.prisma.scheduledJobState.updateMany({
|
||||||
|
where: {
|
||||||
|
name: JOB_NAME,
|
||||||
|
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
|
||||||
|
},
|
||||||
|
data: { lockedUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acquired.count !== 1) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"Ya hay un barrido de renovaciones en curso.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.scheduledJobState.findUniqueOrThrow({
|
||||||
|
where: { name: JOB_NAME },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async releaseLock(lastSuccessfulAt: Date | null): Promise<void> {
|
||||||
|
await this.prisma.scheduledJobState.update({
|
||||||
|
where: { name: JOB_NAME },
|
||||||
|
data: {
|
||||||
|
lockedUntil: null,
|
||||||
|
...(lastSuccessfulAt && { lastSuccessfulAt }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export function renewalLetterSelect(generation: number) {
|
||||||
|
return Prisma.validator<Prisma.PolicySelect>()({
|
||||||
|
id: true,
|
||||||
|
policyNumber: true,
|
||||||
|
policyTo: true,
|
||||||
|
netPremium: true,
|
||||||
|
policyFee: true,
|
||||||
|
total: true,
|
||||||
|
currency: true,
|
||||||
|
coveragesJson: true,
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
// Needed by the notification log's customerId FK, not by the letter.
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
nameMissing: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
addressLine1: true,
|
||||||
|
addressLine2: true,
|
||||||
|
city: true,
|
||||||
|
state: true,
|
||||||
|
zipCode: true,
|
||||||
|
country: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policyType: { select: { name: true } },
|
||||||
|
insuranceProvider: { select: { name: true } },
|
||||||
|
vehicles: {
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
make: true,
|
||||||
|
model: true,
|
||||||
|
modelYear: true,
|
||||||
|
bodyType: true,
|
||||||
|
engineNumber: true,
|
||||||
|
licensePlate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renewalNotices: {
|
||||||
|
where: { generation },
|
||||||
|
select: { sentAt: true, channel: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenewalLetterPolicy = Prisma.PolicyGetPayload<{
|
||||||
|
select: ReturnType<typeof renewalLetterSelect>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface RenewalLetterRow extends Record<string, unknown> {
|
||||||
|
__kind: "letter";
|
||||||
|
policyId: string;
|
||||||
|
policyNumber: string;
|
||||||
|
policyType: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string | null;
|
||||||
|
customerPhone: string | null;
|
||||||
|
customerMobile: string | null;
|
||||||
|
customerAddress: string[];
|
||||||
|
provider: string;
|
||||||
|
policyTo: string;
|
||||||
|
netPremium: string | null;
|
||||||
|
policyFee: string | null;
|
||||||
|
total: string | null;
|
||||||
|
currency: string;
|
||||||
|
coverageDays: unknown;
|
||||||
|
cslLimit: unknown;
|
||||||
|
medicalCoverage: unknown;
|
||||||
|
propertyDamage: unknown;
|
||||||
|
perPersonLiability: unknown;
|
||||||
|
additionalService: unknown;
|
||||||
|
vehicle: {
|
||||||
|
make: string | null;
|
||||||
|
model: string | null;
|
||||||
|
modelYear: string | null;
|
||||||
|
bodyType: string | null;
|
||||||
|
engineNumber: string | null;
|
||||||
|
licensePlate: string | null;
|
||||||
|
} | null;
|
||||||
|
generation: number;
|
||||||
|
sentAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRenewalLetterRow(
|
||||||
|
policy: RenewalLetterPolicy,
|
||||||
|
generation: number,
|
||||||
|
): RenewalLetterRow {
|
||||||
|
const notice = policy.renewalNotices[0];
|
||||||
|
const coverage = (policy.coveragesJson ?? {}) as Record<string, unknown>;
|
||||||
|
const address = [
|
||||||
|
policy.customer.addressLine1,
|
||||||
|
policy.customer.addressLine2,
|
||||||
|
[policy.customer.city, policy.customer.state, policy.customer.zipCode]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", "),
|
||||||
|
policy.customer.country,
|
||||||
|
].filter((part): part is string => Boolean(part));
|
||||||
|
|
||||||
|
return {
|
||||||
|
__kind: "letter",
|
||||||
|
policyId: policy.id,
|
||||||
|
policyNumber: policy.policyNumber,
|
||||||
|
policyType: policy.policyType?.name ?? "—",
|
||||||
|
customerName: policy.customer.nameMissing ? "(sin nombre)" : policy.customer.name,
|
||||||
|
customerEmail: policy.customer.email,
|
||||||
|
customerPhone: policy.customer.phone,
|
||||||
|
customerMobile: policy.customer.mobile,
|
||||||
|
customerAddress: address,
|
||||||
|
provider: policy.insuranceProvider?.name ?? "—",
|
||||||
|
policyTo: policy.policyTo ? policy.policyTo.toISOString().slice(0, 10) : "—",
|
||||||
|
netPremium: policy.netPremium ? policy.netPremium.toFixed(2) : null,
|
||||||
|
policyFee: policy.policyFee ? policy.policyFee.toFixed(2) : null,
|
||||||
|
total: policy.total ? policy.total.toFixed(2) : null,
|
||||||
|
currency: policy.currency,
|
||||||
|
coverageDays: coverage.cobertura ?? null,
|
||||||
|
cslLimit: coverage.csl_limite ?? null,
|
||||||
|
medicalCoverage: coverage.gastos_medico ?? null,
|
||||||
|
propertyDamage: coverage.propiedades ?? null,
|
||||||
|
perPersonLiability: coverage.personas ?? null,
|
||||||
|
additionalService:
|
||||||
|
coverage.servicio_adicional ?? coverage.servicio_adiconal ?? null,
|
||||||
|
vehicle: policy.vehicles[0]
|
||||||
|
? {
|
||||||
|
make: policy.vehicles[0].make,
|
||||||
|
model: policy.vehicles[0].model,
|
||||||
|
modelYear: policy.vehicles[0].modelYear,
|
||||||
|
bodyType: policy.vehicles[0].bodyType,
|
||||||
|
engineNumber: policy.vehicles[0].engineNumber,
|
||||||
|
licensePlate: policy.vehicles[0].licensePlate,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
generation,
|
||||||
|
sentAt: notice?.sentAt
|
||||||
|
? notice.sentAt.toISOString().slice(0, 10)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -15,12 +15,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import {
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
|
periodSourceTable,
|
||||||
|
} from "../billing/billing.service";
|
||||||
import {
|
import {
|
||||||
intParam,
|
intParam,
|
||||||
NOT_VOIDED,
|
NOT_VOIDED,
|
||||||
parseDate,
|
parseDate,
|
||||||
type ReportDef,
|
type ReportDef,
|
||||||
} from "./reports.types";
|
} from "./reports.types";
|
||||||
|
import {
|
||||||
|
renewalLetterSelect,
|
||||||
|
toRenewalLetterRow,
|
||||||
|
} from "./renewal-letter";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ helpers */
|
/* ------------------------------------------------------------------ helpers */
|
||||||
|
|
||||||
@@ -615,10 +624,7 @@ const vigente: ReportDef = {
|
|||||||
* covers every carrier and tier instead of a clone per combination.
|
* covers every carrier and tier instead of a clone per combination.
|
||||||
*
|
*
|
||||||
* `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
|
* `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
|
||||||
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log
|
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log.
|
||||||
* — but this report is read-only; marking a notice as sent is a separate
|
|
||||||
* mutation (not yet built) that would upsert `RenewalNotice` by
|
|
||||||
* `[policyId, generation]`.
|
|
||||||
*/
|
*/
|
||||||
const avisoRenovacion: ReportDef = {
|
const avisoRenovacion: ReportDef = {
|
||||||
slug: "aviso-renovacion",
|
slug: "aviso-renovacion",
|
||||||
@@ -711,78 +717,16 @@ const avisoRenovacion: ReportDef = {
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
orderBy: { policyTo: "asc" },
|
orderBy: { policyTo: "asc" },
|
||||||
select: {
|
select: renewalLetterSelect(generation),
|
||||||
id: true,
|
|
||||||
policyNumber: true,
|
|
||||||
policyTo: true,
|
|
||||||
netPremium: true,
|
|
||||||
policyFee: true,
|
|
||||||
total: true,
|
|
||||||
currency: true,
|
|
||||||
coveragesJson: true,
|
|
||||||
customer: { select: { name: true, nameMissing: true } },
|
|
||||||
insuranceProvider: { select: { name: true } },
|
|
||||||
vehicles: {
|
|
||||||
take: 1,
|
|
||||||
select: {
|
|
||||||
make: true,
|
|
||||||
model: true,
|
|
||||||
modelYear: true,
|
|
||||||
bodyType: true,
|
|
||||||
engineNumber: true,
|
|
||||||
licensePlate: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
renewalNotices: {
|
|
||||||
where: { generation },
|
|
||||||
select: { sentAt: true, channel: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let totalPremium = new Prisma.Decimal(0);
|
let totalPremium = new Prisma.Decimal(0);
|
||||||
let sentCount = 0;
|
let sentCount = 0;
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||||
const notice = r.renewalNotices[0];
|
const letter = toRenewalLetterRow(r, generation);
|
||||||
if (notice?.sentAt) sentCount++;
|
if (letter.sentAt) sentCount++;
|
||||||
// Legacy coverage columns not modeled as first-class Policy fields —
|
return letter;
|
||||||
// see docs/RENEWAL_NOTICES.md's column-mapping table. Keys are best-
|
|
||||||
// effort (derived from the source schema, not yet verified against a
|
|
||||||
// live migrated DB) — confirm before relying on them in production.
|
|
||||||
const cov = (r.coveragesJson ?? {}) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
__kind: "letter",
|
|
||||||
policyId: r.id,
|
|
||||||
policyNumber: r.policyNumber,
|
|
||||||
customerName: nameOf(r.customer),
|
|
||||||
provider: r.insuranceProvider?.name ?? "—",
|
|
||||||
policyTo: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
|
|
||||||
netPremium: r.netPremium ? r.netPremium.toFixed(2) : null,
|
|
||||||
policyFee: r.policyFee ? r.policyFee.toFixed(2) : null,
|
|
||||||
total: r.total ? r.total.toFixed(2) : null,
|
|
||||||
currency: r.currency,
|
|
||||||
coverageDays: cov.cobertura ?? null,
|
|
||||||
cslLimit: cov.csl_limite ?? null,
|
|
||||||
medicalCoverage: cov.gastos_medico ?? null,
|
|
||||||
propertyDamage: cov.propiedades ?? null,
|
|
||||||
perPersonLiability: cov.personas ?? null,
|
|
||||||
additionalService: cov.servicio_adicional ?? cov.servicio_adiconal ?? null,
|
|
||||||
vehicle: r.vehicles[0]
|
|
||||||
? {
|
|
||||||
make: r.vehicles[0].make,
|
|
||||||
model: r.vehicles[0].model,
|
|
||||||
modelYear: r.vehicles[0].modelYear,
|
|
||||||
bodyType: r.vehicles[0].bodyType,
|
|
||||||
engineNumber: r.vehicles[0].engineNumber,
|
|
||||||
licensePlate: r.vehicles[0].licensePlate,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
generation,
|
|
||||||
sentAt: notice?.sentAt
|
|
||||||
? notice.sentAt.toISOString().slice(0, 10)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -812,13 +756,22 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
title: "Estado de cuenta",
|
title: "Estado de cuenta",
|
||||||
description:
|
description:
|
||||||
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
||||||
"ramo y concepto, y el historial completo de movimientos con saldo " +
|
"ramo y concepto, y los movimientos del año en curso con saldo " +
|
||||||
"corrido. El reporte del cliente final.",
|
"corrido, abriendo con el saldo anterior. El reporte del cliente final.",
|
||||||
domain: "estado-cuenta",
|
domain: "estado-cuenta",
|
||||||
legacyName: "EDO CUENTA DATOS",
|
legacyName: "EDO CUENTA DATOS",
|
||||||
format: "statement",
|
format: "statement",
|
||||||
params: [
|
params: [
|
||||||
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
||||||
|
// Which period to print. Blank means the year in progress; an earlier year
|
||||||
|
// prints from its imported archive, the same source the on-screen
|
||||||
|
// statement reads.
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Periodo (año)",
|
||||||
|
kind: "number",
|
||||||
|
placeholder: "año en curso",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
// Statement rows carry synthetic `__kind` discriminators instead of
|
// Statement rows carry synthetic `__kind` discriminators instead of
|
||||||
@@ -850,22 +803,63 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
});
|
});
|
||||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
||||||
|
|
||||||
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
|
// The source-table exclusion, the balance floor and the year scope below
|
||||||
// as BillingService.statement so the numbers match what the customer
|
// are BillingService.statement's, because this report and
|
||||||
// already sees in /estado-cuenta/[id].
|
// /estado-cuenta/[id] are the same statement — one printable, one on
|
||||||
|
// screen — and a customer holding both must not read two balances.
|
||||||
|
const floor = await prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which period to print. An earlier year comes from its imported archive,
|
||||||
|
// tagged rather than dated, exactly as the on-screen statement reads it.
|
||||||
|
const thisYear = new Date().getUTCFullYear();
|
||||||
|
const askedYear = Number(p.year);
|
||||||
|
const requestedYear =
|
||||||
|
Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear;
|
||||||
|
const isArchive = requestedYear !== thisYear;
|
||||||
|
|
||||||
const rows = await prisma.transaction.findMany({
|
const rows = await prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
legacySourceTable: {
|
...(isArchive
|
||||||
notIn: [
|
? // The archive is one period's ledger already, so the tag is the
|
||||||
"EFECTIVO",
|
// whole filter and the balance floor must not apply — the floor
|
||||||
"EFECTIVO_BACKUP",
|
// hides exactly the history this period is asking for.
|
||||||
"EFECTIVO FM3",
|
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||||
"CHEQUE FM3",
|
: {
|
||||||
"IVA 2015",
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
],
|
// Archive rows count as history below the year start (that is
|
||||||
},
|
// what `opening` is for, and for a customer floored by an archive
|
||||||
|
// it is the only carry there is) and are dropped at or above it.
|
||||||
|
// Same rule as the on-screen twin — see BillingService.statement.
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{
|
||||||
|
transactionDate: {
|
||||||
|
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash receipt book, which the ledger already carries as
|
||||||
|
// its own `C<folio>` postings. Taken from the shared helper
|
||||||
|
// rather than restated, so the printed statement and the screen
|
||||||
|
// cannot drift apart — and so this keeps the database
|
||||||
|
// qualifier that spares the insurance line's own EFECTIVO.
|
||||||
|
notCashJournal(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -883,12 +877,32 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compute running balance per currency, then return newest-first.
|
// Scoped to the calendar year and listed oldest-first, the way the legacy
|
||||||
|
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
||||||
|
// balance — they are folded into `opening` and printed as a single "saldo
|
||||||
|
// anterior" line, which is what a BALANCE FORWARD row is.
|
||||||
|
// An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
|
||||||
|
// FORWARD row is the carry, printed like legacy printed it.
|
||||||
|
const yearStart = isArchive
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.UTC(requestedYear, 0, 1));
|
||||||
|
const year = requestedYear;
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const movements = rows.map((r) => {
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
const visible: typeof rows = [];
|
||||||
|
|
||||||
|
const movements = rows.flatMap((r) => {
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||||
const next = prev.plus(r.amount);
|
const next = prev.plus(r.amount);
|
||||||
running.set(r.currency, next);
|
running.set(r.currency, next);
|
||||||
|
|
||||||
|
if (r.transactionDate < yearStart) {
|
||||||
|
opening.set(r.currency, next);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.push(r);
|
||||||
return {
|
return {
|
||||||
date: r.transactionDate.toISOString().slice(0, 10),
|
date: r.transactionDate.toISOString().slice(0, 10),
|
||||||
domain: r.domain,
|
domain: r.domain,
|
||||||
@@ -901,14 +915,38 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
movements.reverse();
|
|
||||||
|
|
||||||
// Per-currency summary + per-domain breakdown.
|
// The carried balance, printed as the statement's first line — same shape
|
||||||
|
// as a movement row so it needs nothing special from the renderer.
|
||||||
|
const carried = [...opening.entries()]
|
||||||
|
.filter(([, amount]) => !amount.isZero())
|
||||||
|
.map(([currency, amount]) => ({
|
||||||
|
date: yearStart.toISOString().slice(0, 10),
|
||||||
|
domain: "UTILITY",
|
||||||
|
currency,
|
||||||
|
reference: "",
|
||||||
|
period: `Al cierre de ${year - 1}`,
|
||||||
|
checkNumber: "",
|
||||||
|
concept: "SALDO ANTERIOR",
|
||||||
|
amount: amount.toFixed(2),
|
||||||
|
balanceAfter: amount.toFixed(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Per-currency summary, seeded with the carried balance so it reconciles
|
||||||
|
// against the last running balance printed below.
|
||||||
const perCurrency = new Map<
|
const perCurrency = new Map<
|
||||||
string,
|
string,
|
||||||
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const [currency, amount] of opening) {
|
||||||
|
perCurrency.set(currency, {
|
||||||
|
currency,
|
||||||
|
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
|
||||||
|
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of visible) {
|
||||||
const c =
|
const c =
|
||||||
perCurrency.get(r.currency) ??
|
perCurrency.get(r.currency) ??
|
||||||
{
|
{
|
||||||
@@ -942,9 +980,10 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
count: c.count,
|
count: c.count,
|
||||||
})),
|
})),
|
||||||
{ __kind: "movements-header" },
|
{ __kind: "movements-header" },
|
||||||
|
...carried,
|
||||||
...movements,
|
...movements,
|
||||||
],
|
],
|
||||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SettingsService } from "./settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator-editable configuration. No controller of its own — each setting is
|
||||||
|
* exposed by the feature that owns it (summary recipients live under
|
||||||
|
* /notifications), so the validation and the permission live next to the
|
||||||
|
* thing they protect rather than behind a generic key/value endpoint.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [SettingsService],
|
||||||
|
exports: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { SettingsService, invalidEmails, parseEmailList } from "./settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The db → env → default ladder is the whole contract of this service: it is
|
||||||
|
* what lets the setting move out of the environment without changing how any
|
||||||
|
* existing deployment behaves.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function build(row: { value: string } | null, env?: string) {
|
||||||
|
const prisma = {
|
||||||
|
appSetting: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(
|
||||||
|
row ? { key: "k", updatedAt: new Date("2026-08-02"), updatedById: "u1", ...row } : null,
|
||||||
|
),
|
||||||
|
upsert: jest.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = { get: jest.fn().mockReturnValue(env) };
|
||||||
|
return {
|
||||||
|
service: new SettingsService(prisma as never, config as never),
|
||||||
|
prisma,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("notification admin emails resolve db > env > default", () => {
|
||||||
|
it("prefers the stored row", async () => {
|
||||||
|
const { service } = build({ value: "a@x.com,b@x.com" }, "env@x.com");
|
||||||
|
|
||||||
|
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
|
||||||
|
value: ["a@x.com", "b@x.com"],
|
||||||
|
source: "db",
|
||||||
|
updatedById: "u1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the environment when nothing is stored", async () => {
|
||||||
|
const { service } = build(null, "env@x.com, other@x.com");
|
||||||
|
|
||||||
|
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
|
||||||
|
value: ["env@x.com", "other@x.com"],
|
||||||
|
source: "env",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the built-in defaults when neither is set", async () => {
|
||||||
|
const { service } = build(null, undefined);
|
||||||
|
|
||||||
|
const resolved = await service.notificationAdminEmails();
|
||||||
|
expect(resolved.source).toBe("default");
|
||||||
|
expect(resolved.value).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a stored empty list as 'nobody', not as unset", async () => {
|
||||||
|
// The regression this guards: falling through to env/defaults here would
|
||||||
|
// keep mailing people who were deliberately removed.
|
||||||
|
const { service } = build({ value: "" }, "env@x.com");
|
||||||
|
|
||||||
|
await expect(service.notificationAdminEmails()).resolves.toMatchObject({
|
||||||
|
value: [],
|
||||||
|
source: "db",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the list back as CSV", async () => {
|
||||||
|
const { service, prisma } = build({ value: "" });
|
||||||
|
|
||||||
|
await service.setNotificationAdminEmails(["a@x.com", "b@x.com"], "user-9");
|
||||||
|
|
||||||
|
expect(prisma.appSetting.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
create: expect.objectContaining({ value: "a@x.com,b@x.com", updatedById: "user-9" }),
|
||||||
|
update: expect.objectContaining({ value: "a@x.com,b@x.com", updatedById: "user-9" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("email list parsing", () => {
|
||||||
|
it("trims and drops blanks", () => {
|
||||||
|
expect(parseEmailList(" a@x.com , ,b@x.com ")).toEqual(["a@x.com", "b@x.com"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects entries that are not addresses at all", () => {
|
||||||
|
expect(invalidEmails(["ok@x.com", "nope", "also@bad"])).toEqual([
|
||||||
|
"nope",
|
||||||
|
"also@bad",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reader/writer for `app_settings` — the configuration staff can change
|
||||||
|
* without a redeploy.
|
||||||
|
*
|
||||||
|
* Every setting resolves through the same three-step ladder: the database row
|
||||||
|
* if an operator has set one, else the environment variable it used to live
|
||||||
|
* in, else a hardcoded default. That ordering is what makes this migration
|
||||||
|
* safe — an existing deployment keeps behaving exactly as it did until
|
||||||
|
* somebody edits the value in the UI, and `source` tells the UI which of the
|
||||||
|
* three it is looking at so "this came from the env, editing it here will
|
||||||
|
* take over" is visible rather than surprising.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SETTING_KEYS = {
|
||||||
|
/** Comma-separated recipients of the per-job notification summary. */
|
||||||
|
notificationAdminEmails: "notification.adminEmails",
|
||||||
|
/** JSON cadence of the automatic servicios sweep. */
|
||||||
|
scheduleServicios: "notification.schedule.servicios",
|
||||||
|
/** JSON cadence of the automatic pólizas renewal sweep. */
|
||||||
|
schedulePolizas: "notification.schedule.polizas",
|
||||||
|
/** Whether the NUMid allocator may reuse empty portal ids. */
|
||||||
|
numidRecycleEmpty: "numid.recycleEmpty",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Where a resolved value came from. Shown in the UI. */
|
||||||
|
export type SettingSource = "db" | "env" | "default";
|
||||||
|
|
||||||
|
export interface ResolvedSetting<T> {
|
||||||
|
value: T;
|
||||||
|
source: SettingSource;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
updatedById: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last resort when neither the database nor the environment says otherwise.
|
||||||
|
* Matches what `NotificationsService` hardcoded before this table existed. */
|
||||||
|
const DEFAULT_ADMIN_EMAILS = ["rmancinas@freakma.net", "mpulido@freakma.net"];
|
||||||
|
|
||||||
|
/** Deliberately permissive — this rejects "not an address at all", not
|
||||||
|
* "not deliverable". Only SES can tell us the latter, and a validator strict
|
||||||
|
* enough to argue with is a validator that blocks a legitimate address. */
|
||||||
|
const EMAIL_RE = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/;
|
||||||
|
|
||||||
|
export function parseEmailList(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidEmails(list: string[]): string[] {
|
||||||
|
return list.filter((e) => !EMAIL_RE.test(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
private readonly logger = new Logger(SettingsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipients of the per-job summary email.
|
||||||
|
*
|
||||||
|
* Read on every send rather than cached at boot: the point of moving this
|
||||||
|
* out of the environment was that it changes while the app is running, and
|
||||||
|
* a cache would reintroduce exactly the restart-to-apply behaviour we are
|
||||||
|
* removing. It is one indexed primary-key lookup per sweep, not per email.
|
||||||
|
*/
|
||||||
|
async notificationAdminEmails(): Promise<ResolvedSetting<string[]>> {
|
||||||
|
const row = await this.read(SETTING_KEYS.notificationAdminEmails);
|
||||||
|
if (row) {
|
||||||
|
const parsed = parseEmailList(row.value);
|
||||||
|
// An empty stored value is a legitimate choice — "send no summaries" —
|
||||||
|
// and must not silently fall through to the env or the defaults, or an
|
||||||
|
// operator who cleared the field would keep receiving mail.
|
||||||
|
return {
|
||||||
|
value: parsed,
|
||||||
|
source: "db",
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedById: row.updatedById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = this.config.get<string>("NOTIFICATION_ADMIN_EMAILS");
|
||||||
|
if (env && env.trim()) {
|
||||||
|
return {
|
||||||
|
value: parseEmailList(env),
|
||||||
|
source: "env",
|
||||||
|
updatedAt: null,
|
||||||
|
updatedById: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: [...DEFAULT_ADMIN_EMAILS],
|
||||||
|
source: "default",
|
||||||
|
updatedAt: null,
|
||||||
|
updatedById: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the summary recipients. An empty list is stored as an empty
|
||||||
|
* string and means "nobody" — see the read path above. */
|
||||||
|
async setNotificationAdminEmails(
|
||||||
|
emails: string[],
|
||||||
|
userId: string,
|
||||||
|
): Promise<ResolvedSetting<string[]>> {
|
||||||
|
await this.write(
|
||||||
|
SETTING_KEYS.notificationAdminEmails,
|
||||||
|
emails.join(","),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
return this.notificationAdminEmails();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cadence of one automatic envío, stored as JSON.
|
||||||
|
*
|
||||||
|
* No env rung on this ladder: a schedule was never an environment variable
|
||||||
|
* (it was a `@Cron` literal in the source), so the only two sources are the
|
||||||
|
* operator's row and the caller's default — which is the previous hardcoded
|
||||||
|
* behaviour. A row that fails to parse is treated as absent and logged
|
||||||
|
* rather than thrown: a bad JSON blob must not take the scheduler down with
|
||||||
|
* it, and falling back to the shipped cadence is the safe reading.
|
||||||
|
*/
|
||||||
|
async notificationSchedule<T>(
|
||||||
|
kind: "servicios" | "polizas",
|
||||||
|
fallback: T,
|
||||||
|
): Promise<ResolvedSetting<T>> {
|
||||||
|
const key =
|
||||||
|
kind === "servicios"
|
||||||
|
? SETTING_KEYS.scheduleServicios
|
||||||
|
: SETTING_KEYS.schedulePolizas;
|
||||||
|
const row = await this.read(key);
|
||||||
|
if (row) {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
value: { ...fallback, ...(JSON.parse(row.value) as T) },
|
||||||
|
source: "db",
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedById: row.updatedById,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Setting ${key} is not valid JSON, using the default: ` +
|
||||||
|
`${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { value: fallback, source: "default", updatedAt: null, updatedById: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNotificationSchedule(
|
||||||
|
kind: "servicios" | "polizas",
|
||||||
|
schedule: unknown,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.write(
|
||||||
|
kind === "servicios"
|
||||||
|
? SETTING_KEYS.scheduleServicios
|
||||||
|
: SETTING_KEYS.schedulePolizas,
|
||||||
|
JSON.stringify(schedule),
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the NUMid allocator may reuse empty portal ids instead of only
|
||||||
|
* issuing new ones.
|
||||||
|
*
|
||||||
|
* Defaults to OFF, and the default is the safety property rather than a
|
||||||
|
* preference: while Access remains the utilities master, every reusable id
|
||||||
|
* still exists in DATGRAL, and a `--sync` migration run reassigns the ref back
|
||||||
|
* to its Access owner (transform_customers.py:327). Recycling before utilities
|
||||||
|
* cuts over therefore hands out ids that quietly stop working. No env rung —
|
||||||
|
* this has never been an environment variable and should be flipped
|
||||||
|
* deliberately, in the UI, by someone who knows the cutover happened.
|
||||||
|
*/
|
||||||
|
async numidRecycleEmpty(): Promise<ResolvedSetting<boolean>> {
|
||||||
|
const row = await this.read(SETTING_KEYS.numidRecycleEmpty);
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
value: row.value === "true",
|
||||||
|
source: "db",
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedById: row.updatedById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { value: false, source: "default", updatedAt: null, updatedById: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNumidRecycleEmpty(
|
||||||
|
enabled: boolean,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ResolvedSetting<boolean>> {
|
||||||
|
await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId);
|
||||||
|
return this.numidRecycleEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(key: string) {
|
||||||
|
return this.prisma.appSetting.findUnique({ where: { key } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async write(key: string, value: string, userId: string) {
|
||||||
|
await this.prisma.appSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
create: { key, value, updatedById: userId },
|
||||||
|
update: { value, updatedById: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,106 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Boxes are sized from the text, at 6 units a character: the reassembler now
|
||||||
|
* reads the space BETWEEN two boxes, so a fixed width would put a fabricated
|
||||||
|
* gap after every short word and every row would come back column-padded.
|
||||||
|
*/
|
||||||
|
function word(x: number, y: number, text: string): string {
|
||||||
|
return `<word xMin="${x}" yMin="${y}" xMax="${x + text.length * 6}" 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(68, 100, "FACTURADO:"),
|
||||||
|
word(300, 100.4, "20260630-20260630"),
|
||||||
|
padding(),
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(page).not.toBeNull();
|
||||||
|
expect(page!.text).toMatch(/PERIODO FACTURADO:\s+20260630-20260630/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps genuinely separate lines apart", () => {
|
||||||
|
const [page] = parseBboxLayout(
|
||||||
|
doc(word(20, 100, "Cuenta:") + word(68, 100, "0900003463"), word(20, 130, "Nombre:"), padding()),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const lines = page!.text.split("\n").map((l) => l.trim());
|
||||||
|
expect(lines).toContain("Cuenta: 0900003463");
|
||||||
|
expect(lines).toContain("Nombre:");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout is data. A borderless table separates its cells with nothing
|
||||||
|
* but white space, so the parsers read a run of spaces as a cell boundary
|
||||||
|
* (`INSURED\s{2,}`) and a column offset as a column (`SUM INSURED` vs
|
||||||
|
* `PREMIUM`). Both regressed to nothing when this collapsed every gap to a
|
||||||
|
* single space, and the fixtures — taken from `pdftotext -layout`, which
|
||||||
|
* prints the gaps — could not see it.
|
||||||
|
*/
|
||||||
|
it("preserves the gap between two cells of a borderless table", () => {
|
||||||
|
const [page] = parseBboxLayout(
|
||||||
|
doc(word(20, 100, "INSURED") + word(300, 100, "PAMELA") + word(340, 100, "WAGONER"), padding()),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const line = page!.text.split("\n").find((l) => l.includes("INSURED"))!;
|
||||||
|
expect(line).toMatch(/INSURED\s{2,}PAMELA WAGONER/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the blank line between two blocks", () => {
|
||||||
|
const [page] = parseBboxLayout(
|
||||||
|
doc(word(20, 100, "Insured"), word(20, 112, "wraps"), word(20, 200, "Next"), padding()),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const lines = page!.text.split("\n").map((l) => l.trim());
|
||||||
|
// The wrapped continuation stays attached; the next block is cut off from
|
||||||
|
// it, which is what stops a "join until the cell ends" walk running away.
|
||||||
|
expect(lines.slice(lines.indexOf("Insured"), lines.indexOf("Next") + 1)).toEqual([
|
||||||
|
"Insured",
|
||||||
|
"wraps",
|
||||||
|
"",
|
||||||
|
"Next",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,438 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* Vertical WHITE SPACE is preserved as a blank line. Rows alone are not the
|
||||||
|
* whole layout: on a form, the blank between two blocks is what says where a
|
||||||
|
* cell's wrapped value stops, and dropping it leaves parsers that walk a
|
||||||
|
* block ("keep joining until the cell ends") running to the end of the page.
|
||||||
|
* That is not hypothetical — the GMX PVL especificación read its whole first
|
||||||
|
* page as the insured's name, because the fixtures were taken from
|
||||||
|
* `pdftotext -layout` (which prints the blanks) while the runtime fed it this
|
||||||
|
* function's output (which did not).
|
||||||
|
*
|
||||||
|
* Horizontal white space is preserved the same way, by padding each word out
|
||||||
|
* to its own column. The same fixture mismatch bit here: a run of spaces is
|
||||||
|
* the ONLY thing separating two cells of a borderless table, so ANA's
|
||||||
|
* `INSURED\s{2,}` label matches and its `SUM INSURED` / `PREMIUM` column
|
||||||
|
* split (taken from `head.search()` offsets) both need real offsets. Joining
|
||||||
|
* on one space put every driver's-policy premium in the sum-insured column
|
||||||
|
* and left the phone glued to the insured's name.
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
const charWidth = estimateCharWidth(words);
|
||||||
|
const out: string[] = [];
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
if (i > 0 && isBlankBetween(rows[i - 1], r)) out.push("");
|
||||||
|
out.push(layoutRow(r, charWidth));
|
||||||
|
});
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row rendered at its printed column offsets.
|
||||||
|
*
|
||||||
|
* Words that merely follow one another inside the same cell are separated by
|
||||||
|
* exactly one space, whatever the column arithmetic says: one `charWidth` for
|
||||||
|
* a page that mixes fonts leaves a rounding error on every word, and letting
|
||||||
|
* that accumulate sprinkles `\s{2,}` runs through ordinary prose — which is
|
||||||
|
* the very thing the parsers read as a cell boundary. Only a gap wide enough
|
||||||
|
* to be deliberate (more than one blank character) is rendered as one, and
|
||||||
|
* only there is the word re-anchored to its true column, so the offsets a
|
||||||
|
* column split depends on stay honest while values stay clean.
|
||||||
|
*/
|
||||||
|
function layoutRow(row: OcrWord[], charWidth: number): string {
|
||||||
|
let line = "";
|
||||||
|
let right = 0;
|
||||||
|
|
||||||
|
for (const w of [...row].sort((a, b) => a.left - b.left)) {
|
||||||
|
const col = Math.round(w.left / charWidth);
|
||||||
|
if (!line.length) {
|
||||||
|
line = " ".repeat(Math.max(0, col));
|
||||||
|
} else if (w.left - right > charWidth * 1.5) {
|
||||||
|
line += " ".repeat(Math.max(2, col - line.length));
|
||||||
|
} else {
|
||||||
|
line += " ";
|
||||||
|
}
|
||||||
|
line += w.text;
|
||||||
|
right = w.left + w.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of one character, in the same units the word boxes use.
|
||||||
|
*
|
||||||
|
* The median of each word's own width-per-character: robust to the handful of
|
||||||
|
* oversized headings and to the wide-tracked letterhead, both of which would
|
||||||
|
* drag a mean. Only words of 3+ characters vote, since a one-character box is
|
||||||
|
* mostly side bearing. Falls back to a value derived from line height when a
|
||||||
|
* page has nothing long enough to measure.
|
||||||
|
*/
|
||||||
|
function estimateCharWidth(words: OcrWord[]): number {
|
||||||
|
const samples = words
|
||||||
|
.filter((w) => w.text.length >= 3 && w.width > 0)
|
||||||
|
.map((w) => w.width / w.text.length)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (samples.length) return samples[Math.floor(samples.length / 2)];
|
||||||
|
const heights = words.map((w) => w.height).filter((h) => h > 0);
|
||||||
|
return heights.length ? Math.max(...heights) / 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the space between two consecutive rows read as an empty line?
|
||||||
|
*
|
||||||
|
* Measured against the taller of the two rows so a heading and its body text
|
||||||
|
* are judged on their own scale. On the real documents the two populations do
|
||||||
|
* not overlap: consecutive lines of one paragraph sit at 0.3–1.1 line heights
|
||||||
|
* apart, and anything the reader sees as blank-separated starts at 2.1. The
|
||||||
|
* threshold is placed in that empty middle, biased high — a missed blank only
|
||||||
|
* restores today's behaviour, while a spurious one would cut a wrapped value
|
||||||
|
* short.
|
||||||
|
*/
|
||||||
|
function isBlankBetween(prev: OcrWord[], row: OcrWord[]): boolean {
|
||||||
|
const bottom = Math.max(...prev.map((w) => w.top + w.height));
|
||||||
|
const top = Math.min(...row.map((w) => w.top));
|
||||||
|
const unit = Math.max(
|
||||||
|
...prev.map((w) => w.height),
|
||||||
|
...row.map((w) => w.height),
|
||||||
|
);
|
||||||
|
return unit > 0 && top - bottom > unit * 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,174 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Abandon a batch pending review — rejects every unposted page. */
|
||||||
|
@Post("batches/:id/discard")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async discard(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const result = await this.statements.discardBatch(id, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.batch.discard", {
|
||||||
|
batchId: id,
|
||||||
|
rejected: result.rejected,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,526 @@
|
|||||||
|
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.");
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.statementDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||||
|
});
|
||||||
|
// Rejecting the last open page settles the batch just as posting it would
|
||||||
|
// — without this, a fully-rejected batch sat in READY_FOR_REVIEW forever
|
||||||
|
// because only confirmBatch() ever closed one.
|
||||||
|
await this.closeIfDone(doc.batchId);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw away a whole batch that is pending review: every page that has not
|
||||||
|
* been posted is marked REJECTED and the batch itself becomes DISCARDED.
|
||||||
|
*
|
||||||
|
* Refuses once any page is POSTED — those pages already wrote ledger rows
|
||||||
|
* against a check, and a "discarded" label on the batch would leave those
|
||||||
|
* charges unexplained. Reject the remaining pages individually instead.
|
||||||
|
*/
|
||||||
|
async discardBatch(batchId: string, reviewedById: string) {
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id: batchId },
|
||||||
|
});
|
||||||
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||||
|
if (batch.status === "DISCARDED") {
|
||||||
|
throw new BadRequestException("Este lote ya fue descartado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const posted = await this.prisma.statementDocument.count({
|
||||||
|
where: { batchId, status: "POSTED" },
|
||||||
|
});
|
||||||
|
if (posted > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`No se puede descartar: ${posted} página(s) ya se registraron en el estado de cuenta.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count } = await this.prisma.statementDocument.updateMany({
|
||||||
|
where: { batchId, status: { notIn: ["POSTED", "REJECTED"] } },
|
||||||
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "DISCARDED", completedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { batchId, rejected: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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.updateMany({
|
||||||
|
// `updateMany` + a status filter so a discarded batch is never quietly
|
||||||
|
// relabelled COMPLETED by a late reject on one of its pages.
|
||||||
|
where: { id: batchId, status: { not: "DISCARDED" } },
|
||||||
|
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 {
|
private require(): S3Client {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.1",
|
"version": "1.0.26",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const TYPE: ChildConfig = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ key: "name", label: "Nombre" },
|
{ key: "name", label: "Nombre" },
|
||||||
{ key: "shortDescription", label: "Descripción" },
|
{ key: "shortDescription", label: "Descripción" },
|
||||||
|
// The rate is stored as a fraction, not a percentage, and the label has to
|
||||||
|
// say so: 8 typed here would tax a $600 premium $4,800. The API rejects
|
||||||
|
// anything above 1 rather than trusting the label alone.
|
||||||
|
{ key: "taxRate", label: "IVA (0.08 = 8%)", type: "number", step: "0.0001" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const ADJUSTER: ChildConfig = {
|
const ADJUSTER: ChildConfig = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports";
|
|||||||
import {
|
import {
|
||||||
archiveCustomer,
|
archiveCustomer,
|
||||||
getCustomer,
|
getCustomer,
|
||||||
|
grantPortalAccess,
|
||||||
policyDocumentDownloadUrl,
|
policyDocumentDownloadUrl,
|
||||||
propertyDocumentDownloadUrl,
|
propertyDocumentDownloadUrl,
|
||||||
restoreCustomer,
|
restoreCustomer,
|
||||||
@@ -127,6 +128,7 @@ function Detail({ id }: { id: string }) {
|
|||||||
customerId={data.id}
|
customerId={data.id}
|
||||||
summary={data.transactionSummary}
|
summary={data.transactionSummary}
|
||||||
transactions={data.transactions}
|
transactions={data.transactions}
|
||||||
|
year={data.transactionYear}
|
||||||
/>
|
/>
|
||||||
<DocumentosSection data={data} />
|
<DocumentosSection data={data} />
|
||||||
</div>
|
</div>
|
||||||
@@ -151,9 +153,43 @@ function CustomerActions({
|
|||||||
}) {
|
}) {
|
||||||
const canEdit = useCan("customer:update");
|
const canEdit = useCan("customer:update");
|
||||||
const canDelete = useCan("customer:delete");
|
const canDelete = useCan("customer:delete");
|
||||||
|
const canGrantPortal = useCan("customer:portal-access");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const archived = customer.archivedAt != null;
|
const archived = customer.archivedAt != null;
|
||||||
|
|
||||||
|
// The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the
|
||||||
|
// "Security Number" my.jorgecuadros.com asks for. An insurance ref is a
|
||||||
|
// different id space entirely and does not let anyone log in, so both columns
|
||||||
|
// are checked — matching on sourceTable alone would hide the button from
|
||||||
|
// customers who cannot actually reach the portal.
|
||||||
|
const hasPortalId = customer.legacyRefs.some(
|
||||||
|
(r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function grantPortal() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"¿Asignar un número de portal a este cliente? Con él podrá entrar a " +
|
||||||
|
"my.jorgecuadros.com.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { numid, origin } = await grantPortalAccess(customer.id);
|
||||||
|
window.alert(
|
||||||
|
origin === "existing"
|
||||||
|
? `Este cliente ya tenía el número de portal ${numid}.`
|
||||||
|
: `Número de portal asignado: ${numid}.`,
|
||||||
|
);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleArchive() {
|
async function toggleArchive() {
|
||||||
const verb = archived ? "restaurar" : "archivar";
|
const verb = archived ? "restaurar" : "archivar";
|
||||||
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||||
@@ -169,11 +205,23 @@ function CustomerActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canEdit && !canDelete) return null;
|
const showPortal = canGrantPortal && !hasPortalId && !archived;
|
||||||
|
if (!canEdit && !canDelete && !showPortal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
{archived && <span className="badge badge-negative">Archivado</span>}
|
{archived && <span className="badge badge-negative">Archivado</span>}
|
||||||
|
{showPortal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={grantPortal}
|
||||||
|
disabled={busy}
|
||||||
|
title="Asigna el número que el cliente usa para entrar al portal"
|
||||||
|
>
|
||||||
|
Habilitar acceso al portal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||||
Editar
|
Editar
|
||||||
@@ -699,16 +747,18 @@ function EstadoCuentaSection({
|
|||||||
customerId,
|
customerId,
|
||||||
summary,
|
summary,
|
||||||
transactions,
|
transactions,
|
||||||
|
year,
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
summary: TransactionSummaryRow[];
|
summary: TransactionSummaryRow[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
|
year: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
rule="cuenta"
|
rule="cuenta"
|
||||||
title="Estado de cuenta"
|
title={`Estado de cuenta ${year}`}
|
||||||
count={transactions.length}
|
count={transactions.length}
|
||||||
countSuffix="movimientos"
|
countSuffix="movimientos"
|
||||||
/>
|
/>
|
||||||
@@ -735,7 +785,7 @@ function EstadoCuentaSection({
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<div className="empty-inline">Sin movimientos registrados.</div>
|
<div className="empty-inline">Sin movimientos en {year}.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
<table className="tx-table">
|
<table className="tx-table">
|
||||||
@@ -757,11 +807,11 @@ function EstadoCuentaSection({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{transactions.length >= 100 && (
|
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
Movimientos de {year}, del más antiguo al más reciente. Los saldos de
|
||||||
Mostrando los 100 movimientos más recientes.
|
arriba son el saldo actual por línea de negocio — los mismos del
|
||||||
</div>
|
estado de cuenta, no la suma del año.
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{transactions.length > 0 && (
|
{transactions.length > 0 && (
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ import type {
|
|||||||
* charge and an insurance payment finally sit on the same page, under the same
|
* charge and an insurance payment finally sit on the same page, under the same
|
||||||
* person, with a running balance.
|
* person, with a running balance.
|
||||||
*
|
*
|
||||||
* The running balance is per currency (the API accumulates it chronologically
|
* The running balance is per currency, so the movement table is scoped to one
|
||||||
* before handing the list back newest-first), so the movement table is scoped
|
* currency at a time — a column that alternated between pesos and dollars would
|
||||||
* to one currency at a time — a column that alternated between pesos and
|
* be a meaningless number.
|
||||||
* dollars would be a meaningless number.
|
*
|
||||||
|
* Like the legacy EDO CUENTA report, the table covers one calendar year and runs
|
||||||
|
* oldest-first, opening on the balance carried in from before it. The period
|
||||||
|
* selector switches years; earlier ones are served from the imported archive of
|
||||||
|
* that year, which is how legacy kept them — one table per closed year.
|
||||||
*/
|
*/
|
||||||
export default function EstadoCuentaDetailPage({
|
export default function EstadoCuentaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -65,19 +69,27 @@ function StatementView({ id }: { id: string }) {
|
|||||||
|
|
||||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||||
|
/** null = the current period; the API decides what that is. */
|
||||||
|
const [year, setYear] = useState<number | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
getStatement(id)
|
getStatement(id, year ?? undefined)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(d);
|
setData(d);
|
||||||
// Default to the currency the customer actually moves the most in;
|
// Default to the currency the customer actually moves the most in;
|
||||||
// preserve a previously-chosen currency across reloads.
|
// preserve a previously-chosen currency across reloads — but only if
|
||||||
|
// the loaded period still has it. Switching to a year the customer
|
||||||
|
// never moved dollars in would otherwise leave the picker on USD with
|
||||||
|
// no matching option, showing an empty table for a year that has rows.
|
||||||
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||||
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
const fallback = busiest?.currency ?? "MXN";
|
||||||
|
setCurrency((prev) =>
|
||||||
|
prev && d.summary.some((s) => s.currency === prev) ? prev : fallback,
|
||||||
|
);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -99,7 +111,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
return cleanup;
|
return cleanup;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id, year]);
|
||||||
|
|
||||||
const movements = useMemo(() => {
|
const movements = useMemo(() => {
|
||||||
if (!data || !currency) return [];
|
if (!data || !currency) return [];
|
||||||
@@ -194,7 +206,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
rule="cuenta"
|
rule="cuenta"
|
||||||
title="Movimientos"
|
title={`Movimientos ${data.year}`}
|
||||||
count={movements.length}
|
count={movements.length}
|
||||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||||
right={
|
right={
|
||||||
@@ -227,6 +239,26 @@ function StatementView({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="filter-row">
|
<div className="filter-row">
|
||||||
|
{/* Only the periods this customer has. A year with no archive would
|
||||||
|
render an empty table that reads as "no hubo movimientos" when the
|
||||||
|
truth is that the year was never imported. */}
|
||||||
|
{data.availableYears.length > 1 && (
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Periodo</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={data.year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{data.availableYears.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
{y === data.availableYears[0] ? " (en curso)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
<span className="filter-label">Moneda</span>
|
<span className="filter-label">Moneda</span>
|
||||||
<select
|
<select
|
||||||
@@ -260,7 +292,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
{movements.length === 0 ? (
|
{movements.length === 0 ? (
|
||||||
<div className="empty-inline">
|
<div className="empty-inline">
|
||||||
Sin movimientos en {currency}
|
Sin movimientos de {data.year} en {currency}
|
||||||
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -282,6 +314,26 @@ function StatementView({ id }: { id: string }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{/*
|
||||||
|
The carried balance, shown the way the legacy report shows
|
||||||
|
it: a BALANCE FORWARD line above the year's movements. It
|
||||||
|
only appears when there is something to carry — when the
|
||||||
|
customer's opening-balance row is itself dated inside this
|
||||||
|
year (the usual case) it is listed as an ordinary movement
|
||||||
|
and this row is zero, so it is left out.
|
||||||
|
|
||||||
|
Suppressed under a business-line filter: the carried balance
|
||||||
|
is the customer's, across both lines, and printing it above
|
||||||
|
one line's rows would read as that line's opening balance.
|
||||||
|
*/}
|
||||||
|
{!domain && Number(active?.opening ?? 0) !== 0 && (
|
||||||
|
<OpeningRow
|
||||||
|
opening={active!.opening}
|
||||||
|
currency={currency}
|
||||||
|
year={data.year}
|
||||||
|
canVoid={canVoid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{movements.map((m) => (
|
{movements.map((m) => (
|
||||||
<StatementRow
|
<StatementRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -481,6 +533,44 @@ function ConceptosSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
|
||||||
|
function OpeningRow({
|
||||||
|
opening,
|
||||||
|
currency,
|
||||||
|
year,
|
||||||
|
canVoid,
|
||||||
|
}: {
|
||||||
|
opening: string;
|
||||||
|
currency: LedgerCurrency;
|
||||||
|
year: number;
|
||||||
|
canVoid: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(`${year}-01-01T00:00:00.000Z`)}
|
||||||
|
</td>
|
||||||
|
<td className="tx-domain-cell">Ambas líneas</td>
|
||||||
|
<td>
|
||||||
|
Saldo anterior
|
||||||
|
<div className="tx-concept">Al cierre de {year - 1}</div>
|
||||||
|
</td>
|
||||||
|
<td className="tx-ref">—</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
|
||||||
|
{formatMoney(opening, currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`bal-running ${balanceTone(opening)}`}>
|
||||||
|
{formatMoney(opening, currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{canVoid && <td />}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatementRow({
|
function StatementRow({
|
||||||
m,
|
m,
|
||||||
canVoid,
|
canVoid,
|
||||||
|
|||||||
@@ -1,557 +1,11 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { Captura } from "@/components/Captura";
|
||||||
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
|
|
||||||
import { useCan } from "@/lib/abilities";
|
|
||||||
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
|
|
||||||
import type {
|
|
||||||
BatchCreateInput,
|
|
||||||
BillingFacets,
|
|
||||||
ByCheckResponse,
|
|
||||||
Currency,
|
|
||||||
LedgerCurrency,
|
|
||||||
TransactionDomain,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch capture by check — the "Editor" screen from the legacy system
|
|
||||||
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2).
|
|
||||||
*
|
|
||||||
* Staff key many customers' receipts against ONE physical check before cutting
|
|
||||||
* it, then check that the captured total matches the check's amount. That
|
|
||||||
* reconciliation is the whole point, so the running total is the most prominent
|
|
||||||
* thing on the page and an optional "importe del cheque" field turns it into a
|
|
||||||
* live difference.
|
|
||||||
*
|
|
||||||
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
|
|
||||||
* by it answers every by-check question (see the "Reporte por cheque" report).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DOMAINS: { key: TransactionDomain; label: string }[] = [
|
|
||||||
{ key: "UTILITY", label: "Servicios" },
|
|
||||||
{ key: "INSURANCE", label: "Seguros" },
|
|
||||||
{ key: "TRUST", label: "Fideicomiso" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Line {
|
|
||||||
/** Local row key — lines have no server identity until the batch posts. */
|
|
||||||
key: number;
|
|
||||||
customerId: string;
|
|
||||||
customerName: string;
|
|
||||||
amount: string;
|
|
||||||
reference: string;
|
|
||||||
period: string;
|
|
||||||
outstanding: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function blankLine(key: number): Line {
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
customerId: "",
|
|
||||||
customerName: "",
|
|
||||||
amount: "",
|
|
||||||
reference: "",
|
|
||||||
period: "",
|
|
||||||
outstanding: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Daily capture, opened on the manual (key-by-hand) mode. */
|
||||||
export default function BatchCapturePage() {
|
export default function BatchCapturePage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<BatchCapture />
|
<Captura initialMode="manual" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BatchCapture() {
|
|
||||||
const canCapture = useCan("ledger:create");
|
|
||||||
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
|
||||||
|
|
||||||
// Check-level fields — shared by every line.
|
|
||||||
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
|
|
||||||
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
|
|
||||||
const [typeId, setTypeId] = useState("");
|
|
||||||
const [checkNumber, setCheckNumber] = useState("");
|
|
||||||
const [transactionDate, setTransactionDate] = useState(
|
|
||||||
new Date().toISOString().slice(0, 10),
|
|
||||||
);
|
|
||||||
/** The physical check's amount, for reconciliation only — never submitted. */
|
|
||||||
const [checkAmount, setCheckAmount] = useState("");
|
|
||||||
|
|
||||||
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
|
|
||||||
const [nextKey, setNextKey] = useState(4);
|
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filled = lines.filter(
|
|
||||||
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Charges are captured as positive numbers and signed on submit, matching
|
|
||||||
// MovementForm — staff type what's on the bill, not a negative.
|
|
||||||
const total = useMemo(
|
|
||||||
() =>
|
|
||||||
filled
|
|
||||||
.filter((l) => !l.outstanding)
|
|
||||||
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
|
||||||
[filled],
|
|
||||||
);
|
|
||||||
const outstandingTotal = useMemo(
|
|
||||||
() =>
|
|
||||||
filled
|
|
||||||
.filter((l) => l.outstanding)
|
|
||||||
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
|
||||||
[filled],
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkAmt = Number(checkAmount);
|
|
||||||
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
|
|
||||||
const diff = hasCheckAmt ? checkAmt - total : 0;
|
|
||||||
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
|
|
||||||
|
|
||||||
function update(key: number, patch: Partial<Line>) {
|
|
||||||
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLine() {
|
|
||||||
setLines((ls) => [...ls, blankLine(nextKey)]);
|
|
||||||
setNextKey((k) => k + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLine(key: number) {
|
|
||||||
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!checkNumber.trim()) {
|
|
||||||
setError("Indica el número de cheque.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (filled.length === 0) {
|
|
||||||
setError("Captura al menos una línea con cliente y monto.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dupes = filled
|
|
||||||
.map((l) => l.customerId)
|
|
||||||
.filter((id, i, arr) => arr.indexOf(id) !== i);
|
|
||||||
if (dupes.length) {
|
|
||||||
const names = filled
|
|
||||||
.filter((l) => dupes.includes(l.customerId))
|
|
||||||
.map((l) => l.customerName);
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
|
|
||||||
", ",
|
|
||||||
)}). ¿Continuar?`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: BatchCreateInput = {
|
|
||||||
domain,
|
|
||||||
transactionDate,
|
|
||||||
checkNumber: checkNumber.trim(),
|
|
||||||
currency: currency as Currency,
|
|
||||||
typeId: typeId || undefined,
|
|
||||||
lines: filled.map((l) => ({
|
|
||||||
customerId: l.customerId,
|
|
||||||
// Every line of a check batch is a charge the office paid out.
|
|
||||||
amount: -Math.abs(Number(l.amount)),
|
|
||||||
reference: l.reference.trim() || undefined,
|
|
||||||
period: l.period.trim() || undefined,
|
|
||||||
outstanding: l.outstanding || undefined,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await createMovementBatch(payload);
|
|
||||||
// Re-read through the by-check view so the confirmation shows what's
|
|
||||||
// actually stored (including anything captured against this check
|
|
||||||
// earlier), not just what this request sent.
|
|
||||||
setPosted(await getByCheck(payload.checkNumber));
|
|
||||||
} catch (e2) {
|
|
||||||
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setPosted(null);
|
|
||||||
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
|
|
||||||
setNextKey((k) => k + 3);
|
|
||||||
setCheckNumber("");
|
|
||||||
setCheckAmount("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canCapture) {
|
|
||||||
return (
|
|
||||||
<div className="state-box state-error">
|
|
||||||
No tienes permiso para capturar movimientos.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (posted) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-head">
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Lote capturado</h1>
|
|
||||||
<p className="eyebrow">
|
|
||||||
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
|
|
||||||
{posted.count === 1 ? "movimiento" : "movimientos"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 10 }}>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={reset}>
|
|
||||||
Capturar otro cheque
|
|
||||||
</button>
|
|
||||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
|
||||||
Volver a estado de cuenta
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="filtered-totals" style={{ marginBottom: 16 }}>
|
|
||||||
{posted.totals.map((t) => (
|
|
||||||
<div className="filtered-total" key={t.currency}>
|
|
||||||
<span className="filtered-total-cur">{t.currency}</span>
|
|
||||||
<span className="filtered-total-net">
|
|
||||||
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
|
|
||||||
</span>
|
|
||||||
<span>{formatNumber(t.count)} movimientos</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{posted.outstandingCount > 0 && (
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span>
|
|
||||||
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
|
|
||||||
total)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tx-scroll">
|
|
||||||
<table className="tx-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Cliente</th>
|
|
||||||
<th>Referencia</th>
|
|
||||||
<th>Periodo</th>
|
|
||||||
<th>Estado</th>
|
|
||||||
<th className="num">Monto</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{posted.items.map((i) => (
|
|
||||||
<tr key={i.id}>
|
|
||||||
<td>
|
|
||||||
<Link
|
|
||||||
href={`/estado-cuenta/${i.customerId}`}
|
|
||||||
className="inline-link"
|
|
||||||
>
|
|
||||||
{i.customerName}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>{i.reference || "—"}</td>
|
|
||||||
<td>{i.period || "—"}</td>
|
|
||||||
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
|
|
||||||
<td className="num">
|
|
||||||
<span className="tx-amount neg">
|
|
||||||
{formatMoney(i.amount, i.currency)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="muted" style={{ marginTop: 14 }}>
|
|
||||||
Para imprimir la conciliación, usa el reporte{" "}
|
|
||||||
<Link
|
|
||||||
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
|
|
||||||
posted.checkNumber,
|
|
||||||
)}`}
|
|
||||||
className="inline-link"
|
|
||||||
>
|
|
||||||
Reporte por cheque
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-head">
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Captura por cheque</h1>
|
|
||||||
<p className="eyebrow">
|
|
||||||
Captura los recibos de varios clientes contra un mismo cheque y
|
|
||||||
concilia el total antes de guardar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
|
||||||
Cancelar
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="state-box state-error">{error}</div>}
|
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
|
||||||
Datos del cheque
|
|
||||||
</h2>
|
|
||||||
<div className="form-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Número de cheque *</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={checkNumber}
|
|
||||||
onChange={(e) => setCheckNumber(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Fecha *</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
value={transactionDate}
|
|
||||||
onChange={(e) => setTransactionDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Línea de negocio *</span>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={domain}
|
|
||||||
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
|
||||||
>
|
|
||||||
{DOMAINS.map((d) => (
|
|
||||||
<option key={d.key} value={d.key}>
|
|
||||||
{d.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Moneda *</span>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={currency}
|
|
||||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
|
||||||
>
|
|
||||||
<option value="MXN">Pesos (MXN)</option>
|
|
||||||
<option value="USD">Dólares (USD)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Concepto</span>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={typeId}
|
|
||||||
onChange={(e) => setTypeId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">(sin concepto)</option>
|
|
||||||
{facets?.types.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{txTypeLabel({ nameEn: t.name })}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Importe del cheque</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={checkAmount}
|
|
||||||
onChange={(e) => setCheckAmount(e.target.value)}
|
|
||||||
placeholder="Para conciliar"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 className="section-title" style={{ margin: 0 }}>
|
|
||||||
Recibos ({formatNumber(filled.length)})
|
|
||||||
</h2>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={addLine}>
|
|
||||||
Agregar línea
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tx-scroll">
|
|
||||||
<table className="tx-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ minWidth: 240 }}>Cliente *</th>
|
|
||||||
<th style={{ minWidth: 120 }}>Referencia</th>
|
|
||||||
<th style={{ minWidth: 100 }}>Periodo</th>
|
|
||||||
<th style={{ minWidth: 110 }} className="num">
|
|
||||||
Monto *
|
|
||||||
</th>
|
|
||||||
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
|
|
||||||
<th style={{ width: 1 }} />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{lines.map((l) => (
|
|
||||||
<tr key={l.key}>
|
|
||||||
<td>
|
|
||||||
<CustomerPicker
|
|
||||||
value={l.customerId}
|
|
||||||
valueName={l.customerId ? l.customerName : undefined}
|
|
||||||
onPick={(id, name) =>
|
|
||||||
update(l.key, { customerId: id, customerName: name })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={l.reference}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(l.key, { reference: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={l.period}
|
|
||||||
onChange={(e) => update(l.key, { period: e.target.value })}
|
|
||||||
placeholder="2026-07"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
className="input num"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={l.amount}
|
|
||||||
onChange={(e) => update(l.key, { amount: e.target.value })}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ textAlign: "center" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={l.outstanding}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(l.key, { outstanding: e.target.checked })
|
|
||||||
}
|
|
||||||
aria-label="Sin fondos"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
|
||||||
onClick={() => removeLine(l.key)}
|
|
||||||
disabled={lines.length === 1}
|
|
||||||
>
|
|
||||||
Quitar
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
|
||||||
Conciliación
|
|
||||||
</h2>
|
|
||||||
<div className="filtered-totals">
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span className="filtered-total-cur">{currency}</span>
|
|
||||||
<span className="filtered-total-net">
|
|
||||||
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
|
|
||||||
</span>
|
|
||||||
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
|
|
||||||
</div>
|
|
||||||
{outstandingTotal > 0 && (
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span>
|
|
||||||
Sin fondos{" "}
|
|
||||||
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
|
|
||||||
(no suma al cheque)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasCheckAmt && (
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span className="filtered-total-net">
|
|
||||||
{reconciled ? (
|
|
||||||
<strong className="tx-amount pos">Cuadra con el cheque</strong>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Diferencia{" "}
|
|
||||||
<strong className="tx-amount neg">
|
|
||||||
{formatMoney(String(diff), currency)}
|
|
||||||
</strong>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={saving || filled.length === 0}
|
|
||||||
>
|
|
||||||
{saving
|
|
||||||
? "Guardando…"
|
|
||||||
: `Capturar ${formatNumber(filled.length)} ${
|
|
||||||
filled.length === 1 ? "recibo" : "recibos"
|
|
||||||
}`}
|
|
||||||
</button>
|
|
||||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
|
||||||
Cancelar
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -212,6 +212,11 @@ button {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Secondary line inside a row or card — used alongside .muted throughout. */
|
||||||
|
.small {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
App shell / top nav
|
App shell / top nav
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -837,6 +842,46 @@ button {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Upload progress (Operaciones ingest) */
|
||||||
|
.upload-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
.progress-track {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--brand-500);
|
||||||
|
transition: width 0.2s linear;
|
||||||
|
}
|
||||||
|
.progress-indeterminate .progress-fill {
|
||||||
|
width: 40% !important;
|
||||||
|
animation: progress-slide 1.2s var(--ease-out-quart) infinite;
|
||||||
|
}
|
||||||
|
@keyframes progress-slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(250%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.upload-progress-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% {
|
||||||
background-position: -420px 0;
|
background-position: -420px 0;
|
||||||
@@ -881,6 +926,15 @@ button {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
margin-bottom: 0.4375rem;
|
margin-bottom: 0.4375rem;
|
||||||
}
|
}
|
||||||
|
/* Sub-label under an input: the computed figure behind an override field, or
|
||||||
|
why a field is disabled. Quiet enough not to compete with .field-label. */
|
||||||
|
.field-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--muted-2);
|
||||||
|
margin-top: 0.3125rem;
|
||||||
|
}
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -895,6 +949,12 @@ button {
|
|||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
color: var(--muted-2);
|
color: var(--muted-2);
|
||||||
}
|
}
|
||||||
|
.input:disabled,
|
||||||
|
.select:disabled {
|
||||||
|
background: var(--surface-2, var(--surface));
|
||||||
|
color: var(--muted-2);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--brand-600);
|
border-color: var(--brand-600);
|
||||||
@@ -3069,3 +3129,61 @@ button {
|
|||||||
border-color: var(--brand-500);
|
border-color: var(--brand-500);
|
||||||
color: var(--brand-700);
|
color: var(--brand-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Layout + text utilities the screens already assumed
|
||||||
|
Several components were written against these names before any rule
|
||||||
|
defined them, so they rendered as bare inline spans. The visible symptom
|
||||||
|
was the policy OCR review header running together —
|
||||||
|
"Para revisarPágina 1700489616· PAMELA DENISE WAGONERLICENCIASANA" —
|
||||||
|
because JSX drops the newline between sibling elements and the `gap` those
|
||||||
|
call sites pass does nothing without a flex container.
|
||||||
|
========================================================================== */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
/* The muted line under a page title, and the same voice reused inline. Only
|
||||||
|
the block form takes a margin — as a flex child it would shift the item
|
||||||
|
off the row's centre line. */
|
||||||
|
.page-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
p.page-sub {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
/* A neutral chip. Same shape as `.badge` so the OCR statuses, policy type and
|
||||||
|
carrier read as the labels they are rather than as running prose. */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.1875rem 0.5625rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--paper-2);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
/* The warning sibling of `.state-error`, used where a page needs a human to
|
||||||
|
choose between candidates rather than reporting a failure. */
|
||||||
|
.state-warn {
|
||||||
|
background: var(--servicios-tint);
|
||||||
|
border: 1px solid rgba(154, 106, 18, 0.25);
|
||||||
|
color: var(--servicios-ink);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,19 +8,18 @@ export const metadata = {
|
|||||||
"Plataforma interna unificada de clientes, servicios y seguros.",
|
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The browser talks to the API cross-origin, so it needs the API URL at
|
// API_ORIGIN is an OPTIONAL override, read here on the server per request and
|
||||||
// runtime. NEXT_PUBLIC_* would bake it at build time (one URL per image); we
|
// injected as window.__API_ORIGIN__ (see lib/api.ts). NEXT_PUBLIC_* would bake
|
||||||
// want the URL to come from the deploy .env instead. So read it here on the
|
// it at build time (one URL per image); reading it here keeps one image usable
|
||||||
// server per request and inject it as window.__API_ORIGIN__ (see lib/api.ts).
|
// anywhere. Left unset — the normal case — this injects the empty string and
|
||||||
// force-dynamic guarantees process.env is read at request time, never baked
|
// lib/api.ts derives the origin from window.location instead, so the app
|
||||||
// into a static prerender.
|
// follows the server when it moves without an env edit. force-dynamic
|
||||||
|
// guarantees process.env is read at request time, never baked into a static
|
||||||
|
// prerender.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
const apiOrigin =
|
const apiOrigin = process.env.API_ORIGIN ?? "";
|
||||||
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
|
// 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.
|
// image is not pinned to one build identity in its client bundle.
|
||||||
const build = readBuildInfoFromEnv();
|
const build = readBuildInfoFromEnv();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user