Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -33,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
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
||||||
#
|
#
|
||||||
# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what
|
# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
|
||||||
# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and `latest`
|
# what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`,
|
||||||
# image tags. Deploying stays a separate, deliberate act: once the build is
|
# `sha-<short>` and `latest` image tags, and deploy-on-tag.yml, which waits for
|
||||||
# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag
|
# that build to go green and then dispatches deploy-galactus.yml with
|
||||||
# carries the `v`, the image tag does not).
|
# `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
|
# 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
|
# thing that must be identical every time, and cutting it from a laptop is how
|
||||||
@@ -191,12 +197,26 @@ jobs:
|
|||||||
const tag = `v${process.env.VERSION}`;
|
const tag = `v${process.env.VERSION}`;
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
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 started = async () => {
|
||||||
const res = await fetch(`${base}/actions/runs?limit=30`, { headers });
|
const res = await fetch(`${base}/actions/runs?limit=30`, { headers });
|
||||||
if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
return (body.workflow_runs || []).some(
|
return (body.workflow_runs || []).some(
|
||||||
(r) => r.head_sha === sha && String(r.path || "").includes("build.yml"),
|
(r) =>
|
||||||
|
r.head_sha === sha &&
|
||||||
|
String(r.path || "").includes("build.yml") &&
|
||||||
|
isTagRun(r),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,5 +272,9 @@ jobs:
|
|||||||
echo "Released v${VERSION}."
|
echo "Released v${VERSION}."
|
||||||
echo ""
|
echo ""
|
||||||
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
||||||
echo "When it is green, dispatch 'Deploy to galactus' with:"
|
echo "deploy-on-tag.yml is watching that build; when it goes green it dispatches"
|
||||||
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false"
|
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,16 +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 — DONE** (2026-08-01). The ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff key in by hand, built in `apps/api/src/statements/` and posting through §1.2's `createBatch` seam with `source: "OCR"` and a per-document `captureRef`. Web: `/recibos` + `/recibos/:id`. Abilities `statement:ingest`/`statement:review` (STAFF — the review step is what makes machine capture safe at that tier). OCR is self-hosted **Tesseract** behind a swappable `OcrProvider` interface; `tesseract-ocr`, `tesseract-ocr-data-spa` and `poppler-utils` were added to the API image.
|
- **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**.
|
**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`.
|
**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.
|
**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.)
|
||||||
@@ -162,7 +174,13 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
|
|
||||||
**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 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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -447,6 +447,11 @@ for what's actually next.
|
|||||||
|
|
||||||
## Statement OCR intake (`/recibos`) — DONE 2026-08-01
|
## 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
|
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
|
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
|
+ amount per page, instead of keying 300+ statements per company by hand. Built
|
||||||
@@ -526,3 +531,167 @@ Implementation notes worth keeping:
|
|||||||
**Open:** whether the CFE charge should be the rounded barcode/headline figure
|
**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
|
(`$268`, what is paid at the window — what the parser uses today) or the exact
|
||||||
breakdown total (`$268.88`). One question for Jorge.
|
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.4",
|
"version": "1.0.23",
|
||||||
"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,8 +1,10 @@
|
|||||||
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";
|
||||||
@@ -10,17 +12,22 @@ 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 { 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,
|
||||||
@@ -28,9 +35,12 @@ import { AppController } from "./app.controller";
|
|||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
StatementsModule,
|
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"
|
||||||
@@ -36,16 +40,29 @@ export type Ability =
|
|||||||
| "statement:review"
|
| "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",
|
||||||
@@ -65,6 +82,15 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"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,180 @@
|
|||||||
|
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 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(findMany.mock.calls[0][0].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(findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||||
|
"transactionDate",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||||
|
// The two guards answer different questions — one reproduces legacy's
|
||||||
|
// DATOS2-only materialization, the other drops superseded history — and
|
||||||
|
// dropping either one changes the customer's balance.
|
||||||
|
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
const where = findMany.mock.calls[0][0].where;
|
||||||
|
expect(where.OR).toEqual([
|
||||||
|
{ 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -152,6 +159,56 @@ const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
|||||||
*/
|
*/
|
||||||
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy type name for a carried-forward opening balance.
|
||||||
|
*
|
||||||
|
* These rows are not movements. Access materialized one per customer per year,
|
||||||
|
* dated Jan 1, holding the closing balance of everything before it — that is
|
||||||
|
* what let the portal keep each year in its own table (`datosfreak` = current,
|
||||||
|
* `2025`, `2024`, ...) and still show a correct running balance from a single
|
||||||
|
* year's rows.
|
||||||
|
*/
|
||||||
|
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)`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source tables excluded from the customer-facing statement.
|
* Source tables excluded from the customer-facing statement.
|
||||||
*
|
*
|
||||||
@@ -402,22 +459,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} ${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} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
${having}
|
${having}
|
||||||
) x
|
) x
|
||||||
@@ -458,9 +517,18 @@ 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 applies NOT_SUPERSEDED
|
||||||
|
* and drops rows an opening balance already accounts for. The four aggregates
|
||||||
|
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot.
|
||||||
|
*/
|
||||||
async stats() {
|
async stats() {
|
||||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
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 +537,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}
|
||||||
|
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}
|
||||||
|
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 +585,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}
|
||||||
|
GROUP BY t.customerId, t.currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
`;
|
`;
|
||||||
@@ -534,7 +618,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 +633,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 +673,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,11 +701,14 @@ 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 the current 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.
|
||||||
|
*
|
||||||
|
* 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) {
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
@@ -647,9 +734,32 @@ export class BillingService {
|
|||||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
||||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
||||||
// (which has no legacySourceTable) silently vanished from the
|
// (which has no legacySourceTable) silently vanished from the
|
||||||
@@ -683,15 +793,52 @@ 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 yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
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 +858,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 +885,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 +957,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 +976,37 @@ 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: yearStart.getUTCFullYear(),
|
||||||
|
summary: [...perCurrency.values()].map((c) => {
|
||||||
|
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||||
|
return {
|
||||||
currency: c.currency,
|
currency: c.currency,
|
||||||
|
/** Balance carried in from before this year — legacy's BALANCE FORWARD. */
|
||||||
|
opening: open.toFixed(2),
|
||||||
charges: c.charges.toFixed(2),
|
charges: c.charges.toFixed(2),
|
||||||
credits: c.credits.toFixed(2),
|
credits: c.credits.toFixed(2),
|
||||||
balance: c.charges.plus(c.credits).toFixed(2),
|
balance: open.plus(c.charges).plus(c.credits).toFixed(2),
|
||||||
chargeCount: c.chargeCount,
|
chargeCount: c.chargeCount,
|
||||||
creditCount: c.creditCount,
|
creditCount: c.creditCount,
|
||||||
count: c.count,
|
count: c.count,
|
||||||
firstMovement: c.first,
|
firstMovement: c.first,
|
||||||
lastMovement: c.last,
|
lastMovement: c.last,
|
||||||
})),
|
};
|
||||||
byDomain: [...perDomain.values()].map((d) => ({
|
}),
|
||||||
|
byDomain: [...perDomain.values()].map((d) => {
|
||||||
|
const open =
|
||||||
|
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
|
||||||
|
new Prisma.Decimal(0);
|
||||||
|
return {
|
||||||
domain: d.domain,
|
domain: d.domain,
|
||||||
currency: d.currency,
|
currency: d.currency,
|
||||||
|
opening: open.toFixed(2),
|
||||||
charges: d.charges.toFixed(2),
|
charges: d.charges.toFixed(2),
|
||||||
credits: d.credits.toFixed(2),
|
credits: d.credits.toFixed(2),
|
||||||
balance: d.charges.plus(d.credits).toFixed(2),
|
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
|
||||||
count: d.count,
|
count: d.count,
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
byType: [...byType.values()]
|
byType: [...byType.values()]
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -67,6 +67,55 @@ 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 */
|
||||||
@@ -163,7 +212,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) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,7 +400,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 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +415,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}`);
|
||||||
@@ -465,3 +528,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,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,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { BALANCE_FORWARD_TYPE } 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 +620,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 +713,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,8 +752,8 @@ 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",
|
||||||
@@ -850,13 +790,31 @@ 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 },
|
||||||
|
});
|
||||||
|
|
||||||
const rows = await prisma.transaction.findMany({
|
const rows = await prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
|
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
|
||||||
|
// drops every app-captured row (they have no legacySourceTable) — the
|
||||||
|
// same defect this report's on-screen twin was fixed for.
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
legacySourceTable: {
|
legacySourceTable: {
|
||||||
notIn: [
|
notIn: [
|
||||||
"EFECTIVO",
|
"EFECTIVO",
|
||||||
@@ -867,6 +825,8 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -883,12 +843,28 @@ 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.
|
||||||
|
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
const year = yearStart.getUTCFullYear();
|
||||||
|
|
||||||
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 +877,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 +942,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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
* The shipped implementation is self-hosted Tesseract (see tesseract.provider).
|
* The shipped implementation is self-hosted Tesseract (see tesseract.provider).
|
||||||
* That choice is evidence-based rather than assumed: run against 46 pages of
|
* 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
|
* real scanned CFE, CESPT and Telnor statements, it identified the provider on
|
||||||
* 46/46 and extracted a usable account reference on 43/46, which is well past
|
* 46/46 and extracted a usable account reference on 43/46, and on a later
|
||||||
* the bar for a queue whose whole point is that a human confirms every row. A
|
* 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
|
* managed document-extraction API (Textract, Document Intelligence, Document
|
||||||
* AI) fits behind this same interface if per-page accuracy ever proves
|
* AI) fits behind this same interface if per-page accuracy ever proves
|
||||||
* insufficient, with no schema change — but at 300+ pages/month/company it
|
* insufficient, with no schema change — but at 300+ pages/month/company it
|
||||||
@@ -31,10 +33,10 @@ export interface OcrPage {
|
|||||||
/** Full page text, reading order, newline-separated. */
|
/** Full page text, reading order, newline-separated. */
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
/**
|
||||||
* Word boxes. Needed because two of the three real layouts are *tables* —
|
* Word boxes. Needed because several of the real layouts are *tables* — the
|
||||||
* the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with the
|
* CESPT "RECIBO" prints `No. DE CUENTA` as a column header with the value in
|
||||||
* value in the row beneath it, which line-oriented text cannot associate.
|
* the row beneath it, which line-oriented text cannot associate. Parsers fall
|
||||||
* Parsers fall back to geometry for exactly those fields.
|
* back to geometry for exactly those fields.
|
||||||
*/
|
*/
|
||||||
words: OcrWord[];
|
words: OcrWord[];
|
||||||
/** Mean word confidence across the page, 0..1. */
|
/** Mean word confidence across the page, 0..1. */
|
||||||
@@ -48,6 +50,22 @@ export interface OcrProvider {
|
|||||||
renderPages(pdf: Buffer): Promise<Buffer[]>;
|
renderPages(pdf: Buffer): Promise<Buffer[]>;
|
||||||
/** OCR a single rendered page image. */
|
/** OCR a single rendered page image. */
|
||||||
recognize(pageImage: Buffer): Promise<OcrPage>;
|
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");
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,6 +105,37 @@ export class TesseractOcrProvider implements OcrProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `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> {
|
async recognize(pageImage: Buffer): Promise<OcrPage> {
|
||||||
await this.require();
|
await this.require();
|
||||||
return this.scratch(async (dir) => {
|
return this.scratch(async (dir) => {
|
||||||
@@ -136,6 +167,218 @@ export class TesseractOcrProvider implements OcrProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Turn tesseract's TSV into words plus reassembled text.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,11 @@ import type { OcrPage, OcrWord } from "../ocr/ocr.provider";
|
|||||||
* like with like and never has to know about provider-specific formatting.
|
* like with like and never has to know about provider-specific formatting.
|
||||||
*/
|
*/
|
||||||
export interface ParsedStatement {
|
export interface ParsedStatement {
|
||||||
/** "CFE" | "CESPT" | "TELNOR", or null when no parser claimed the page. */
|
/**
|
||||||
|
* "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;
|
provider: string | null;
|
||||||
serviceKind: ServiceKind | null;
|
serviceKind: ServiceKind | null;
|
||||||
accountRef: string | null;
|
accountRef: string | null;
|
||||||
@@ -90,6 +94,16 @@ function firstMatch(text: string, patterns: RegExp[]): string | null {
|
|||||||
return null;
|
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> = {
|
const MONTHS: Record<string, number> = {
|
||||||
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
||||||
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
||||||
@@ -104,15 +118,16 @@ export function parseDate(raw: string | null | undefined): Date | null {
|
|||||||
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||||
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
||||||
|
|
||||||
// 22-JUL-2026 / 22 JUN 26
|
// 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})$/);
|
m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[A-Z]*[-\s/](\d{2,4})$/);
|
||||||
if (m && MONTHS[m[2]] !== undefined) {
|
if (m && MONTHS[m[2]] !== undefined) {
|
||||||
const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
|
const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
|
||||||
return utc(y, MONTHS[m[2]], +m[1]);
|
return utc(y, MONTHS[m[2]], +m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2026-07-22 (already normalised, e.g. decoded from a barcode)
|
// 2026-07-22 (already normalised, e.g. decoded from a barcode) and the
|
||||||
m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
// 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]);
|
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -174,9 +189,44 @@ const BRAND: [string, RegExp][] = [
|
|||||||
["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
|
["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
|
||||||
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
|
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
|
||||||
["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/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][] = [
|
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],
|
["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],
|
["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],
|
["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i],
|
||||||
@@ -364,10 +414,442 @@ function parseTelnor(page: OcrPage): ParsedStatement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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> = {
|
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||||
CFE: parseCfe,
|
CFE: parseCfe,
|
||||||
CESPT: parseCespt,
|
CESPT: parseCespt,
|
||||||
TELNOR: parseTelnor,
|
TELNOR: parseTelnor,
|
||||||
|
"GAS TIJUANA": parseGas,
|
||||||
|
"PREDIAL TIJUANA": parsePredialTijuana,
|
||||||
|
"PREDIAL ROSARITO": parsePredialRosarito,
|
||||||
|
"PREDIAL ENSENADA": parsePredialEnsenada,
|
||||||
|
"ZONA FEDERAL TIJUANA": parseZonaFederal,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY: ParsedStatement = {
|
const EMPTY: ParsedStatement = {
|
||||||
|
|||||||
@@ -32,30 +32,53 @@ export interface MatchResult {
|
|||||||
* person. Names are displayed for the reviewer to sanity-check, and are never
|
* person. Names are displayed for the reviewer to sanity-check, and are never
|
||||||
* an input to matching.
|
* an input to matching.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
/**
|
||||||
export class StatementMatcherService {
|
* Which `PropertyService` column a given kind's statements actually print.
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
*
|
||||||
|
* Exported because the same answer governs three places that must agree: the
|
||||||
/** Which PropertyService column a given kind's statements actually print. */
|
* lookup here, the blank-service fill on review, and the write-back on confirm.
|
||||||
private fieldFor(kind: ServiceKind): "accountNumber" | "meterNumber" | null {
|
* 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) {
|
switch (kind) {
|
||||||
case "ELECTRIC": // CFE "NO. DE SERVICIO" -> DATMEX.rpu
|
case "ELECTRIC": // CFE "NO. DE SERVICIO" -> DATMEX.rpu
|
||||||
case "WATER": // CESPT "Cuenta" / "No. DE CUENTA" -> DATMEX.agua
|
case "WATER": // CESPT "Cuenta" / "No. DE CUENTA" -> DATMEX.agua
|
||||||
case "TELEPHONE": // Telnor "Teléfono" (LADA stripped) -> DATMEX.telefono
|
case "TELEPHONE": // Telnor "Teléfono" (LADA stripped) -> DATMEX.telefono
|
||||||
case "FEDERAL_ZONE":
|
|
||||||
case "CABLE":
|
case "CABLE":
|
||||||
return "accountNumber";
|
return "accountNumber";
|
||||||
case "GAS": // no account column in DATMEX; the number lived in notes
|
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";
|
return "meterNumber";
|
||||||
// PROPERTY_TAX deliberately has no scoped column: what its
|
|
||||||
// accountNumber holds is DATMEX.predial, which is neither unique nor
|
|
||||||
// printed on any statement. Predial bills match on the clave catastral
|
|
||||||
// alone — see matchByCadastralKey.
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StatementMatcherService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise<MatchResult> {
|
async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise<MatchResult> {
|
||||||
const kind = parsed.serviceKind ?? expectedKind;
|
const kind = parsed.serviceKind ?? expectedKind;
|
||||||
|
|
||||||
@@ -68,33 +91,39 @@ export class StatementMatcherService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = this.fieldFor(kind);
|
const field = scopedRefField(kind);
|
||||||
|
|
||||||
if (field && parsed.accountRef) {
|
if (field && parsed.accountRef) {
|
||||||
const hit = await this.byServiceField(kind, field, parsed.accountRef);
|
const hit = await this.byServiceField(kind, field, parsed.accountRef);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secondary key. The clave catastral is printed on CESPT bills as well as
|
// The clave catastral is printed on CESPT bills as well as predial ones, so
|
||||||
// predial ones, so it rescues a page whose account number did not OCR —
|
// it rescues a page whose account number did not OCR — which happened on
|
||||||
// which happened on real samples, where the clave read cleanly and the
|
// real samples, where the clave read cleanly and the account number did
|
||||||
// account number did not.
|
// 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) {
|
if (parsed.cadastralKey) {
|
||||||
const hit = await this.byCadastralKey(kind, parsed.cadastralKey);
|
const primary = kind === "PROPERTY_TAX" && !parsed.accountRef;
|
||||||
|
const hit = await this.byCadastralKey(kind, parsed.cadastralKey, primary);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!field && !parsed.cadastralKey) {
|
if (!field && !parsed.cadastralKey) {
|
||||||
|
return this.unmatched(`no hay campo de búsqueda definido para ${kind}`);
|
||||||
|
}
|
||||||
|
if (!parsed.accountRef && !parsed.cadastralKey) {
|
||||||
return this.unmatched(
|
return this.unmatched(
|
||||||
kind === "PROPERTY_TAX"
|
kind === "PROPERTY_TAX"
|
||||||
? "el predial sólo se puede identificar por clave catastral y no se leyó ninguna"
|
? "no se leyó ni la clave catastral ni la cuenta municipal"
|
||||||
: `no hay campo de búsqueda definido para ${kind}`,
|
: "no se pudo leer la referencia de la cuenta",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.unmatched(
|
return this.unmatched(
|
||||||
parsed.accountRef
|
parsed.accountRef
|
||||||
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
|
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
|
||||||
: "no se pudo leer la referencia de la cuenta",
|
: `no se encontró ninguna propiedad con la clave catastral ${parsed.cadastralKey}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +174,8 @@ export class StatementMatcherService {
|
|||||||
private async byCadastralKey(
|
private async byCadastralKey(
|
||||||
kind: ServiceKind,
|
kind: ServiceKind,
|
||||||
key: string,
|
key: string,
|
||||||
|
/** True when the clave is the identifier the statement was issued against. */
|
||||||
|
primary: boolean,
|
||||||
): Promise<MatchResult | null> {
|
): Promise<MatchResult | null> {
|
||||||
const props = await this.prisma.property.findMany({
|
const props = await this.prisma.property.findMany({
|
||||||
where: { cadastralKey: key },
|
where: { cadastralKey: key },
|
||||||
@@ -174,15 +205,21 @@ export class StatementMatcherService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The clave identifies the property with certainty, but it is a *secondary*
|
// When the clave is the *secondary* key — a utility bill that also happens
|
||||||
// key: it was not the number the statement was issued against. Left for
|
// to print it — the page is left for review, because the clave was not the
|
||||||
// review so the confirm also teaches the matcher the account number, rather
|
// number the statement was issued against and confirming is what teaches
|
||||||
// than the same page needing the fallback again next month.
|
// 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 {
|
return {
|
||||||
propertyServiceId: candidates[0].propertyServiceId ?? null,
|
propertyServiceId: candidates[0].propertyServiceId ?? null,
|
||||||
customerId: candidates[0].customerId,
|
customerId: candidates[0].customerId,
|
||||||
note: `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`,
|
note: primary
|
||||||
confident: false,
|
? `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,
|
candidates,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ export class StatementsController {
|
|||||||
return doc;
|
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 every matched document in the batch, against one check. */
|
||||||
@Post("batches/:id/confirm")
|
@Post("batches/:id/confirm")
|
||||||
@RequireAbility("statement:review")
|
@RequireAbility("statement:review")
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { BillingModule } from "../billing/billing.module";
|
import { BillingModule } from "../billing/billing.module";
|
||||||
|
import { OcrModule } from "../ocr/ocr.module";
|
||||||
import { StatementsController } from "./statements.controller";
|
import { StatementsController } from "./statements.controller";
|
||||||
import { StatementsService } from "./statements.service";
|
import { StatementsService } from "./statements.service";
|
||||||
import { StatementMatcherService } from "./statement-matcher.service";
|
import { StatementMatcherService } from "./statement-matcher.service";
|
||||||
import { OCR_PROVIDER } from "./ocr/ocr.provider";
|
|
||||||
import { TesseractOcrProvider } from "./ocr/tesseract.provider";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The concrete OCR engine is bound here and nowhere else — everything
|
* The concrete OCR engine is bound in OcrModule (see apps/api/src/ocr/) —
|
||||||
* downstream depends on the OcrProvider interface, so swapping Tesseract for a
|
* everything downstream depends on the OcrProvider interface, so swapping
|
||||||
* managed extraction API is a one-line change in this file.
|
* Tesseract for a managed extraction API is a one-line change there.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BillingModule],
|
imports: [BillingModule, OcrModule],
|
||||||
controllers: [StatementsController],
|
controllers: [StatementsController],
|
||||||
providers: [
|
providers: [StatementsService, StatementMatcherService],
|
||||||
StatementsService,
|
|
||||||
StatementMatcherService,
|
|
||||||
{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class StatementsModule {}
|
export class StatementsModule {}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { BillingService } from "../billing/billing.service";
|
|||||||
import type { UploadedFileLike } from "../storage/upload-file";
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||||
import { parseStatement } from "./parsers/statement-parser";
|
import { parseStatement } from "./parsers/statement-parser";
|
||||||
import { StatementMatcherService } from "./statement-matcher.service";
|
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
|
||||||
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,14 +127,23 @@ export class StatementsService {
|
|||||||
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
||||||
|
|
||||||
const pages = await this.ocr.renderPages(file.buffer);
|
const pages = await this.ocr.renderPages(file.buffer);
|
||||||
for (const image of pages) {
|
// 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;
|
pageNumber += 1;
|
||||||
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
||||||
await this.storage.put(storageKey, image, "image/png");
|
await this.storage.put(storageKey, image, "image/png");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ocr = await this.ocr.recognize(image);
|
const embedded = textLayer[index] ?? null;
|
||||||
|
const ocr = embedded ?? (await this.ocr.recognize(image));
|
||||||
const parsed = parseStatement(ocr);
|
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 match = await this.matcher.match(parsed, serviceKind);
|
||||||
|
|
||||||
const notes = [...parsed.notes, match.note].filter(Boolean);
|
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||||
@@ -295,8 +304,8 @@ export class StatementsService {
|
|||||||
where: { id: doc.batchId },
|
where: { id: doc.batchId },
|
||||||
select: { serviceKind: true },
|
select: { serviceKind: true },
|
||||||
});
|
});
|
||||||
if (batch) {
|
const field = batch && scopedRefField(batch.serviceKind);
|
||||||
const field = batch.serviceKind === "GAS" ? "meterNumber" : "accountNumber";
|
if (batch && field) {
|
||||||
const blank = await this.prisma.propertyService.findMany({
|
const blank = await this.prisma.propertyService.findMany({
|
||||||
where: {
|
where: {
|
||||||
kind: batch.serviceKind,
|
kind: batch.serviceKind,
|
||||||
@@ -333,10 +342,54 @@ export class StatementsService {
|
|||||||
if (doc.status === "POSTED") {
|
if (doc.status === "POSTED") {
|
||||||
throw new BadRequestException("Este documento ya fue registrado.");
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
}
|
}
|
||||||
return this.prisma.statementDocument.update({
|
const updated = await this.prisma.statementDocument.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
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 --------------------------------------------------------------
|
// --- posting --------------------------------------------------------------
|
||||||
@@ -434,7 +487,8 @@ export class StatementsService {
|
|||||||
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
|
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
|
||||||
kind: ServiceKind,
|
kind: ServiceKind,
|
||||||
) {
|
) {
|
||||||
const field = kind === "GAS" ? "meterNumber" : "accountNumber";
|
const field = scopedRefField(kind);
|
||||||
|
if (!field) return;
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
|
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
|
||||||
await this.prisma.propertyService.updateMany({
|
await this.prisma.propertyService.updateMany({
|
||||||
@@ -451,8 +505,10 @@ export class StatementsService {
|
|||||||
where: { batchId, status: { in: OPEN } },
|
where: { batchId, status: { in: OPEN } },
|
||||||
});
|
});
|
||||||
if (open === 0) {
|
if (open === 0) {
|
||||||
await this.prisma.statementBatch.update({
|
await this.prisma.statementBatch.updateMany({
|
||||||
where: { id: batchId },
|
// `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() },
|
data: { status: "COMPLETED", completedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.4",
|
"version": "1.0.23",
|
||||||
"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,
|
||||||
@@ -151,9 +152,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 +204,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
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ 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 the current year only and
|
||||||
|
* runs oldest-first, opening on the balance carried in from before it.
|
||||||
*/
|
*/
|
||||||
export default function EstadoCuentaDetailPage({
|
export default function EstadoCuentaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -194,7 +196,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={
|
||||||
@@ -260,7 +262,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 +284,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 +503,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,
|
||||||
|
|||||||
@@ -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
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
@@ -921,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;
|
||||||
@@ -935,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);
|
||||||
@@ -3109,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();
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { Notificaciones } from "@/components/Notificaciones";
|
||||||
|
|
||||||
|
export default function NotificacionesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Notificaciones initialTab="servicios" />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,18 +14,24 @@ import {
|
|||||||
deleteBackup,
|
deleteBackup,
|
||||||
deleteIngest,
|
deleteIngest,
|
||||||
getOpsJob,
|
getOpsJob,
|
||||||
|
getReplicationStatus,
|
||||||
listBackups,
|
listBackups,
|
||||||
listIngest,
|
listIngest,
|
||||||
listOpsJobs,
|
listOpsJobs,
|
||||||
startOpsJob,
|
startOpsJob,
|
||||||
uploadIngest,
|
uploadIngest,
|
||||||
|
verifyReplication,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type { UploadProgress } from "@/lib/api";
|
import type { UploadProgress } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
|
ApplyProgress,
|
||||||
BackupFile,
|
BackupFile,
|
||||||
IngestFile,
|
IngestFile,
|
||||||
OpsJob,
|
OpsJob,
|
||||||
OpsJobKind,
|
OpsJobKind,
|
||||||
|
GtidDrift,
|
||||||
|
ReplicationStatus,
|
||||||
|
VerifyResult,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
@@ -56,6 +62,7 @@ function Operaciones() {
|
|||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
const [confirmText, setConfirmText] = useState("");
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [forceFull, setForceFull] = useState(false);
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
@@ -156,12 +163,12 @@ function Operaciones() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(kind: OpsJobKind, file?: string) {
|
async function start(kind: OpsJobKind, file?: string, force?: boolean) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
try {
|
try {
|
||||||
const job = await startOpsJob(kind, file);
|
const job = await startOpsJob(kind, file, force);
|
||||||
setActiveJob(job);
|
setActiveJob(job);
|
||||||
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -174,6 +181,9 @@ function Operaciones() {
|
|||||||
function askConfirm(state: ConfirmState) {
|
function askConfirm(state: ConfirmState) {
|
||||||
setConfirm(state);
|
setConfirm(state);
|
||||||
setConfirmText("");
|
setConfirmText("");
|
||||||
|
// Always re-armed: ticking "delete native rows" once must not carry into
|
||||||
|
// the next reimport.
|
||||||
|
setForceFull(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
}
|
}
|
||||||
@@ -182,7 +192,7 @@ function Operaciones() {
|
|||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
const c = confirm;
|
const c = confirm;
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
if (c.kind === "REIMPORT") await start("REIMPORT");
|
if (c.kind === "REIMPORT") await start("REIMPORT", undefined, forceFull);
|
||||||
else if (c.kind === "SYNC") await start("SYNC");
|
else if (c.kind === "SYNC") await start("SYNC");
|
||||||
else await start("RESTORE", c.file);
|
else await start("RESTORE", c.file);
|
||||||
}
|
}
|
||||||
@@ -223,10 +233,13 @@ function Operaciones() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<JobProgressBar job={activeJob} />
|
||||||
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
<pre className="ops-log">{activeJob.log || "Iniciando…"}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ReplicationCard />
|
||||||
|
|
||||||
{/* Ingest folder */}
|
{/* Ingest folder */}
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||||
@@ -472,11 +485,24 @@ function Operaciones() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="inline-form-note">
|
<p className="inline-form-note">
|
||||||
{confirm.kind === "REIMPORT"
|
{confirm.kind === "REIMPORT"
|
||||||
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
|
? "Esto BORRA todos los datos actuales y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático. Si la base contiene registros que sólo existen en la plataforma (clientes creados aquí, números de portal asignados, pólizas capturadas por OCR, movimientos capturados), la operación se detiene y los enumera sin tocar nada."
|
||||||
: confirm.kind === "SYNC"
|
: confirm.kind === "SYNC"
|
||||||
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
||||||
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||||
</p>
|
</p>
|
||||||
|
{confirm.kind === "REIMPORT" && (
|
||||||
|
<label className="inline-form-note" style={{ display: "block" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceFull}
|
||||||
|
onChange={(e) => setForceFull(e.target.checked)}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
Borrar también los registros que sólo existen en la plataforma
|
||||||
|
(ignorar la verificación). Sólo marque esto si de verdad quiere
|
||||||
|
perderlos.
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||||
<input
|
<input
|
||||||
@@ -596,3 +622,363 @@ function OpTile({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health of the read replica behind my.jorgecuadros.com.
|
||||||
|
*
|
||||||
|
* Worth a panel because the failure mode is silent: a replica whose SQL thread
|
||||||
|
* has stopped keeps answering queries, just with data frozen at the moment it
|
||||||
|
* stopped. Nothing on the customer site looks wrong — the balances are simply
|
||||||
|
* out of date — so without this the only signal is a customer complaining.
|
||||||
|
*/
|
||||||
|
function ReplicationCard() {
|
||||||
|
const [status, setStatus] = useState<ReplicationStatus | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [verify, setVerify] = useState<VerifyResult | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyError, setVerifyError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
getReplicationStatus()
|
||||||
|
.then((s) => {
|
||||||
|
setStatus(s);
|
||||||
|
setFailed(false);
|
||||||
|
})
|
||||||
|
.catch(() => setFailed(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runVerify = useCallback(() => {
|
||||||
|
setVerifying(true);
|
||||||
|
setVerifyError(null);
|
||||||
|
verifyReplication()
|
||||||
|
.then(setVerify)
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
setVerifyError(e instanceof Error ? e.message : "No se pudo comparar."),
|
||||||
|
)
|
||||||
|
.finally(() => setVerifying(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Not configured is the normal state in dev and before cutover, so it is a
|
||||||
|
// quiet note rather than an alarm — showing red here would train people to
|
||||||
|
// ignore the card.
|
||||||
|
if (failed || (status && !status.configured)) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Réplica del sitio de clientes</h2>
|
||||||
|
<p className="inline-form-note">
|
||||||
|
{failed
|
||||||
|
? "No se pudo consultar el estado de la réplica."
|
||||||
|
: "No configurada en este entorno."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<h2 className="section-title">Réplica del sitio de clientes</h2>
|
||||||
|
<p className="inline-form-note">Consultando…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
|
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Réplica del sitio de clientes{" "}
|
||||||
|
<span className={`badge ${status.healthy ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{status.healthy ? "Replicando" : "Detenida"}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={load}>
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.problem && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{status.problem}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="kv-grid" style={{ paddingLeft: 0, paddingRight: 0 }}>
|
||||||
|
<KV label="Servidor" value={status.host} />
|
||||||
|
<KV label="Origen" value={status.sourceHost} />
|
||||||
|
<KV label="Hilo de E/S" value={status.ioRunning} />
|
||||||
|
<KV label="Hilo SQL" value={status.sqlRunning} />
|
||||||
|
{/* Never render a null lag as "0 s": MySQL reports NULL whenever a
|
||||||
|
thread is down, so the honest word is "unknown", not "up to date". */}
|
||||||
|
<KV
|
||||||
|
label="Retraso"
|
||||||
|
value={status.secondsBehind === null ? "sin dato" : `${status.secondsBehind} s`}
|
||||||
|
/>
|
||||||
|
<KV label="Pendiente de aplicar" value={backlogLabel(status.apply)} />
|
||||||
|
<KV label="Diferencia con el maestro" value={driftLabel(status.drift)} />
|
||||||
|
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApplyProgressBar apply={status.apply} />
|
||||||
|
|
||||||
|
{/* Only worth showing when it is not zero, and even then as a note rather
|
||||||
|
than a warning: these are the seed load's own transactions, and they
|
||||||
|
are inert until someone tries to promote this box. */}
|
||||||
|
{status.drift !== null && status.drift.localTransactions > 0 && (
|
||||||
|
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||||
|
La réplica tiene {status.drift.localTransactions.toLocaleString("es-MX")} transacciones
|
||||||
|
propias (de la carga inicial). No se propagan y no afectan la lectura; sólo importarían
|
||||||
|
si este servidor pasara a ser maestro.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VerifyPanel
|
||||||
|
result={verify}
|
||||||
|
running={verifying}
|
||||||
|
error={verifyError}
|
||||||
|
onRun={runVerify}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row-by-row comparison against the master, on demand.
|
||||||
|
*
|
||||||
|
* Separate from the polled fields because it costs a full scan of both servers.
|
||||||
|
* It is the only check here that can catch a row changed on the replica by
|
||||||
|
* something other than replication — the GTID and lag figures would both still
|
||||||
|
* read perfectly healthy in that case.
|
||||||
|
*/
|
||||||
|
function VerifyPanel({
|
||||||
|
result,
|
||||||
|
running,
|
||||||
|
error,
|
||||||
|
onRun,
|
||||||
|
}: {
|
||||||
|
result: VerifyResult | null;
|
||||||
|
running: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRun: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
|
||||||
|
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||||
|
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||||
|
Compara fila por fila las 8 tablas que lee el sitio de clientes. Recorre ambos
|
||||||
|
servidores por completo, así que tarda.
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={onRun} disabled={running}>
|
||||||
|
{running ? "Comparando…" : "Comparar con el maestro"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.problem && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{result.problem}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !result.problem && (
|
||||||
|
<>
|
||||||
|
<p style={{ marginTop: 12, marginBottom: 8 }}>
|
||||||
|
<span className={`badge ${result.identical ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{result.identical ? "Idénticas" : "Hay diferencias"}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="inline-form-note">
|
||||||
|
{formatDateTime(result.checkedAt)} · {(result.elapsedMs / 1000).toFixed(1)} s
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tabla</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Maestro</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Réplica</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.tables.map((t) => (
|
||||||
|
<tr key={t.table}>
|
||||||
|
<td>{t.table}</td>
|
||||||
|
{/* -1 is the sentinel for "that server did not answer for
|
||||||
|
this table", which is not the same as zero rows. */}
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
{t.masterRows < 0 ? "—" : t.masterRows.toLocaleString("es-MX")}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
{t.replicaRows < 0 ? "—" : t.replicaRows.toLocaleString("es-MX")}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${t.matches ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{t.matches
|
||||||
|
? "igual"
|
||||||
|
: t.masterRows !== t.replicaRows
|
||||||
|
? "difieren en filas"
|
||||||
|
: "difieren en contenido"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions the master has executed that the replica has not.
|
||||||
|
*
|
||||||
|
* Rendered as its own line rather than folded into the lag figure because the
|
||||||
|
* two disagree in exactly the case that matters: a disconnected I/O thread
|
||||||
|
* reports 0 seconds of lag (no event has arrived to be late) while this number
|
||||||
|
* climbs.
|
||||||
|
*/
|
||||||
|
function driftLabel(drift: GtidDrift | null): string {
|
||||||
|
// Null means the master could not be reached. Saying "al día" here would be a
|
||||||
|
// lie of the worst kind — it is the reading a broken check produces.
|
||||||
|
if (drift === null) return "sin dato";
|
||||||
|
if (drift.missingTransactions === 0) return "al día";
|
||||||
|
return `${drift.missingTransactions.toLocaleString("es-MX")} transacciones atrás`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes the replica has fetched but not yet applied.
|
||||||
|
*
|
||||||
|
* Kept separate from the lag figure because it answers a question the lag
|
||||||
|
* cannot: while the SQL thread chews through one big transaction, the seconds
|
||||||
|
* counter can hold still, but this number visibly falls.
|
||||||
|
*/
|
||||||
|
function backlogLabel(apply: ApplyProgress | null): string {
|
||||||
|
if (!apply) return "sin dato";
|
||||||
|
// Different source binlog files means the replica is whole files behind and
|
||||||
|
// the byte delta is not a delta at all — positions restart in each new file.
|
||||||
|
if (!apply.sameFile) return "más de un archivo de binlog";
|
||||||
|
if (apply.backlogBytes === 0) return "al día";
|
||||||
|
return formatBytes(apply.backlogBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applied-vs-fetched bar. Rendered only when both threads are on the same
|
||||||
|
* source binlog file, because that is the only case where the percentage is
|
||||||
|
* arithmetic rather than a guess.
|
||||||
|
*/
|
||||||
|
function ApplyProgressBar({ apply }: { apply: ApplyProgress | null }) {
|
||||||
|
if (!apply || !apply.sameFile || apply.percent === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="upload-progress" style={{ marginTop: 12 }}>
|
||||||
|
<div
|
||||||
|
className="progress-track"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={apply.percent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label="Eventos aplicados de los recibidos"
|
||||||
|
>
|
||||||
|
<div className="progress-fill" style={{ width: `${apply.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="upload-progress-stats mono">
|
||||||
|
<span>{apply.percent}% aplicado</span>
|
||||||
|
<span>
|
||||||
|
{apply.sourceLogFile} · {apply.execPos.toLocaleString("es-MX")} /{" "}
|
||||||
|
{apply.readPos.toLocaleString("es-MX")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches the KV in the clientes/polizas/servicios detail pages. */
|
||||||
|
function KV({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="kv-label">{label}</div>
|
||||||
|
<div className="kv-value">{value || "—"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step progress for a running migration.
|
||||||
|
*
|
||||||
|
* Only REIMPORT and SYNC report steps; BACKUP and RESTORE are a single
|
||||||
|
* mysqldump, so they render nothing here rather than a made-up bar — the
|
||||||
|
* spinner in the heading already says "working".
|
||||||
|
*
|
||||||
|
* The safety backup runs before the migration, so `progress` is null for the
|
||||||
|
* first stretch of every REIMPORT. That phase is named explicitly instead of
|
||||||
|
* showing 0%, which would read as "stuck".
|
||||||
|
*/
|
||||||
|
function JobProgressBar({ job }: { job: OpsJob }) {
|
||||||
|
const running = job.status === "RUNNING";
|
||||||
|
const p = job.progress;
|
||||||
|
|
||||||
|
if (!p) {
|
||||||
|
if (!running) return null;
|
||||||
|
return (
|
||||||
|
<p className="inline-form-note" style={{ marginTop: 8 }}>
|
||||||
|
Respaldo de seguridad previo…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 10, marginBottom: 4 }}>
|
||||||
|
<div
|
||||||
|
className="row-actions"
|
||||||
|
style={{ justifyContent: "space-between", marginBottom: 6 }}
|
||||||
|
>
|
||||||
|
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||||
|
Paso {p.step} de {p.total} — {p.name}
|
||||||
|
</span>
|
||||||
|
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||||
|
{p.percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={p.percent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`Paso ${p.step} de ${p.total}`}
|
||||||
|
style={{
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: "var(--line)",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${p.percent}%`,
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 999,
|
||||||
|
transition: "width 400ms ease",
|
||||||
|
background:
|
||||||
|
job.status === "FAILED"
|
||||||
|
? "var(--negative)"
|
||||||
|
: "var(--positive)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ import {
|
|||||||
premiumHeadline,
|
premiumHeadline,
|
||||||
SIN_NOMBRE,
|
SIN_NOMBRE,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
import {
|
||||||
|
PAYMENT_FREQUENCY_LABELS,
|
||||||
|
type AdjusterRow,
|
||||||
|
type Installment,
|
||||||
|
type PolicyDetail,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { formatRate } from "@/lib/premium";
|
||||||
|
|
||||||
export default function PolizaDetailPage({
|
export default function PolizaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -186,6 +192,10 @@ function ChildrenEditor({
|
|||||||
const INSTALLMENTS: ChildConfig = {
|
const INSTALLMENTS: ChildConfig = {
|
||||||
apiKind: "installments",
|
apiKind: "installments",
|
||||||
title: "Pagos",
|
title: "Pagos",
|
||||||
|
// A policy paid in several exhibiciones prices each payment on its own, so
|
||||||
|
// the whole premium breakdown repeats per row — that is the two-row money
|
||||||
|
// block on the Access form. `amount` stays what was actually collected and
|
||||||
|
// is deliberately separate from `total`; they differ by rounding.
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "sequence", label: "Sec.", type: "number" },
|
{ key: "sequence", label: "Sec.", type: "number" },
|
||||||
{ key: "amount", label: "Monto", type: "number" },
|
{ key: "amount", label: "Monto", type: "number" },
|
||||||
@@ -195,6 +205,12 @@ function ChildrenEditor({
|
|||||||
{ key: "paidDate", label: "Pagado", type: "date" },
|
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||||
{ key: "checkNumber", label: "Cheque" },
|
{ key: "checkNumber", label: "Cheque" },
|
||||||
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||||
|
{ key: "netPremium", label: "Prima neta", type: "number" },
|
||||||
|
{ key: "surcharge", label: "Recargo", type: "number" },
|
||||||
|
{ key: "policyFee", label: "Derecho", type: "number" },
|
||||||
|
{ key: "tax", label: "IVA", type: "number" },
|
||||||
|
{ key: "total", label: "Prima total", type: "number" },
|
||||||
|
{ key: "commission", label: "Comisión", type: "number" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const VEHICLES: ChildConfig = {
|
const VEHICLES: ChildConfig = {
|
||||||
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
|
|||||||
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<KV
|
||||||
|
label="Forma de pago"
|
||||||
|
value={
|
||||||
|
data.paymentFrequency
|
||||||
|
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||||
|
{/* Only ever set on a policy paid in installments, so showing an
|
||||||
|
empty row on the other 98% would be noise. */}
|
||||||
|
{data.surcharge != null && Number(data.surcharge) !== 0 && (
|
||||||
|
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
|
||||||
|
)}
|
||||||
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
{/* Access never stored IVA — it was a calculated control on the form
|
||||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
— so every migrated policy reads null here until it is edited. */}
|
||||||
|
{data.tax != null && (
|
||||||
|
<KV
|
||||||
|
label={
|
||||||
|
data.taxRate != null
|
||||||
|
? `IVA (${formatRate(Number(data.taxRate))})`
|
||||||
|
: "IVA"
|
||||||
|
}
|
||||||
|
value={formatMoney(data.tax, cur)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||||
only show it when it actually carries a figure. */}
|
only show it when it actually carries a figure. */}
|
||||||
{data.total != null && Number(data.total) > 0 && (
|
{data.total != null && Number(data.total) > 0 && (
|
||||||
<KV label="Total" value={formatMoney(data.total, cur)} />
|
<KV label="Prima total" value={formatMoney(data.total, cur)} />
|
||||||
)}
|
)}
|
||||||
|
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||||
|
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||||
<KV
|
<KV
|
||||||
label="Liquidación"
|
label="Liquidación"
|
||||||
value={
|
value={
|
||||||
@@ -648,10 +689,79 @@ function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------- Coberturas */
|
/* -------------------------------------------------------- Coberturas */
|
||||||
/** The legacy tables carry per-line coverage columns the target schema does
|
/**
|
||||||
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
* `coveragesJson` holds two unrelated shapes and the section renders each on
|
||||||
|
* its own terms:
|
||||||
|
*
|
||||||
|
* - **A Spanish-keyed object** — the legacy per-line coverage columns the
|
||||||
|
* target schema does not model, preserved verbatim by the migration. Every
|
||||||
|
* policy imported from Access carries this one.
|
||||||
|
* - **A `ParsedCoverage[]` array** — written by the policy OCR confirm step
|
||||||
|
* (GMX's coverage table, ANA's numbered risk sections).
|
||||||
|
*
|
||||||
|
* Running the object renderer over the array is what used to happen, and it
|
||||||
|
* produced a row per array index labelled "0", "1", "2" with `[object
|
||||||
|
* Object]` as its value — not a crash, so nothing surfaced it.
|
||||||
|
*/
|
||||||
|
interface StoredCoverage {
|
||||||
|
risk?: string;
|
||||||
|
insuredAmount?: number | null;
|
||||||
|
deductible?: string | null;
|
||||||
|
lossParticipation?: string | null;
|
||||||
|
premium?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||||
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
const raw = data.coveragesJson ?? null;
|
||||||
|
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const rows = (raw as StoredCoverage[]).filter((c) => c && c.risk);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Coberturas" count={rows.length} />
|
||||||
|
<div className="card">
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Riesgo</th>
|
||||||
|
<th className="num">Suma asegurada</th>
|
||||||
|
<th className="num">Prima</th>
|
||||||
|
<th>Deducible</th>
|
||||||
|
<th>Participación</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((c, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{c.risk}</td>
|
||||||
|
<td className="num">
|
||||||
|
{c.insuredAmount == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.insuredAmount.toString(), data.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{c.premium == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.premium.toString(), data.currency)}
|
||||||
|
</td>
|
||||||
|
<td>{c.deductible ?? "—"}</td>
|
||||||
|
<td>{c.lossParticipation ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||||
|
Coberturas leídas del PDF de la aseguradora.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(raw ?? {}).filter(
|
||||||
([, v]) => v !== null && v !== "" && v !== 0,
|
([, v]) => v !== null && v !== "" && v !== 0,
|
||||||
);
|
);
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PolicyOcrReview } from "@/components/PolicyOcrReview";
|
||||||
|
|
||||||
|
export default function PolicyOcrBatchPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<PolicyOcrReview id={params.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCR mode of the policy intake screen. Drops the GMX or A.N.A. PDF, walks through
|
||||||
|
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
||||||
|
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
||||||
|
* modes doesn't drop state.
|
||||||
|
*
|
||||||
|
* Sister route `/polizas/captura/[id]` is the batch review screen once a
|
||||||
|
* batch is uploaded.
|
||||||
|
*/
|
||||||
|
export default function CapturaOcrPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<PolicyCaptura initialMode="auto" />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,41 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useSearchParams } from "next/navigation";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { PolicyForm } from "@/components/PolicyForm";
|
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||||
import { useCan } from "@/lib/abilities";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual mode of the policy intake screen. Shares the tab wrapper with
|
||||||
|
* `/polizas/captura` (OCR mode) so staff can swap between the two without
|
||||||
|
* losing their place. Customer picker comes from the `?customerId=`
|
||||||
|
* / `?customerName=` query string — used by `/clientes/[id]` when staff
|
||||||
|
* creates a policy from a customer detail page.
|
||||||
|
*/
|
||||||
export default function NuevaPolizaPage() {
|
export default function NuevaPolizaPage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<NuevaPoliza />
|
<PolicyCaptura initialMode="manual" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NuevaPoliza() {
|
|
||||||
const allowed = useCan("policy:create");
|
|
||||||
const params = useSearchParams();
|
|
||||||
const customerId = params.get("customerId") ?? undefined;
|
|
||||||
const customerName = params.get("customerName") ?? undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-head">
|
|
||||||
<Link href="/polizas" className="back-link">← Pólizas</Link>
|
|
||||||
<h1 className="page-title">Nueva póliza</h1>
|
|
||||||
</div>
|
|
||||||
{allowed ? (
|
|
||||||
<PolicyForm fixedCustomerId={customerId} fixedCustomerName={customerName} />
|
|
||||||
) : (
|
|
||||||
<div className="state-box state-error">
|
|
||||||
No tiene permisos para crear pólizas.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export default function PolizasPage() {
|
|||||||
|
|
||||||
function PolizasBrowser() {
|
function PolizasBrowser() {
|
||||||
const canCreate = useCan("policy:create");
|
const canCreate = useCan("policy:create");
|
||||||
|
const canIngest = useCan("policy:ingest");
|
||||||
const [stats, setStats] = useState<PolicyStats | null>(null);
|
const [stats, setStats] = useState<PolicyStats | null>(null);
|
||||||
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
const [facets, setFacets] = useState<PolicyFacets | null>(null);
|
||||||
|
|
||||||
@@ -135,6 +136,11 @@ function PolizasBrowser() {
|
|||||||
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
|
{ slug: "vigente", label: "Por vencer (Incen.)", params: { typeName: "INCEN" } },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
{canIngest && (
|
||||||
|
<Link href="/polizas/captura" className="btn btn-outline">
|
||||||
|
+ Captura OCR
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
<Link href="/polizas/nuevo" className="btn btn-primary">+ Nueva póliza</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||||
import {
|
import {
|
||||||
confirmStatementBatch,
|
confirmStatementBatch,
|
||||||
|
discardStatementBatch,
|
||||||
getStatementBatch,
|
getStatementBatch,
|
||||||
listStatementDocuments,
|
listStatementDocuments,
|
||||||
rejectStatementDocument,
|
rejectStatementDocument,
|
||||||
@@ -66,6 +68,7 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [discarding, setDiscarding] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -108,9 +111,31 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
async function discard() {
|
||||||
|
setDiscarding(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await discardStatementBatch(id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo descartar el lote.");
|
||||||
|
} finally {
|
||||||
|
setDiscarding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="state-box">Cargando…</div>;
|
if (loading) return <div className="state-box">Cargando…</div>;
|
||||||
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||||
|
|
||||||
|
const postedCount = batch.byStatus.POSTED ?? 0;
|
||||||
|
// Discarding is only offered while the batch can still be abandoned whole:
|
||||||
|
// nothing posted to the ledger yet, and not already settled.
|
||||||
|
const canDiscard =
|
||||||
|
canReview &&
|
||||||
|
batch.status !== "DISCARDED" &&
|
||||||
|
batch.status !== "COMPLETED" &&
|
||||||
|
postedCount === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<header className="page-head">
|
<header className="page-head">
|
||||||
@@ -132,9 +157,7 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
{error && <div className="state-box state-error">{error}</div>}
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
{processing && (
|
{processing && (
|
||||||
<div className="state-box">
|
<ProcessingBanner docsLength={docs.length} pendingOcr={batch.byStatus.PENDING_OCR ?? 0} />
|
||||||
Leyendo los recibos… esta pantalla se actualiza sola.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||||
@@ -148,6 +171,15 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canDiscard && (
|
||||||
|
<DiscardBatchCard
|
||||||
|
busy={discarding}
|
||||||
|
onDiscard={discard}
|
||||||
|
pageCount={docs.length}
|
||||||
|
what="recibo"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="stack">
|
<section className="stack">
|
||||||
{sorted.map((doc) => (
|
{sorted.map((doc) => (
|
||||||
<DocumentRow
|
<DocumentRow
|
||||||
@@ -168,8 +200,58 @@ const STATUS_LABEL_BATCH: Record<string, string> = {
|
|||||||
READY_FOR_REVIEW: "Listo para revisar",
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
COMPLETED: "Registrado",
|
COMPLETED: "Registrado",
|
||||||
FAILED: "Falló",
|
FAILED: "Falló",
|
||||||
|
DISCARDED: "Descartado",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live readout while OCR is running. The backend tells us how many pages are
|
||||||
|
* still PENDING_OCR, so we can show real progress instead of "loading…". When
|
||||||
|
* the docs list hasn't caught up to the upload yet (total === 0) we fall back
|
||||||
|
* to the indeterminate bar.
|
||||||
|
*/
|
||||||
|
function ProcessingBanner({
|
||||||
|
docsLength,
|
||||||
|
pendingOcr,
|
||||||
|
}: {
|
||||||
|
docsLength: number;
|
||||||
|
pendingOcr: number;
|
||||||
|
}) {
|
||||||
|
const done = Math.max(docsLength - pendingOcr, 0);
|
||||||
|
const pct =
|
||||||
|
docsLength > 0 ? Math.min(100, Math.round((done / docsLength) * 100)) : null;
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<div className="upload-progress" style={{ padding: 0 }}>
|
||||||
|
<div
|
||||||
|
className={`progress-track${pct === null ? " progress-indeterminate" : ""}`}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-valuenow={pct ?? undefined}
|
||||||
|
>
|
||||||
|
<div className="progress-fill" style={{ width: `${pct ?? 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="upload-progress-stats">
|
||||||
|
{pct === null ? (
|
||||||
|
<span>Leyendo los recibos…</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>{pct}%</strong>
|
||||||
|
<span>
|
||||||
|
{done} de {docsLength} página(s) leídas
|
||||||
|
</span>
|
||||||
|
{pendingOcr > 0 && <span>{pendingOcr} en cola</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span style={{ marginLeft: "auto" }}>
|
||||||
|
Esta pantalla se actualiza sola.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryCard({
|
function SummaryCard({
|
||||||
batch,
|
batch,
|
||||||
readyCount,
|
readyCount,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { Notificaciones } from "@/components/Notificaciones";
|
||||||
|
|
||||||
|
/** Renewal notices moved into /notificaciones as its "Pólizas" tab. This route
|
||||||
|
* stays so old bookmarks and links land on that tab instead of a 404. */
|
||||||
|
export default function RenovacionesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Notificaciones initialTab="polizas" />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
getNotificationAdminEmails,
|
||||||
|
setNotificationAdminEmails,
|
||||||
|
type NotificationAdminEmails,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { formatDateTime } from "@/lib/labels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who receives the per-job summary email.
|
||||||
|
*
|
||||||
|
* This used to be NOTIFICATION_ADMIN_EMAILS in the deployment environment,
|
||||||
|
* which made "add Beto to the summaries" a redeploy. It is now a stored
|
||||||
|
* setting; the env var still acts as the fallback until someone saves here,
|
||||||
|
* so nothing changes for a deployment that never touches this screen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SOURCE_NOTE: Record<NotificationAdminEmails["source"], string> = {
|
||||||
|
db: "Guardado desde esta pantalla.",
|
||||||
|
env: "Viene de la configuración del despliegue (NOTIFICATION_ADMIN_EMAILS). Al guardar aquí, este valor toma precedencia.",
|
||||||
|
default: "Nadie lo ha configurado; se están usando los valores por omisión.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminEmailsSetting() {
|
||||||
|
const canEdit = useCan("setting:manage");
|
||||||
|
|
||||||
|
const [setting, setSetting] = useState<NotificationAdminEmails | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await getNotificationAdminEmails();
|
||||||
|
setSetting(data);
|
||||||
|
setDraft(data.value.join(", "));
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
const emails = draft
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const data = await setNotificationAdminEmails(emails);
|
||||||
|
setSetting(data);
|
||||||
|
setDraft(data.value.join(", "));
|
||||||
|
setEditing(false);
|
||||||
|
setSaved(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setDraft(setting?.value.join(", ") ?? "");
|
||||||
|
setEditing(false);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
return (
|
||||||
|
<section className="card" style={{ padding: 20 }}>
|
||||||
|
<h2 className="section-title">Destinatarios del resumen</h2>
|
||||||
|
{error ? (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted small" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
|
Cargando…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card" style={{ padding: 20 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: 12,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Destinatarios del resumen</h2>
|
||||||
|
<p className="muted small" style={{ marginTop: 4, marginBottom: 0, maxWidth: 620 }}>
|
||||||
|
Después de cada envío se manda un correo interno con el resultado
|
||||||
|
(enviados, omitidos, fallidos). Estas son las direcciones que lo
|
||||||
|
reciben. No afecta a los correos que reciben los clientes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && !editing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<label className="field" style={{ marginBottom: 8 }}>
|
||||||
|
<span className="field-label">
|
||||||
|
Correos separados por coma (vacío = no enviar resumen a nadie)
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
disabled={saving}
|
||||||
|
placeholder="alguien@ejemplo.com, otro@ejemplo.com"
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? "Guardando…" : "Guardar"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={cancel}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
{setting.value.length === 0 ? (
|
||||||
|
<span className="empty-inline">
|
||||||
|
Nadie recibe el resumen de los envíos.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
|
{setting.value.map((email) => (
|
||||||
|
<li key={email} className="mono">
|
||||||
|
{email}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<p className="section-note" style={{ marginTop: 10, marginBottom: 0 }}>
|
||||||
|
{SOURCE_NOTE[setting.source]}
|
||||||
|
{setting.updatedAt &&
|
||||||
|
` Última edición: ${formatDateTime(setting.updatedAt)}.`}
|
||||||
|
{saved && " Guardado."}
|
||||||
|
</p>
|
||||||
|
{!canEdit && (
|
||||||
|
<p className="section-note" style={{ marginTop: 6, marginBottom: 0 }}>
|
||||||
|
Solo un ADMIN puede cambiar esta lista.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user