Compare commits
19
Commits
7df928c3ab
..
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b4aab7ec | ||
|
|
a8afd87c3f | ||
|
|
b59abda895 | ||
|
|
4d5008b545 | ||
|
|
121952fdc1 | ||
|
|
15f533b984 | ||
|
|
db2bd545a1 | ||
|
|
30dfc7dc3e | ||
|
|
d5ebb86cae | ||
|
|
19f03198d6 | ||
|
|
b2cdcbe2cd | ||
|
|
7e3b530174 | ||
|
|
1cba9bfc32 | ||
|
|
3ff56e6b72 | ||
|
|
27f04f1073 | ||
|
|
4ee7ec71f0 | ||
|
|
9ba5d2d09a | ||
|
|
c100dfa224 | ||
|
|
0bf97e6d2c |
@@ -4,6 +4,23 @@ SESSION_SECRET=change-me-to-a-random-string
|
|||||||
WEB_ORIGIN=http://localhost:3000
|
WEB_ORIGIN=http://localhost:3000
|
||||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
||||||
|
|
||||||
|
# Object storage (MinIO / S3) for document blobs and scanned receipt pages.
|
||||||
|
# Without S3_ENDPOINT + credentials the API still boots, but every document
|
||||||
|
# upload/download and the whole recibo OCR intake are disabled. Credentials fall
|
||||||
|
# back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD when the S3_* pair is unset.
|
||||||
|
S3_ENDPOINT=http://localhost:9000
|
||||||
|
S3_BUCKET=jorgecuadros-documents
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
|
||||||
|
# Login the "Operaciones" screen runs mysqldump/mysql as. Optional locally: when
|
||||||
|
# unset it falls back to the DATABASE_URL credentials, which a dev MySQL usually
|
||||||
|
# grants enough for. Required in any deployment, where the application user has
|
||||||
|
# only ALL ON jorgecuadros.* and mysqldump --single-transaction needs the global
|
||||||
|
# RELOAD privilege. Host/port/database always come from DATABASE_URL.
|
||||||
|
OPS_DB_ADMIN_USER=
|
||||||
|
OPS_DB_ADMIN_PASSWORD=
|
||||||
|
|
||||||
# Company info — printed in the header of every report (PDF + browser
|
# Company info — printed in the header of every report (PDF + browser
|
||||||
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
|
# print). Leave blank to use the placeholders. COMPANY_LOGO_PATH is
|
||||||
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
|
# optional; when unset the API falls back to apps/api/assets/company_logo.png.
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# Manual PROD deploy to galactus — the office server, Portainer endpoint 3.
|
||||||
|
#
|
||||||
|
# galactus is STANDALONE Docker (`swarm: inactive`), so this workflow applies
|
||||||
|
# the compose files under deploy/galactus/, NOT the Swarm files in deploy/.
|
||||||
|
# .gitea/workflows/deploy.yml is the cubex/Swarm equivalent; the two are kept
|
||||||
|
# separate on purpose because plain compose silently ignores Swarm's `deploy:`
|
||||||
|
# keys rather than failing on them.
|
||||||
|
#
|
||||||
|
# This does NOT build. build.yml already built + pushed both images from one
|
||||||
|
# matrix run, so api and web at the same tag are always in step.
|
||||||
|
#
|
||||||
|
# Order of operations, and why:
|
||||||
|
# 1. db + minio (scope=full only) — the API depends on both.
|
||||||
|
# 2. pre-migrate backup dumped INSIDE the still-running OLD api container,
|
||||||
|
# so the file lands in the volume the Operaciones
|
||||||
|
# restore screen reads. Must precede the migration.
|
||||||
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
||||||
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
||||||
|
# the rule, the backup is the emergency lever.
|
||||||
|
# 4. app (api + web) the new images.
|
||||||
|
# 5. verify ask the running API what it actually is.
|
||||||
|
#
|
||||||
|
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
||||||
|
# schema stays forward. This is exactly why every schema change must be
|
||||||
|
# backward-compatible with the previous release.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - Gitea repo secrets, galactus-specific (suffix _GALACTUS so the cubex
|
||||||
|
# secrets keep working side by side):
|
||||||
|
# PORTAINER_URL_GALACTUS https://100.103.77.46:9443
|
||||||
|
# PORTAINER_API_KEY_GALACTUS Portainer access token for galactus
|
||||||
|
# PORTAINER_ENDPOINT_ID_GALACTUS 3
|
||||||
|
# PORTAINER_APP_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-app
|
||||||
|
# PORTAINER_DB_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-db
|
||||||
|
# PORTAINER_MINIO_STACK_NAME_GALACTUS e.g. jorgecuadros-prod-minio
|
||||||
|
# DATABASE_URL_GALACTUS mysql://jorgecuadros:<pass>@<galactus>:3306/jorgecuadros
|
||||||
|
# APP_API_ORIGIN_GALACTUS browser-facing API URL
|
||||||
|
# APP_WEB_ORIGIN_GALACTUS web public origin (API CORS)
|
||||||
|
# APP_S3_ENDPOINT_GALACTUS server-side minio URL
|
||||||
|
# SESSION_SECRET_GALACTUS 64-hex (openssl rand -hex 32)
|
||||||
|
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD
|
||||||
|
# MYSQL_PASSWORD / MYSQL_ROOT_PASSWORD
|
||||||
|
# - The runner (which lives on cubex) must be able to reach BOTH
|
||||||
|
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
||||||
|
# If it cannot reach 3306, run the migration by hand from a host that can
|
||||||
|
# and dispatch with skip_migrate=true.
|
||||||
|
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
||||||
|
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
||||||
|
# with P3005 "database schema is not empty":
|
||||||
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
||||||
|
# --schema packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
|
name: Deploy to galactus
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Image tag to deploy (1.2.3 — no leading v — or sha-<short>, or latest)"
|
||||||
|
required: true
|
||||||
|
default: "latest"
|
||||||
|
scope:
|
||||||
|
description: "What to deploy"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "app"
|
||||||
|
options:
|
||||||
|
- app
|
||||||
|
- full
|
||||||
|
bootstrap:
|
||||||
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
skip_migrate:
|
||||||
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.mancinas.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy ${{ github.event.inputs.tag }} (${{ github.event.inputs.scope }})
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
||||||
|
# runtime and cannot resolve one without it.
|
||||||
|
run: apk add --no-cache openssl ca-certificates git
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# An unset secret arrives as an empty string, and the deploy action then
|
||||||
|
# fails with "Input required and not supplied: token" — which names the
|
||||||
|
# action's input, not the secret you forgot. Check them up front and say
|
||||||
|
# exactly which ones are missing.
|
||||||
|
- name: Preflight — required secrets
|
||||||
|
env:
|
||||||
|
PORTAINER_URL_GALACTUS: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY_GALACTUS: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID_GALACTUS: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
PORTAINER_APP_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
||||||
|
PORTAINER_DB_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
||||||
|
PORTAINER_MINIO_STACK_NAME_GALACTUS: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
||||||
|
DATABASE_URL_GALACTUS: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
SESSION_SECRET_GALACTUS: ${{ secrets.SESSION_SECRET_GALACTUS }}
|
||||||
|
APP_API_ORIGIN_GALACTUS: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
||||||
|
APP_WEB_ORIGIN_GALACTUS: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
||||||
|
APP_S3_ENDPOINT_GALACTUS: ${{ secrets.APP_S3_ENDPOINT_GALACTUS }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
SCOPE: ${{ github.event.inputs.scope }}
|
||||||
|
run: |
|
||||||
|
REQUIRED="PORTAINER_URL_GALACTUS PORTAINER_API_KEY_GALACTUS
|
||||||
|
PORTAINER_ENDPOINT_ID_GALACTUS PORTAINER_APP_STACK_NAME_GALACTUS
|
||||||
|
DATABASE_URL_GALACTUS SESSION_SECRET_GALACTUS
|
||||||
|
APP_API_ORIGIN_GALACTUS APP_WEB_ORIGIN_GALACTUS
|
||||||
|
APP_S3_ENDPOINT_GALACTUS MINIO_ROOT_USER MINIO_ROOT_PASSWORD
|
||||||
|
MYSQL_ROOT_PASSWORD"
|
||||||
|
if [ "$SCOPE" = "full" ]; then
|
||||||
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME_GALACTUS
|
||||||
|
PORTAINER_MINIO_STACK_NAME_GALACTUS MYSQL_PASSWORD"
|
||||||
|
fi
|
||||||
|
missing=""
|
||||||
|
for name in $REQUIRED; do
|
||||||
|
eval "value=\${$name}"
|
||||||
|
[ -z "$value" ] && missing="$missing $name"
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error::missing repo secrets:$missing"
|
||||||
|
echo "::error::set them under Settings > Actions > Secrets"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "all required secrets present for scope=$SCOPE"
|
||||||
|
|
||||||
|
# --- full only: database ---------------------------------------------
|
||||||
|
- name: Deploy database stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-db.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MYSQL_SERVER_ID": "1",
|
||||||
|
"MYSQL_PORT": "3306",
|
||||||
|
"MYSQL_DATABASE": "jorgecuadros",
|
||||||
|
"MYSQL_USER": "jorgecuadros",
|
||||||
|
"MYSQL_PASSWORD": "${{ secrets.MYSQL_PASSWORD }}",
|
||||||
|
"MYSQL_ROOT_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- full only: object storage ---------------------------------------
|
||||||
|
- name: Deploy minio stack
|
||||||
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-minio.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"MINIO_API_PORT": "9000",
|
||||||
|
"MINIO_CONSOLE_PORT": "9001",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- restore point, taken while the OLD api container is still up ------
|
||||||
|
- name: Pre-migrate backup
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
# The dump runs as root: --single-transaction issues FLUSH TABLES,
|
||||||
|
# which needs the global RELOAD privilege the application user
|
||||||
|
# deliberately does not have.
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
||||||
|
# Portainer serves a self-signed certificate. Scoped to this step
|
||||||
|
# only, which does nothing but talk to Portainer.
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
- name: Apply database migrations
|
||||||
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL_GALACTUS }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SCHEMA=packages/database/prisma/schema.prisma
|
||||||
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
||||||
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
||||||
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
||||||
|
echo "::error::the database predates migration history — baseline it once with:"
|
||||||
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- make sure the host actually has the images ------------------------
|
||||||
|
# The deploy action's `pull: true` does not reliably refresh an already
|
||||||
|
# cached moving tag. Pull explicitly, or a "successful" deploy can leave
|
||||||
|
# the host serving an older build of the same tag.
|
||||||
|
- name: Pull images
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
|
||||||
|
TAG: ${{ github.event.inputs.tag }}
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pull-images.mjs
|
||||||
|
|
||||||
|
# --- always: the app (web + api) -------------------------------------
|
||||||
|
- name: Deploy app stack
|
||||||
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
token: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME_GALACTUS }}
|
||||||
|
file: deploy/galactus/jorgecuadros-app.compose.yml
|
||||||
|
type: file
|
||||||
|
standalone: true
|
||||||
|
pull: true
|
||||||
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
env_data: |
|
||||||
|
{
|
||||||
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
|
"API_PORT": "3001",
|
||||||
|
"WEB_PORT": "3000",
|
||||||
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
|
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
||||||
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
||||||
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
||||||
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
||||||
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET_GALACTUS }}",
|
||||||
|
"SESSION_COOKIE_SECURE": "false",
|
||||||
|
"OPS_DB_ADMIN_USER": "root",
|
||||||
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||||
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- prove it ----------------------------------------------------------
|
||||||
|
- name: Verify running version
|
||||||
|
env:
|
||||||
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN_GALACTUS }}
|
||||||
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN_GALACTUS }}
|
||||||
|
WANT: ${{ github.event.inputs.tag }}
|
||||||
|
# A stack naming a tag is not proof the containers run it. Ask BOTH
|
||||||
|
# tiers what they are, and require them to be the same commit: api and
|
||||||
|
# web are built from one matrix run, so a difference can only mean one
|
||||||
|
# of them did not actually get replaced.
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache curl >/dev/null
|
||||||
|
fetch_version() {
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
echo "waiting for $1 ($i/30)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "::error::$1/version never answered"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fetch_version "$API_ORIGIN" /tmp/api.json
|
||||||
|
fetch_version "$WEB_ORIGIN" /tmp/web.json
|
||||||
|
cat /tmp/api.json; echo; cat /tmp/web.json; echo
|
||||||
|
|
||||||
|
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
|
||||||
|
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
|
||||||
|
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
|
||||||
|
|
||||||
|
# Compare the COMMIT, not the version string: on a branch build both
|
||||||
|
# tiers report "master", so version equality proves nothing.
|
||||||
|
if [ "$API_SHA" != "$WEB_SHA" ]; then
|
||||||
|
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
|
||||||
|
echo "::error::one of the images was not replaced; check the Pull images step"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "api and web agree: $API_SHA"
|
||||||
|
|
||||||
|
# A semver dispatch is additionally comparable to the tag itself:
|
||||||
|
# metadata-action's {{version}} turns tag v1.2.3 into image 1.2.3,
|
||||||
|
# while `latest` and `sha-*` report the branch or short sha instead.
|
||||||
|
case "$WANT" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*)
|
||||||
|
if [ "$API_VER" != "$WANT" ]; then
|
||||||
|
echo "::error::deployed $WANT but the API reports $API_VER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: running $API_VER"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
+192
-5
@@ -8,6 +8,17 @@
|
|||||||
# app = web + api only (the usual app release) [default]
|
# app = web + api only (the usual app release) [default]
|
||||||
# full = db + minio + web + api (bring up / update the whole platform)
|
# full = db + minio + web + api (bring up / update the whole platform)
|
||||||
#
|
#
|
||||||
|
# The `tag` input carries NO leading `v`: metadata-action's {{version}} turns
|
||||||
|
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
||||||
|
#
|
||||||
|
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
||||||
|
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
||||||
|
# an older tag; that rolls back CODE only, never the schema, which is why every
|
||||||
|
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
||||||
|
#
|
||||||
|
# galactus (the office server) is standalone Docker, not this Swarm — it has its
|
||||||
|
# own workflow, .gitea/workflows/deploy-galactus.yml.
|
||||||
|
#
|
||||||
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates
|
||||||
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
# it on every run, so no manual stack pre-creation in the Portainer UI. On a
|
||||||
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
|
||||||
@@ -36,6 +47,14 @@
|
|||||||
# # Database stack (full only)
|
# # Database stack (full only)
|
||||||
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||||
# MYSQL_ROOT_PASSWORD mysql root password
|
# MYSQL_ROOT_PASSWORD mysql root password
|
||||||
|
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
||||||
|
# migration step connects to the database directly. If it cannot reach 3306,
|
||||||
|
# migrate by hand and dispatch with skip_migrate=true.
|
||||||
|
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
||||||
|
# that exists today): baseline it before the first run, or the migrate step
|
||||||
|
# fails with P3005 "database schema is not empty":
|
||||||
|
# npx prisma@5 migrate resolve --applied 0000_init \
|
||||||
|
# --schema packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
name: Deploy to Portainer
|
name: Deploy to Portainer
|
||||||
|
|
||||||
@@ -54,6 +73,16 @@ on:
|
|||||||
options:
|
options:
|
||||||
- app
|
- app
|
||||||
- full
|
- full
|
||||||
|
bootstrap:
|
||||||
|
description: "First-ever deploy: allow the pre-migrate backup to be skipped when no API container exists yet"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
skip_migrate:
|
||||||
|
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.mancinas.io
|
REGISTRY: git.mancinas.io
|
||||||
@@ -63,10 +92,57 @@ jobs:
|
|||||||
name: Deploy (${{ github.event.inputs.scope }})
|
name: Deploy (${{ github.event.inputs.scope }})
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: node:18-alpine
|
image: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
# openssl: prisma's migration engine picks its musl/openssl build at
|
||||||
|
# runtime and cannot resolve one without it.
|
||||||
|
run: apk add --no-cache openssl ca-certificates git
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# An unset secret arrives as an empty string, and the deploy action then
|
||||||
|
# fails with "Input required and not supplied: token" — which names the
|
||||||
|
# action's input, not the secret you forgot.
|
||||||
|
- name: Preflight — required secrets
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
PORTAINER_APP_STACK_NAME: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||||
|
PORTAINER_DB_STACK_NAME: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||||
|
PORTAINER_MINIO_STACK_NAME: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
|
||||||
|
APP_API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
||||||
|
APP_WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
|
||||||
|
APP_S3_ENDPOINT: ${{ secrets.APP_S3_ENDPOINT }}
|
||||||
|
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
|
||||||
|
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
|
||||||
|
MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
SCOPE: ${{ github.event.inputs.scope }}
|
||||||
|
run: |
|
||||||
|
REQUIRED="PORTAINER_URL PORTAINER_API_KEY PORTAINER_ENDPOINT_ID
|
||||||
|
PORTAINER_APP_STACK_NAME DATABASE_URL SESSION_SECRET
|
||||||
|
APP_API_ORIGIN APP_WEB_ORIGIN APP_S3_ENDPOINT
|
||||||
|
MINIO_ROOT_USER MINIO_ROOT_PASSWORD MYSQL_ROOT_PASSWORD"
|
||||||
|
if [ "$SCOPE" = "full" ]; then
|
||||||
|
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME
|
||||||
|
MYSQL_PASSWORD"
|
||||||
|
fi
|
||||||
|
missing=""
|
||||||
|
for name in $REQUIRED; do
|
||||||
|
eval "value=\${$name}"
|
||||||
|
[ -z "$value" ] && missing="$missing $name"
|
||||||
|
done
|
||||||
|
if [ -n "$missing" ]; then
|
||||||
|
echo "::error::missing repo secrets:$missing"
|
||||||
|
echo "::error::set them under Settings > Actions > Secrets"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "all required secrets present for scope=$SCOPE"
|
||||||
|
|
||||||
# --- full only: database ---------------------------------------------
|
# --- full only: database ---------------------------------------------
|
||||||
- name: Deploy database stack
|
- name: Deploy database stack
|
||||||
if: ${{ github.event.inputs.scope == 'full' }}
|
if: ${{ github.event.inputs.scope == 'full' }}
|
||||||
@@ -77,7 +153,7 @@ jobs:
|
|||||||
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
name: ${{ secrets.PORTAINER_DB_STACK_NAME }}
|
||||||
file: deploy/jorgecuadros-db.stack.yml
|
file: deploy/jorgecuadros-db.stack.yml
|
||||||
type: file
|
type: file
|
||||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"MYSQL_SERVER_ID": "1",
|
"MYSQL_SERVER_ID": "1",
|
||||||
@@ -98,7 +174,7 @@ jobs:
|
|||||||
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }}
|
||||||
file: deploy/jorgecuadros-minio.stack.yml
|
file: deploy/jorgecuadros-minio.stack.yml
|
||||||
type: file
|
type: file
|
||||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"MINIO_API_PORT": "9000",
|
"MINIO_API_PORT": "9000",
|
||||||
@@ -107,6 +183,65 @@ jobs:
|
|||||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- restore point, taken while the OLD api container is still up ------
|
||||||
|
# Dumped INSIDE the running api container so the file lands in the volume
|
||||||
|
# the "Operaciones" restore screen reads — a dump on the runner would be
|
||||||
|
# unreachable by the only restore path this platform has.
|
||||||
|
- name: Pre-migrate backup
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
# The dump runs as root: --single-transaction issues FLUSH TABLES,
|
||||||
|
# which needs the global RELOAD privilege the application user
|
||||||
|
# deliberately does not have.
|
||||||
|
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
|
||||||
|
BACKUP_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
|
||||||
|
# Portainer serves a self-signed certificate. Scoped to this step
|
||||||
|
# only, which does nothing but talk to Portainer.
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
# Prisma has no down-migrations: a code rollback does NOT roll the schema
|
||||||
|
# back. See docs/DEPLOY_AND_MIGRATIONS.md — every change must be
|
||||||
|
# expand/contract so the previous release still runs against the new
|
||||||
|
# schema. Run as a deploy STEP, never as the container CMD: N replicas
|
||||||
|
# would race each other applying the same migration.
|
||||||
|
- name: Apply database migrations
|
||||||
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SCHEMA=packages/database/prisma/schema.prisma
|
||||||
|
npx --yes prisma@5 migrate status --schema "$SCHEMA" || true
|
||||||
|
if ! npx --yes prisma@5 migrate deploy --schema "$SCHEMA"; then
|
||||||
|
echo "::error::migrate deploy failed. If this is P3005 (schema not empty),"
|
||||||
|
echo "::error::the database predates migration history — baseline it once with:"
|
||||||
|
echo "::error:: npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- make sure the host actually has the images ------------------------
|
||||||
|
# The deploy action's `pull: true` does not reliably refresh an already
|
||||||
|
# cached moving tag; without this a "successful" deploy can leave the host
|
||||||
|
# serving an older build of the same tag.
|
||||||
|
- name: Pull images
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
|
||||||
|
TAG: ${{ github.event.inputs.tag }}
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/pull-images.mjs
|
||||||
|
|
||||||
# --- always: the app (web + api) -------------------------------------
|
# --- always: the app (web + api) -------------------------------------
|
||||||
- name: Deploy app stack
|
- name: Deploy app stack
|
||||||
uses: cssnr/portainer-stack-deploy-action@v1
|
uses: cssnr/portainer-stack-deploy-action@v1
|
||||||
@@ -116,8 +251,8 @@ jobs:
|
|||||||
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
name: ${{ secrets.PORTAINER_APP_STACK_NAME }}
|
||||||
file: deploy/jorgecuadros-app.stack.yml
|
file: deploy/jorgecuadros-app.stack.yml
|
||||||
type: file
|
type: file
|
||||||
pull_image: true
|
pull: true
|
||||||
endpoint_id: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
@@ -129,6 +264,58 @@ jobs:
|
|||||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||||
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||||
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
|
"SESSION_SECRET": "${{ secrets.SESSION_SECRET }}",
|
||||||
|
"OPS_DB_ADMIN_USER": "root",
|
||||||
|
"OPS_DB_ADMIN_PASSWORD": "${{ secrets.MYSQL_ROOT_PASSWORD }}",
|
||||||
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
"MINIO_ROOT_USER": "${{ secrets.MINIO_ROOT_USER }}",
|
||||||
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
"MINIO_ROOT_PASSWORD": "${{ secrets.MINIO_ROOT_PASSWORD }}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- prove it ----------------------------------------------------------
|
||||||
|
# A stack naming a tag is not proof the container is running it — a
|
||||||
|
# skipped pull leaves the old code up. Ask the API what it actually is.
|
||||||
|
- name: Verify running version
|
||||||
|
env:
|
||||||
|
API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
|
||||||
|
WEB_ORIGIN: ${{ secrets.APP_WEB_ORIGIN }}
|
||||||
|
WANT: ${{ github.event.inputs.tag }}
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
apk add --no-cache curl >/dev/null
|
||||||
|
fetch_version() {
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
echo "waiting for $1 ($i/30)..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "::error::$1/version never answered"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fetch_version "$API_ORIGIN" /tmp/api.json
|
||||||
|
fetch_version "$WEB_ORIGIN" /tmp/web.json
|
||||||
|
cat /tmp/api.json; echo; cat /tmp/web.json; echo
|
||||||
|
|
||||||
|
API_SHA=$(node -e 'console.log(require("/tmp/api.json").gitSha)')
|
||||||
|
WEB_SHA=$(node -e 'console.log(require("/tmp/web.json").gitSha)')
|
||||||
|
API_VER=$(node -e 'console.log(require("/tmp/api.json").version)')
|
||||||
|
|
||||||
|
# Compare the COMMIT, not the version string: on a branch build both
|
||||||
|
# tiers report "master", so version equality proves nothing.
|
||||||
|
if [ "$API_SHA" != "$WEB_SHA" ]; then
|
||||||
|
echo "::error::api and web are different builds — api $API_SHA, web $WEB_SHA"
|
||||||
|
echo "::error::one of the images was not replaced; check the Pull images step"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "api and web agree: $API_SHA"
|
||||||
|
|
||||||
|
case "$WANT" in
|
||||||
|
[0-9]*.[0-9]*.[0-9]*)
|
||||||
|
if [ "$API_VER" != "$WANT" ]; then
|
||||||
|
echo "::error::deployed $WANT but the API reports $API_VER"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "verified: running $API_VER"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
||||||
|
#
|
||||||
|
# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what
|
||||||
|
# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` and `latest`
|
||||||
|
# image tags. Deploying stays a separate, deliberate act: once the build is
|
||||||
|
# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag
|
||||||
|
# carries the `v`, the image tag does not).
|
||||||
|
#
|
||||||
|
# Why a workflow instead of three local commands: the release commit is the one
|
||||||
|
# thing that must be identical every time, and cutting it from a laptop is how
|
||||||
|
# a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the
|
||||||
|
# only input is the number.
|
||||||
|
#
|
||||||
|
# Prereqs (once):
|
||||||
|
# - Repo secret RELEASE_TOKEN: a Gitea personal access token with
|
||||||
|
# write:repository on this repo. The built-in Actions token is deliberately
|
||||||
|
# NOT used — whether a push made with it re-triggers build.yml depends on the
|
||||||
|
# Gitea version, and a release that silently publishes no images is worse
|
||||||
|
# than one that fails. A PAT push is an ordinary push and always triggers.
|
||||||
|
# If build.yml somehow does not start, it has workflow_dispatch: run it
|
||||||
|
# against the new tag by hand.
|
||||||
|
|
||||||
|
name: Cut release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
bump:
|
||||||
|
description: "Which part to bump (choose 'explicit' to type the number)"
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
default: "minor"
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
- explicit
|
||||||
|
version:
|
||||||
|
description: "Exact version when bump=explicit (x.y.z, no leading v)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: node:20-alpine
|
||||||
|
steps:
|
||||||
|
- name: Install tools
|
||||||
|
run: apk add --no-cache git
|
||||||
|
|
||||||
|
- name: Preflight — RELEASE_TOKEN
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
echo "::error::Secret RELEASE_TOKEN is not set. Create a Gitea PAT with"
|
||||||
|
echo "::error::write:repository and add it as a repo secret named RELEASE_TOKEN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Full history + tags: the duplicate-tag check below is meaningless
|
||||||
|
# against a shallow clone, which has none of them.
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: master
|
||||||
|
token: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Resolve the new version
|
||||||
|
id: ver
|
||||||
|
env:
|
||||||
|
BUMP: ${{ github.event.inputs.bump }}
|
||||||
|
EXPLICIT: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
CURRENT=$(node -p "require('./package.json').version")
|
||||||
|
echo "current: $CURRENT"
|
||||||
|
|
||||||
|
if [ "$BUMP" = "explicit" ]; then
|
||||||
|
NEXT="$EXPLICIT"
|
||||||
|
if [ -z "$NEXT" ]; then
|
||||||
|
echo "::error::bump=explicit requires the version input."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
NEXT=$(node -e '
|
||||||
|
const [cur, part] = process.argv.slice(1);
|
||||||
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(cur);
|
||||||
|
if (!m) { console.error(`unparseable current version: ${cur}`); process.exit(1); }
|
||||||
|
let [maj, min, pat] = m.slice(1).map(Number);
|
||||||
|
if (part === "major") { maj += 1; min = 0; pat = 0; }
|
||||||
|
else if (part === "minor") { min += 1; pat = 0; }
|
||||||
|
else { pat += 1; }
|
||||||
|
process.stdout.write(`${maj}.${min}.${pat}`);
|
||||||
|
' "$CURRENT" "$BUMP")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# set-version.mjs validates the shape too, but failing here keeps the
|
||||||
|
# working tree clean when the input is a typo.
|
||||||
|
case "$NEXT" in
|
||||||
|
v*) echo "::error::Version must not carry a leading 'v' (got $NEXT)."; exit 1 ;;
|
||||||
|
esac
|
||||||
|
if ! printf '%s' "$NEXT" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||||
|
echo "::error::Invalid version: $NEXT (expected x.y.z)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$NEXT" = "$CURRENT" ]; then
|
||||||
|
echo "::error::$NEXT is already the current version."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then
|
||||||
|
echo "::error::Tag v$NEXT already exists. Releases are immutable — pick a new number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "next: $NEXT"
|
||||||
|
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Stamp the version across every manifest
|
||||||
|
run: node scripts/set-version.mjs "${{ steps.ver.outputs.version }}"
|
||||||
|
|
||||||
|
# A release whose only content is the version bump means the dispatch was
|
||||||
|
# a mistake — set-version.mjs already refused a no-op above, so an empty
|
||||||
|
# diff here means the manifests were somehow already at this number.
|
||||||
|
- name: Commit, tag, push
|
||||||
|
env:
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
ACTOR: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "::error::No manifest changed. Nothing to release."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "actions@git.mancinas.io"
|
||||||
|
|
||||||
|
git commit -a \
|
||||||
|
-m "chore(release): v${VERSION}" \
|
||||||
|
-m "Cut by ${ACTOR} via the \"Cut release\" workflow. Pushing the tag triggers build.yml; deploy separately with tag=${VERSION}."
|
||||||
|
git tag -a "v${VERSION}" -m "v${VERSION}"
|
||||||
|
|
||||||
|
# Re-point at an authenticated remote. The token is a secret, so Gitea
|
||||||
|
# masks it in the log; nothing here echoes the URL regardless.
|
||||||
|
git remote set-url origin \
|
||||||
|
"$(printf '%s' "${GITHUB_SERVER_URL}" | sed "s#://#://x-access-token:${RELEASE_TOKEN}@#")/${GITHUB_REPOSITORY}.git"
|
||||||
|
|
||||||
|
# One push for both refs: a commit that lands without its tag builds
|
||||||
|
# nothing and looks like a successful release.
|
||||||
|
git push origin "HEAD:master" "refs/tags/v${VERSION}"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.ver.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
echo "Released v${VERSION}."
|
||||||
|
echo ""
|
||||||
|
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
||||||
|
echo "When it is green, dispatch 'Deploy to galactus' with:"
|
||||||
|
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false"
|
||||||
@@ -134,8 +134,11 @@ Given the amount of near-duplicate/overlapping data across snapshot tables (mult
|
|||||||
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
- **Receipt capture module — DONE** (2026-07-27). The legacy "Editor" replacement, built on the single-movement capture from step 6. Wires up the previously-unused `Transaction.outstanding` (NOPAGO): capture flag on `POST /billing`, `?outstanding=` list filter, `POST /billing/:id/resolve-outstanding` (gated `ledger:create`, not `ledger:void` — resolving *completes* a capture), and exclusion from every balance aggregate exactly as the legacy `SALDOS ULTIMO 0`'s `HAVING NOPAGO = 0` did. Adds `POST /billing/batch` (one `$transaction`, check-level fields shared, per-line customer/amount) and `GET /billing/by-check`, plus the `cheque-count` report replacing `REPORTE CHEQUE COUNT` / `REPORTE POR CHEQUE` / `EDITA CHEQUE ALF|COUNT|NUM` — print/PDF/CSV/XLSX come free from the existing `/reportes/:slug` machinery. Web: `/estado-cuenta/lote` (the actual "Editor" screen, with live reconciliation against the physical check amount), plus an "Estado de pago" filter, a "sin fondos" row tag and a Resolver dialog on `/estado-cuenta`. No new abilities. Verified end-to-end against dev, API + browser.
|
||||||
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
**Two pre-existing bugs found and fixed while building it:** (a) `statement()` filtered `legacySourceTable: { notIn: [...] }`, which compiles to SQL `NOT IN` — and `NULL NOT IN (…)` is NULL, so **every app-captured movement was invisible on the customer statement** (438 rows in the movement browser vs 392 on the statement) while still appearing everywhere else. This would have made the whole receipt-capture feature look broken to staff. Now NULL-safe. (b) The balances *count* query omitted the void filter its own page query applied, so the row count disagreed with the rows.
|
||||||
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
**OCR seam:** `BillingService.createBatch(dto, opts)` is the single multi-row write path and carries three contract guarantees for the step-11 OCR module to post through — `items[i]` maps to `lines[i]` (so `StatementDocument.postedTransactionId` can be zipped back on), `opts.refs[i]` stamps `captureRef` with a duplicate-post guard that a *voided* row deliberately does not block, and `opts.source` is service-level only so an HTTP client cannot label hand-keyed rows as machine-captured. Backed by a new `TransactionCaptureSource` enum (MANUAL/BATCH/OCR) + `captureRef`, both nullable so the 40,136 migrated rows stay NULL rather than being mislabelled.
|
||||||
- **PDF/OCR auto-capture** — ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff currently key in by hand. Posts through the capture module above. Matching logic was checked field-by-field against `migration/transform_properties.py`'s actual output and found three real gaps to close first: no `TELEPHONE` service kind exists yet, `PROPERTY_TAX.accountNumber` was migrated from `PREDIAL` not `CLAVE` (needs verification against a real predial statement), and `GAS.meterNumber` was never populated by the migration at all.
|
- **PDF/OCR auto-capture — DONE** (2026-08-01). The ingest→split→OCR→match→review pipeline for the 300+/month/service-provider statements staff key in by hand, built in `apps/api/src/statements/` and posting through §1.2's `createBatch` seam with `source: "OCR"` and a per-document `captureRef`. Web: `/recibos` + `/recibos/:id`. Abilities `statement:ingest`/`statement:review` (STAFF — the review step is what makes machine capture safe at that tier). OCR is self-hosted **Tesseract** behind a swappable `OcrProvider` interface; `tesseract-ocr`, `tesseract-ocr-data-spa` and `poppler-utils` were added to the API image.
|
||||||
- **Multi-bank chequera** — `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register; today's `bank_transactions` is hardcoded single-account/MXN-only by design (see step 7 above) and needs a required `bankAccountId` plus scoping added to every read path in `bank.service.ts`, including two raw-SQL queries in `summary()`.
|
**Every decision was driven by 10 real scans (46 pages).** Shipped-parser results on them: provider 46/46, account ref 43/46, amount 42/46, due date 44/46 — and against the dev database **39/46 (85%) exact auto-match, 40/46 (87%) identified**, the rest genuine review cases. The scans are pure images (no text layer), so OCR is mandatory, and they arrive **bundled one customer per page**.
|
||||||
|
**The three gaps are closed, and two of them were mis-stated in the spec.** (a) `TELEPHONE` now exists and is backfilled from `Property.phone1` only — coverage is 534/18/1 across phone1/2/3, so phone is one billed line per property, not three. (b) **Clave catastral ≠ predial**: `DATMEX.clave` (934 rows, `KA903009`) is what CESPT and predial bills actually print, while `predial` — what `PROPERTY_TAX.accountNumber` holds — has only 663 distinct values across 1135 rows and appears on no statement; the clave now lives on `Property.cadastralKey` as the matcher's secondary key and predial is left untouched. (c) Gas was **not** a dead end: 160 of the 334 `DATMEX.gas` values are real account numbers (the rest are `ESTACIONARIO`/`CILINDRO` descriptors), all recovered into `GAS.meterNumber`.
|
||||||
|
**Matching is scoped per service kind and never reads the customer name** — a CESPT receipt prints `ARNAIZ ROSAS ELSA AURORA` for an account this office holds under `CATT, RANDY`, because the name on a utility bill is the registrant, not the current owner. Normalisation is per provider: CFE strips leading zeros off `NO. DE SERVICIO`, Telnor strips the 664 LADA down to the stored local 7 digits. Where a provider prints a payment barcode it is preferred over the printed label (one CFE label OCR'd a digit too many while its barcode was correct) and the two are cross-checked, with disagreement forcing review. Confirming a document whose service had no reference writes it back, so gas and any other cold start is a one-time cost.
|
||||||
|
- **Multi-bank chequera — DONE** (2026-07-27). `Bank`/`BankAccount` models so Seguros (US bank) and Utilities (Mexican bank, currently SCOTHIA) can each have their own register. `bank_transactions` gained a **required** `bankAccountId` (plus an `(bankAccountId, transactionDate)` index, since every read is now filtered by account and ordered by date), and all 22,669 existing rows were backfilled onto a seeded "Utilities — Scotiabank (MXN)" account by `migration/backfill_bank_accounts.py` — a standalone step because `prisma db push` cannot add a required column to a populated table. It is idempotent and now runs inside `run_all.py` (both normal and `--sync`) ahead of `transform_bank.py`, which fails fast if the account is missing. Every read path in `bank.service.ts` is account-scoped, including `facets()` (which had no filter at all) and *both* raw-SQL rollups in `summary()`. API: `?bankAccountId=` is required on `list`/`stats`/`facets`/`summary` — **not** optional-with-an-all-accounts-default, since summing an MXN and a USD register repeats exactly the currency-collapsing mistake the billing module exists to prevent — plus a new `bank/accounts` + `bank/banks` sub-resource under a MANAGER `bank:manage-accounts` ability. Web: `/banco` gained an account picker (remembered per browser) and reads every figure in the selected account's currency, `/banco/cuentas` manages banks and accounts, and `/inicio`'s chequera card names the account it is showing instead of implying one register. An account's `currency` is immutable after creation by design — its booked movements are denominated in it. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged.
|
||||||
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
- **Customer-number recycling** — promotes the legacy `NUM id` (currently only inside `customer_legacy_refs`) into a first-class, reusable `Customer.customerNumber`, automates *finding* candidates for reuse (cancelled / 1-year-inactive), and auto-assigns the lowest free number at creation — the search is automated, the release/reuse decision stays a human action. Backfill needs care: ~140 utilities rows and all insurance-only customers have no real legacy number (synthetic `rownum_N`/`insrow_N` placeholders in `transform_customers.py`, not real `NUM id`s).
|
||||||
|
|
||||||
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
Several open questions block parts of this (OCR provider/budget, the Seguros bank's identity, the clave-catastral-vs-predial mismatch, exact recycling triggers, and whether "recycling" should ever mean true data purge vs. archive-and-reuse-the-number) — see the spec's collected open-questions section.
|
||||||
@@ -157,7 +160,7 @@ Repo scaffolded at `jorgecuadros-platform/`: npm workspaces, NestJS API with a r
|
|||||||
|
|
||||||
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
|
**Portal live DB now in hand.** `utility_dbo.sql` (1.3 GB, 55 tables) and the portal codebase `my-jorgecuadros-web` (PHP/`mysqli`, Gitea repo, themed classic/modern, ~397 PHP files, core in `scripts/functions.php`) are both on disk — resolving the long-standing "`utility_dbo` schema unknown" blocker. Sync-relevant tables identified: statements/money (`utility_bills`, `accounting`, `email_alert_log`), customer/property (`home_owners`, `home_index`, `condominium`, `management`, `hoa_management`, `trust_assist`), portal-facing policy views (`fm2`/`fm3`/`fmt`, `full_coverage`, `mx_liability`, `usa_liability`), and portal write points (`peticion_gas`, PayPal payments, `notifications_settings`, `verification_codes`). A second dump, `jorgecuadros.sql` (38 MB, 11 tables — `pagos`/`pagosemail`/`PROPANO`/`TRUSTVENCE`/etc.), appears to be an older/partial export, not the portal live DB.
|
||||||
|
|
||||||
**Step 11 spec written, not built.** `docs/RECEIPT_CAPTURE_SPEC.md` covers the receipt-capture ("Editor") completion plus the three net-new ops features (OCR auto-capture, multi-bank chequera, customer-number recycling) — see Build sequencing step 11 above for the summary. Written from the 2026-07-25/26 meeting notes and verified against the real migration scripts and current API code, not just designed from the meeting notes alone.
|
**Step 11 is now three-quarters built.** Receipt capture, the multi-bank chequera and PDF/OCR auto-capture are all done and verified; only customer-number recycling remains unbuilt. `docs/RECEIPT_CAPTURE_SPEC.md` carries a BUILT note per section recording what shipped and, for §2, the four things real scanned statements proved the spec had wrong or unknown.
|
||||||
|
|
||||||
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
|
**Step 12 spec written, not built.** `docs/INSURANCE_FEATURES_SPEC.md` covers the insurance half of the same meeting (renewal emails, liquidación batch, certificate + portal delivery, carrier APIs) — see Build sequencing step 12 above. Verified the same way, plus a live query of the dev DB for the counts it quotes (email coverage, pending liquidación, installment fill rates) and of the staged Parquet for the legacy settlement-slot usage. Two of the four features are much smaller than they sound: the renewal-notice table, its idempotency key and the letter body already exist, and the per-policy liquidación fields are already wired end to end.
|
||||||
|
|
||||||
@@ -183,9 +186,10 @@ Unlike the ops items above, these block design decisions, not just infrastructur
|
|||||||
|
|
||||||
**Step 11 — utilities/ops side:**
|
**Step 11 — utilities/ops side:**
|
||||||
|
|
||||||
- OCR provider/budget for the statement auto-capture pipeline (self-hosted vs. a paid per-page API, given 300+ statements/month/service provider).
|
- ~~OCR provider/budget~~ — **CLOSED**: self-hosted Tesseract, chosen on measured accuracy against real scans, so there is no per-page cost to approve.
|
||||||
- Whether `PROPERTY_TAX.accountNumber` (migrated from `DATMEX.PREDIAL`) is actually the same number as "Clave Catastral" (`DATMEX.CLAVE`) — blocks OCR matching for predial statements until confirmed against a real bill.
|
- ~~Whether `PROPERTY_TAX.accountNumber` (from `DATMEX.PREDIAL`) is the same number as "Clave Catastral" (`DATMEX.CLAVE`)~~ — **CLOSED**: they are different numbers. Answered from real CESPT bills plus the staged data; the clave is now migrated separately and predial was left alone.
|
||||||
- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate.
|
- Whether the CFE figure to charge is the rounded headline/barcode amount (`$268` — what is actually paid at the window) or the exact breakdown `Total` (`$268.88`). The parser takes the barcode amount; one confirmation from Jorge would settle it.
|
||||||
|
- The actual bank name/currency/details for the Seguros USD account, and whether any historical Seguros bank register exists to migrate. (Multi-bank support itself is **built** — this is now only the missing content: staff can open the account in `/banco/cuentas` the moment the answer arrives, and it starts empty unless a historical register turns up.)
|
||||||
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
- The exact "1 year inactivity" / "cancelled" triggers for customer-number recycling eligibility.
|
||||||
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
- Whether customer-number recycling should ever include true PII purge (matching the office's paper-world habit) or archive-and-reuse-the-number is sufficient — recommended default is archive-only, consistent with this project's existing never-hard-delete convention.
|
||||||
|
|
||||||
|
|||||||
@@ -381,9 +381,18 @@ for what's actually next.
|
|||||||
insurance/servicios/fideicomiso split the migration comment implied. A
|
insurance/servicios/fideicomiso split the migration comment implied. A
|
||||||
classifier would invent data, so `categoryId` stays null and the module does
|
classifier would invent data, so `categoryId` stays null and the module does
|
||||||
not filter on it. Register is browsable by date/payee/amount/cheque instead.
|
not filter on it. Register is browsable by date/payee/amount/cheque instead.
|
||||||
(c) **Single currency (MXN).** `bank_transactions` has no currency column and
|
(c) ~~**Single currency (MXN).**~~ **SUPERSEDED 2026-07-27 by the multi-bank
|
||||||
every `amountInWords` is spelled out in PESOS — so, unlike the customer
|
chequera** (step 11, `docs/RECEIPT_CAPTURE_SPEC.md` §3). The office keeps
|
||||||
ledger, everything here is one currency and not split per-currency.
|
more than one register, so `bank_transactions` now carries a **required**
|
||||||
|
`bankAccountId` and every read in the module is scoped to exactly one
|
||||||
|
`BankAccount`, whose `currency` the movements inherit — there is still no
|
||||||
|
currency column on the movement itself, because a real bank account doesn't
|
||||||
|
mix currencies. All 22,669 migrated rows are the Utilities/Scotiabank MXN
|
||||||
|
account (backfilled by `migration/backfill_bank_accounts.py`, which
|
||||||
|
`run_all.py` runs before `transform_bank.py`), which is why every
|
||||||
|
`amountInWords` is still spelled out in PESOS. There is deliberately no
|
||||||
|
"all accounts" option: summing an MXN and a USD register would repeat the
|
||||||
|
currency-collapsing mistake the billing module warns against.
|
||||||
(d) **The "acumulado" is net movement since the register opened, not a bank
|
(d) **The "acumulado" is net movement since the register opened, not a bank
|
||||||
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
|
balance** — SCOTHIA carries no opening balance (its `ban` table holds only the
|
||||||
bank's name), so the running total starts at 0 in 2013. Labelled as such in
|
bank's name), so the running total starts at 0 in 2013. Labelled as such in
|
||||||
@@ -391,9 +400,18 @@ for what's actually next.
|
|||||||
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
(e) Sign convention (from `transform_bank.py`): positive = ingreso,
|
||||||
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
negative = egreso, exactly zero = a cancelled/void cheque (787 of 791 say
|
||||||
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
|
CANCELADO/VOID) — voids are excluded from both the income and expense sides.
|
||||||
|
(f) **Multi-account since 2026-07-27.** `/banco` opens on an account picker
|
||||||
|
(the last account is remembered per browser) and reads every figure in that
|
||||||
|
account's currency; `/banco/cuentas` manages banks and accounts under a new
|
||||||
|
MANAGER `bank:manage-accounts` ability. Accounts are never deleted — the
|
||||||
|
`bankAccountId` FK is required, so a used account can only be *closed*
|
||||||
|
(`active: false`), which hides it from new captures but keeps its history
|
||||||
|
readable. An account's currency is immutable after creation, since its
|
||||||
|
booked movements are denominated in it.
|
||||||
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
- Full pipeline reproducible in one command: `run_all.py --env <env>` runs customers →
|
||||||
properties → policies → transactions → prune → bank → blobs in order (all idempotent);
|
properties → policies → transactions → prune → bank accounts → bank → blobs in order
|
||||||
add `--stage` to re-extract from the Access files first. Verified end-to-end against dev.
|
(all idempotent); add `--stage` to re-extract from the Access files first. Verified
|
||||||
|
end-to-end against dev.
|
||||||
|
|
||||||
5. **Infra** — **DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack
|
5. **Infra** — **DONE.** Dev MySQL deployed to the cubex Swarm via the Portainer API as stack
|
||||||
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
`jorgecuadros-dev-db` (MySQL 8.4, `192.168.4.212:3307`, node `cubex` labeled
|
||||||
@@ -424,3 +442,87 @@ for what's actually next.
|
|||||||
verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals,
|
verified vs dev: Anular buttons admin-gated, voided rows struck + excluded from totals,
|
||||||
clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail
|
clicking Anular voids end-to-end (note: it uses a blocking `window.confirm`). Customer-detail
|
||||||
mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.
|
mini tx list now also strikes voided rows ("(anulado)" tag) — was the last void-UI gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statement OCR intake (`/recibos`) — DONE 2026-08-01
|
||||||
|
|
||||||
|
Plan step 11 §2 (`docs/RECEIPT_CAPTURE_SPEC.md` §2). The last big utilities
|
||||||
|
feature: staff scan the month's utility bills and the machine proposes customer
|
||||||
|
+ amount per page, instead of keying 300+ statements per company by hand. Built
|
||||||
|
in `apps/api/src/statements/` and `apps/web/src/app/recibos/`, posting through
|
||||||
|
step 11 §1.2's `BillingService.createBatch` seam (`source: "OCR"`, per-document
|
||||||
|
`captureRef`) so machine and hand capture share one write path and one audit
|
||||||
|
trail. Abilities `statement:ingest` / `statement:review`, both STAFF.
|
||||||
|
|
||||||
|
**Verified end to end against the live dev API + MinIO**, not just built: real
|
||||||
|
CFE and Telnor scans uploaded over HTTP, OCR'd, matched, confirmed against a
|
||||||
|
check, and the resulting rows checked in MySQL — negative (charge) amounts,
|
||||||
|
`captureSource = OCR`, concept auto-derived from the batch's service kind,
|
||||||
|
`captureRef` linking each transaction back to its page. Re-confirming a posted
|
||||||
|
batch is refused. All test data was removed afterwards.
|
||||||
|
|
||||||
|
**Everything here was decided from 10 real scanned statements (46 pages), not
|
||||||
|
from the sample-free spec.** Shipped-parser results on them: provider 46/46,
|
||||||
|
account reference 43/46, amount 42/46, due date 44/46; matched against the dev
|
||||||
|
database, **39/46 (85%) exact auto-match, 40/46 (87%) identified**. The rest are
|
||||||
|
real review cases (one shared account number, three phones not on file, one
|
||||||
|
clave not in the book, one page too poor to read).
|
||||||
|
|
||||||
|
Findings that corrected the spec, each of which changed the build:
|
||||||
|
|
||||||
|
- **The scans have no text layer at all** — they are camera images of paper, so
|
||||||
|
OCR is mandatory rather than a convenience, and they arrive **bundled, one
|
||||||
|
customer per page**.
|
||||||
|
- **Clave catastral is not predial.** `DATMEX.clave` (934 rows, `KA903009`) is
|
||||||
|
what CESPT and predial bills print; `DATMEX.predial` — which
|
||||||
|
`PROPERTY_TAX.accountNumber` holds — has only 663 distinct values across 1135
|
||||||
|
rows and appears on no statement. The clave now lives on
|
||||||
|
`Property.cadastralKey` as the matcher's secondary key; predial was left
|
||||||
|
untouched. This is the question that had been blocking predial matching.
|
||||||
|
- **Gas was recoverable after all.** The spec said no legacy gas number existed;
|
||||||
|
in fact 160 of 334 `DATMEX.gas` values are real account numbers (the rest are
|
||||||
|
`ESTACIONARIO`/`CILINDRO` descriptors). Recovered into `GAS.meterNumber`.
|
||||||
|
- **Phone is one billed line per property** (534 / 18 / 1 across phone1/2/3), so
|
||||||
|
`TELEPHONE` — a new `ServiceKind` — backfills from `phone1` only.
|
||||||
|
- **Never match on the printed name.** A CESPT receipt for account `5365218`
|
||||||
|
reads `ARNAIZ ROSAS ELSA AURORA`; the office's book, corroborated by the
|
||||||
|
clave, has `CATT, RANDY`. The name on a utility bill is the registrant, not
|
||||||
|
the current owner.
|
||||||
|
|
||||||
|
`migration/backfill_statement_match_fields.py` closes those three data gaps on
|
||||||
|
an existing database (idempotent, wired into `run_all.py` after
|
||||||
|
`transform_properties.py`, which now produces them directly on a full rebuild).
|
||||||
|
Applied to dev: 934 claves, 160 gas numbers, 534 TELEPHONE rows.
|
||||||
|
|
||||||
|
Implementation notes worth keeping:
|
||||||
|
|
||||||
|
- OCR is self-hosted **Tesseract** behind an `OcrProvider` interface — the
|
||||||
|
provider question is closed on measured accuracy, and a managed API stays a
|
||||||
|
one-line swap in `statements.module.ts`. `tesseract-ocr`,
|
||||||
|
`tesseract-ocr-data-spa` and `poppler-utils` were added to the API image; if
|
||||||
|
they are missing the module reports itself unavailable and only this feature
|
||||||
|
is disabled.
|
||||||
|
- **Payment barcodes beat printed labels.** One CFE label OCR'd a digit too
|
||||||
|
many while its barcode was correct, so the barcode is the source and the label
|
||||||
|
the cross-check; disagreement forces review.
|
||||||
|
- **Detect the provider by brand first, layout only as a fallback** — and never
|
||||||
|
interleave the two passes. A scanned CESPT header came back as `E BAJA ES
|
||||||
|
PAGO / EALIFORNIA`, which is why the layout fallback exists; a Telnor page
|
||||||
|
contains words a CFE layout rule would otherwise claim, which is why ordering
|
||||||
|
matters.
|
||||||
|
- **Parse amounts by separator position.** A real Telnor bill OCR'd as
|
||||||
|
`$ 649,00`; stripping commas as thousands separators turns that into $64,900.
|
||||||
|
- Two of the three layouts are line-oriented, but the CESPT "RECIBO" is a
|
||||||
|
**table** whose values sit under column headers — that one needs the word
|
||||||
|
boxes, which is why `OcrPage` carries geometry and not just text.
|
||||||
|
- Confirming a document whose matched service had no reference **writes the
|
||||||
|
reference back** (only into an empty field, and only when exactly one blank
|
||||||
|
service of that kind is a candidate), so gas and any other cold start is a
|
||||||
|
one-time cost rather than a permanent queue.
|
||||||
|
- Handwritten folder numbers on the bills (`9`, `405`) are **not** used for
|
||||||
|
matching — Tesseract read `405` as `205`.
|
||||||
|
|
||||||
|
**Open:** whether the CFE charge should be the rounded barcode/headline figure
|
||||||
|
(`$268`, what is paid at the window — what the parser uses today) or the exact
|
||||||
|
breakdown total (`$268.88`). One question for Jorge.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -6,4 +6,25 @@ export class AppController {
|
|||||||
health() {
|
health() {
|
||||||
return { status: "ok" };
|
return { status: "ok" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is actually running. The three values are baked into the image at
|
||||||
|
* build time by .gitea/workflows/build.yml (see docker/api.Dockerfile) and
|
||||||
|
* are the only way to confirm a deploy — or a rollback — landed: the tag you
|
||||||
|
* dispatched and the code inside the container can disagree if a stack was
|
||||||
|
* applied without pulling, or if the app stack still names an older tag.
|
||||||
|
*
|
||||||
|
* Deliberately unauthenticated, same as /health: the deploy workflow has to
|
||||||
|
* read it with no session, and it exposes nothing an attacker could not
|
||||||
|
* already infer from the repo.
|
||||||
|
*/
|
||||||
|
@Get("version")
|
||||||
|
version() {
|
||||||
|
return {
|
||||||
|
service: "api",
|
||||||
|
version: process.env.APP_VERSION ?? "dev",
|
||||||
|
gitSha: process.env.GIT_SHA ?? "unknown",
|
||||||
|
buildDate: process.env.BUILD_DATE ?? "unknown",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { CustomersModule } from "./customers/customers.module";
|
|||||||
import { PoliciesModule } from "./policies/policies.module";
|
import { PoliciesModule } from "./policies/policies.module";
|
||||||
import { PropertiesModule } from "./properties/properties.module";
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
|
import { StatementsModule } from "./statements/statements.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
import { OpsModule } from "./ops/ops.module";
|
import { OpsModule } from "./ops/ops.module";
|
||||||
import { ReportsModule } from "./reports/reports.module";
|
import { ReportsModule } from "./reports/reports.module";
|
||||||
@@ -26,6 +27,7 @@ import { AppController } from "./app.controller";
|
|||||||
PoliciesModule,
|
PoliciesModule,
|
||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
|
StatementsModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
OpsModule,
|
OpsModule,
|
||||||
ReportsModule,
|
ReportsModule,
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export type Ability =
|
|||||||
| "ledger:void"
|
| "ledger:void"
|
||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
|
| "bank:manage-accounts"
|
||||||
|
| "statement:ingest"
|
||||||
|
| "statement:review"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage";
|
||||||
@@ -50,6 +53,15 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"ledger:void": "MANAGER",
|
"ledger:void": "MANAGER",
|
||||||
"bank:create": "STAFF",
|
"bank:create": "STAFF",
|
||||||
"bank:void": "MANAGER",
|
"bank:void": "MANAGER",
|
||||||
|
// Opening or renaming a chequera is rarer and higher-stakes than posting a
|
||||||
|
// movement into one — a wrong account silently mixes two sets of books.
|
||||||
|
"bank:manage-accounts": "MANAGER",
|
||||||
|
// Uploading a stack of scans and reviewing what the OCR read are both
|
||||||
|
// "capturing a receipt" — the same trust tier as ledger:create, since
|
||||||
|
// confirming a statement *is* capturing it. The review step is what makes
|
||||||
|
// this safe at STAFF level: nothing reaches the ledger unconfirmed.
|
||||||
|
"statement:ingest": "STAFF",
|
||||||
|
"statement:review": "STAFF",
|
||||||
"lookup:manage": "MANAGER",
|
"lookup:manage": "MANAGER",
|
||||||
"user:manage": "ADMIN",
|
"user:manage": "ADMIN",
|
||||||
"db:manage": "ADMIN",
|
"db:manage": "ADMIN",
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { Controller, Get, HttpCode, Post, Req, Res, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
import { LocalAuthGuard } from "./local-auth.guard";
|
import { LocalAuthGuard } from "./local-auth.guard";
|
||||||
import { AuthenticatedGuard } from "./authenticated.guard";
|
import { AuthenticatedGuard } from "./authenticated.guard";
|
||||||
import { LoginDto } from "./login.dto";
|
import { LoginDto } from "./login.dto";
|
||||||
|
import { UpdatePreferencesDto } from "./update-preferences.dto";
|
||||||
import { abilitiesFor, Role } from "./abilities";
|
import { abilitiesFor, Role } from "./abilities";
|
||||||
|
import { UsersService } from "../users/users.service";
|
||||||
|
|
||||||
/** Attach the resolved ability map so the web can gate its UI off one payload. */
|
/** Attach the resolved ability map so the web can gate its UI off one payload. */
|
||||||
function withAbilities(user: unknown) {
|
function withAbilities(user: unknown) {
|
||||||
@@ -14,6 +26,8 @@ function withAbilities(user: unknown) {
|
|||||||
|
|
||||||
@Controller("auth")
|
@Controller("auth")
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
// LoginDto is only used for request-shape documentation/validation here —
|
// LoginDto is only used for request-shape documentation/validation here —
|
||||||
// the actual credential check happens inside LocalStrategy via Passport,
|
// the actual credential check happens inside LocalStrategy via Passport,
|
||||||
// which populates req.user before this handler runs.
|
// which populates req.user before this handler runs.
|
||||||
@@ -30,6 +44,19 @@ export class AuthController {
|
|||||||
return withAbilities(req.user);
|
return withAbilities(req.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the caller's own UI preferences. Deliberately not on /users/:id —
|
||||||
|
* that controller is ADMIN-only, and this has to work for every role. The
|
||||||
|
* target is always the session's own user id, never a body parameter.
|
||||||
|
*/
|
||||||
|
@UseGuards(AuthenticatedGuard)
|
||||||
|
@Patch("preferences")
|
||||||
|
async updatePreferences(@Req() req: Request, @Body() dto: UpdatePreferencesDto) {
|
||||||
|
const id = (req.user as { id: string }).id;
|
||||||
|
const user = await this.users.updatePreferences(id, dto.uiScale);
|
||||||
|
return withAbilities(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("logout")
|
@Post("logout")
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
logout(@Req() req: Request) {
|
logout(@Req() req: Request) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsNumber, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service UI preferences — any authenticated user may set these on their
|
||||||
|
* own account, including VIEWER. No ability gate: it changes nothing but how
|
||||||
|
* the app looks to that one person.
|
||||||
|
*
|
||||||
|
* The bounds mirror MIN_UI_SCALE/MAX_UI_SCALE in apps/web/src/lib/ui-scale.ts;
|
||||||
|
* keep them in sync. The API clamps rather than trusting the client because
|
||||||
|
* this endpoint is reachable outside the UI.
|
||||||
|
*/
|
||||||
|
export class UpdatePreferencesDto {
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.9)
|
||||||
|
@Max(1.5)
|
||||||
|
uiScale!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
/** Mirrors the Prisma `Currency` enum; a chequera's is fixed at creation. */
|
||||||
|
export const BANK_CURRENCIES = ["MXN", "USD"] as const;
|
||||||
|
export type BankAccountCurrency = (typeof BANK_CURRENCIES)[number];
|
||||||
|
|
||||||
|
/** Mirrors `TransactionDomain`. A soft hint on the account, never enforced. */
|
||||||
|
export const BANK_BUSINESS_LINES = ["UTILITY", "INSURANCE", "TRUST"] as const;
|
||||||
|
export type BankBusinessLine = (typeof BANK_BUSINESS_LINES)[number];
|
||||||
|
|
||||||
|
export class CreateBankDto {
|
||||||
|
@IsString() @MinLength(1) name!: string;
|
||||||
|
/** "MX" | "US" — free text, informational only. */
|
||||||
|
@IsOptional() @IsString() country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBankDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
@IsOptional() @IsString() country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBankAccountDto {
|
||||||
|
@IsString() @MinLength(1) bankId!: string;
|
||||||
|
@IsString() @MinLength(1) label!: string;
|
||||||
|
/**
|
||||||
|
* Immutable after creation (no field for it on the update DTO): every
|
||||||
|
* movement already booked into the account is denominated in it, so
|
||||||
|
* changing it would silently re-denominate history.
|
||||||
|
*/
|
||||||
|
@IsIn(BANK_CURRENCIES) currency!: BankAccountCurrency;
|
||||||
|
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBankAccountDto {
|
||||||
|
@IsOptional() @IsString() @MinLength(1) bankId?: string;
|
||||||
|
@IsOptional() @IsString() @MinLength(1) label?: string;
|
||||||
|
@IsOptional() @IsIn(BANK_BUSINESS_LINES) businessLine?: BankBusinessLine;
|
||||||
|
/** Closing an account hides it from the picker; its movements stay readable. */
|
||||||
|
@IsOptional() @IsBoolean() active?: boolean;
|
||||||
|
}
|
||||||
@@ -2,10 +2,14 @@ import { IsBoolean, IsNumber, IsOptional, IsString, MinLength } from "class-vali
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A new bank-register movement. `amount` is signed: positive = ingreso,
|
* A new bank-register movement. `amount` is signed: positive = ingreso,
|
||||||
* negative = egreso (the module's sign convention). Single currency (MXN).
|
* negative = egreso (the module's sign convention). The currency is the
|
||||||
|
* account's, not the movement's — `bankAccountId` decides it.
|
||||||
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
|
* Booked rows are never edited — a mistake is corrected by voiding + re-capture.
|
||||||
*/
|
*/
|
||||||
export class CreateBankMovementDto {
|
export class CreateBankMovementDto {
|
||||||
|
/** Which chequera this lands in. Required — see BankAccount in the schema. */
|
||||||
|
@IsString() @MinLength(1) bankAccountId!: string;
|
||||||
|
|
||||||
@IsNumber() amount!: number;
|
@IsNumber() amount!: number;
|
||||||
@IsString() @MinLength(1) transactionDate!: string;
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -20,6 +21,12 @@ import {
|
|||||||
BankSort,
|
BankSort,
|
||||||
} from "./bank.service";
|
} from "./bank.service";
|
||||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateBankDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateBankDto,
|
||||||
|
} from "./bank-account.dto";
|
||||||
|
|
||||||
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
const DIRECTIONS: BankDirection[] = ["income", "expense", "void"];
|
||||||
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
const CLEARED: BankCleared[] = ["cleared", "pending"];
|
||||||
@@ -54,28 +61,108 @@ export class BankController {
|
|||||||
return (req.user as { id: string }).id;
|
return (req.user as { id: string }).id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- accounts -------------------------------------------------------------
|
||||||
|
// Declared before the parameterised routes below so `/bank/accounts` can
|
||||||
|
// never be swallowed by a `:id`-shaped path.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account picker. Readable by any authenticated user, VIEWER included —
|
||||||
|
* nothing else on this page can render until an account is chosen.
|
||||||
|
*/
|
||||||
|
@Get("accounts")
|
||||||
|
accounts() {
|
||||||
|
return this.bank.listAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("banks")
|
||||||
|
banks() {
|
||||||
|
return this.bank.listBanks();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("banks")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async createBank(@Body() dto: CreateBankDto, @Req() req: Request) {
|
||||||
|
const row = await this.bank.createBank(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.bank.create", {
|
||||||
|
bankId: row.id,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("banks/:id")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async updateBank(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateBankDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.bank.updateBank(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.bank.update", { bankId: id });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("accounts")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async createAccount(
|
||||||
|
@Body() dto: CreateBankAccountDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.bank.createAccount(dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.account.create", {
|
||||||
|
bankAccountId: row.id,
|
||||||
|
label: row.label,
|
||||||
|
currency: row.currency,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("accounts/:id")
|
||||||
|
@RequireAbility("bank:manage-accounts")
|
||||||
|
async updateAccount(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: UpdateBankAccountDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const row = await this.bank.updateAccount(id, dto);
|
||||||
|
void this.audit.log(this.actingId(req), "bank.account.update", {
|
||||||
|
bankAccountId: id,
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- register reads (all scoped to one account) ---------------------------
|
||||||
|
|
||||||
@Get("stats")
|
@Get("stats")
|
||||||
stats() {
|
async stats(@Query("bankAccountId") bankAccountId?: string) {
|
||||||
return this.bank.stats();
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.stats(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("facets")
|
@Get("facets")
|
||||||
facets() {
|
async facets(@Query("bankAccountId") bankAccountId?: string) {
|
||||||
return this.bank.facets();
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
|
return this.bank.facets(account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Year and month rollups with a running net-movement figure. */
|
/** Year and month rollups with a running net-movement figure. */
|
||||||
@Get("summary")
|
@Get("summary")
|
||||||
summary(@Query("year") year?: string) {
|
async summary(
|
||||||
|
@Query("bankAccountId") bankAccountId?: string,
|
||||||
|
@Query("year") year?: string,
|
||||||
|
) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
const y = Number(year);
|
const y = Number(year);
|
||||||
return this.bank.summary(
|
return this.bank.summary(
|
||||||
|
account.id,
|
||||||
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
Number.isInteger(y) && y >= 1900 && y <= 2999 ? y : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The register browser. */
|
/** The register browser. */
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
async list(
|
||||||
|
@Query("bankAccountId") bankAccountId?: string,
|
||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@Query("page") page?: string,
|
@Query("page") page?: string,
|
||||||
@Query("pageSize") pageSize?: string,
|
@Query("pageSize") pageSize?: string,
|
||||||
@@ -85,7 +172,9 @@ export class BankController {
|
|||||||
@Query("to") to?: string,
|
@Query("to") to?: string,
|
||||||
@Query("sort") sort?: string,
|
@Query("sort") sort?: string,
|
||||||
) {
|
) {
|
||||||
|
const account = await this.bank.requireAccount(bankAccountId);
|
||||||
return this.bank.list({
|
return this.bank.list({
|
||||||
|
bankAccountId: account.id,
|
||||||
query,
|
query,
|
||||||
page: Math.max(1, Number(page) || 1),
|
page: Math.max(1, Number(page) || 1),
|
||||||
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
@@ -105,6 +194,7 @@ export class BankController {
|
|||||||
const row = await this.bank.createMovement(dto);
|
const row = await this.bank.createMovement(dto);
|
||||||
void this.audit.log(this.actingId(req), "bank.create", {
|
void this.audit.log(this.actingId(req), "bank.create", {
|
||||||
bankTransactionId: row.id,
|
bankTransactionId: row.id,
|
||||||
|
bankAccountId: row.bankAccountId,
|
||||||
amount: dto.amount,
|
amount: dto.amount,
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { BadRequestException, Injectable, NotFoundException } from "@nestjs/comm
|
|||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateBankMovementDto } from "./bank-movement.dto";
|
import { CreateBankMovementDto } from "./bank-movement.dto";
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateBankDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateBankDto,
|
||||||
|
} from "./bank-account.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-voided rows (voidedAt set) are reversed and must leave every
|
* App-voided rows (voidedAt set) are reversed and must leave every
|
||||||
@@ -28,9 +34,18 @@ const NOT_VOIDED: Prisma.BankTransactionWhereInput = { voidedAt: null };
|
|||||||
* expense and are excluded from both sides, the way the ~193 zero rows are
|
* expense and are excluded from both sides, the way the ~193 zero rows are
|
||||||
* in the customer ledger.
|
* in the customer ledger.
|
||||||
*
|
*
|
||||||
* SINGLE CURRENCY. Unlike the customer ledger there is no currency column here:
|
* ONE ACCOUNT AT A TIME, CURRENCY FROM THE ACCOUNT. The office now keeps more
|
||||||
* `bank_transactions` has none, and every `amountInWords` on the egreso side is
|
* than one chequera (Utilities banks in MXN, Seguros in USD), so every read
|
||||||
* spelled out in PESOS. All figures in this module are MXN.
|
* path here is scoped to exactly one `bankAccountId` — never "all accounts".
|
||||||
|
* There is deliberately no currency column on `bank_transactions`: a movement
|
||||||
|
* inherits its account's, the way a real bank account doesn't mix currencies.
|
||||||
|
* Callers must therefore pass an account id; an unscoped total would sum MXN
|
||||||
|
* and USD into a figure that never existed, the same mistake the billing
|
||||||
|
* module's per-currency rule exists to prevent.
|
||||||
|
*
|
||||||
|
* The 22,669 migrated rows are all SCOTHIA = the Utilities MXN account
|
||||||
|
* (backfilled by `migration/backfill_bank_accounts.py`), and their
|
||||||
|
* `amountInWords` on the egreso side is spelled out in PESOS accordingly.
|
||||||
*
|
*
|
||||||
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
* NO CATEGORY DIMENSION. `bank_transactions.categoryId` is NULL on all 22,354
|
||||||
* rows and this module does not filter or group by it, because the data cannot
|
* rows and this module does not filter or group by it, because the data cannot
|
||||||
@@ -64,6 +79,8 @@ export type BankSort =
|
|||||||
| "reference";
|
| "reference";
|
||||||
|
|
||||||
export interface BankListParams {
|
export interface BankListParams {
|
||||||
|
/** Which chequera to read. Required — see the module header. */
|
||||||
|
bankAccountId: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
@@ -98,7 +115,9 @@ export class BankService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
private where(p: BankListParams): Prisma.BankTransactionWhereInput {
|
||||||
const and: Prisma.BankTransactionWhereInput[] = [];
|
const and: Prisma.BankTransactionWhereInput[] = [
|
||||||
|
{ bankAccountId: p.bankAccountId },
|
||||||
|
];
|
||||||
|
|
||||||
if (p.query && p.query.trim()) {
|
if (p.query && p.query.trim()) {
|
||||||
const q = p.query.trim();
|
const q = p.query.trim();
|
||||||
@@ -124,7 +143,9 @@ export class BankService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return and.length ? { AND: and } : {};
|
// Never empty: the account clause above is always present, so no read can
|
||||||
|
// accidentally span every chequera.
|
||||||
|
return { AND: and };
|
||||||
}
|
}
|
||||||
|
|
||||||
private orderBy(
|
private orderBy(
|
||||||
@@ -233,22 +254,25 @@ export class BankService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-line figures for the bank page header. */
|
/** Top-line figures for the bank page header, for one chequera. */
|
||||||
async stats() {
|
async stats(bankAccountId: string) {
|
||||||
|
const account = { bankAccountId };
|
||||||
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
const [count, bounds, pending, transferred, totals] = await Promise.all([
|
||||||
this.prisma.bankTransaction.count({ where: NOT_VOIDED }),
|
this.prisma.bankTransaction.count({
|
||||||
|
where: { AND: [account, NOT_VOIDED] },
|
||||||
|
}),
|
||||||
this.prisma.bankTransaction.aggregate({
|
this.prisma.bankTransaction.aggregate({
|
||||||
where: NOT_VOIDED,
|
where: { AND: [account, NOT_VOIDED] },
|
||||||
_min: { transactionDate: true },
|
_min: { transactionDate: true },
|
||||||
_max: { transactionDate: true },
|
_max: { transactionDate: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({
|
this.prisma.bankTransaction.count({
|
||||||
where: { AND: [{ cleared: false }, NOT_VOIDED] },
|
where: { AND: [account, { cleared: false }, NOT_VOIDED] },
|
||||||
}),
|
}),
|
||||||
this.prisma.bankTransaction.count({
|
this.prisma.bankTransaction.count({
|
||||||
where: { AND: [{ transferred: true }, NOT_VOIDED] },
|
where: { AND: [account, { transferred: true }, NOT_VOIDED] },
|
||||||
}),
|
}),
|
||||||
this.totalsFor({}),
|
this.totalsFor(account),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -261,14 +285,16 @@ export class BankService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Year list for the period filter, newest first. */
|
/** Year list for the period filter, newest first, for one chequera. */
|
||||||
async facets() {
|
async facets(bankAccountId: string) {
|
||||||
|
// Tagged-template `$queryRaw`: the interpolation below is a bound
|
||||||
|
// parameter, not string concatenation.
|
||||||
const years = await this.prisma.$queryRaw<
|
const years = await this.prisma.$queryRaw<
|
||||||
{ year: number; count: bigint | number | string }[]
|
{ year: number; count: bigint | number | string }[]
|
||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE voidedAt IS NULL
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||||
GROUP BY year
|
GROUP BY year
|
||||||
ORDER BY year DESC
|
ORDER BY year DESC
|
||||||
`;
|
`;
|
||||||
@@ -287,8 +313,12 @@ export class BankService {
|
|||||||
* `BAN` table holds only the bank's name), so the register starts at zero on
|
* `BAN` table holds only the bank's name), so the register starts at zero on
|
||||||
* its first row in 2013 and the running figure is the net movement since
|
* its first row in 2013 and the running figure is the net movement since
|
||||||
* then. Labelled as such in the UI so it is never read as a statement balance.
|
* then. Labelled as such in the UI so it is never read as a statement balance.
|
||||||
|
*
|
||||||
|
* Both rollups take the SAME `bankAccountId`. Scoping only one of them would
|
||||||
|
* leave the year list and its month drill-down describing different books —
|
||||||
|
* wrong in a way that still looks right.
|
||||||
*/
|
*/
|
||||||
async summary(year?: number) {
|
async summary(bankAccountId: string, year?: number) {
|
||||||
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
const years = await this.prisma.$queryRaw<PeriodRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
YEAR(transactionDate) AS period,
|
YEAR(transactionDate) AS period,
|
||||||
@@ -297,7 +327,7 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE voidedAt IS NULL
|
WHERE voidedAt IS NULL AND bankAccountId = ${bankAccountId}
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`;
|
`;
|
||||||
@@ -311,7 +341,9 @@ export class BankService {
|
|||||||
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) AS expense,
|
||||||
SUM(amount) AS net
|
SUM(amount) AS net
|
||||||
FROM bank_transactions
|
FROM bank_transactions
|
||||||
WHERE YEAR(transactionDate) = ${year} AND voidedAt IS NULL
|
WHERE YEAR(transactionDate) = ${year}
|
||||||
|
AND voidedAt IS NULL
|
||||||
|
AND bankAccountId = ${bankAccountId}
|
||||||
GROUP BY period
|
GROUP BY period
|
||||||
ORDER BY period ASC
|
ORDER BY period ASC
|
||||||
`
|
`
|
||||||
@@ -365,13 +397,135 @@ export class BankService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- accounts -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every chequera, closed ones included — a closed account still has to be
|
||||||
|
* selectable to read its history, it just isn't offered for new captures.
|
||||||
|
*/
|
||||||
|
async listAccounts() {
|
||||||
|
const rows = await this.prisma.bankAccount.findMany({
|
||||||
|
orderBy: [{ active: "desc" }, { label: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
label: true,
|
||||||
|
currency: true,
|
||||||
|
businessLine: true,
|
||||||
|
active: true,
|
||||||
|
bank: { select: { id: true, name: true, country: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
label: a.label,
|
||||||
|
currency: a.currency,
|
||||||
|
businessLine: a.businessLine,
|
||||||
|
active: a.active,
|
||||||
|
bankId: a.bank.id,
|
||||||
|
bankName: a.bank.name,
|
||||||
|
bankCountry: a.bank.country,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBanks() {
|
||||||
|
return this.prisma.bank.findMany({
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true, country: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an account id from a request, or reject. Every read route funnels
|
||||||
|
* through this so a bad/missing id is a 400 rather than a silently empty
|
||||||
|
* register that reads as "this account has no movements".
|
||||||
|
*/
|
||||||
|
async requireAccount(bankAccountId: string | undefined) {
|
||||||
|
if (!bankAccountId || !bankAccountId.trim())
|
||||||
|
throw new BadRequestException("Falta la cuenta bancaria (bankAccountId)");
|
||||||
|
const account = await this.prisma.bankAccount.findUnique({
|
||||||
|
where: { id: bankAccountId },
|
||||||
|
select: { id: true, label: true, currency: true, active: true },
|
||||||
|
});
|
||||||
|
if (!account)
|
||||||
|
throw new NotFoundException(`Cuenta bancaria ${bankAccountId} no existe`);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBank(dto: CreateBankDto) {
|
||||||
|
return this.prisma.bank.create({
|
||||||
|
data: { name: dto.name.trim(), country: dto.country?.trim() || null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBank(id: string, dto: UpdateBankDto) {
|
||||||
|
await this.getBankOr404(id);
|
||||||
|
return this.prisma.bank.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.country !== undefined
|
||||||
|
? { country: dto.country.trim() || null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBankOr404(id: string) {
|
||||||
|
const bank = await this.prisma.bank.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!bank) throw new NotFoundException(`Banco ${id} no existe`);
|
||||||
|
return bank;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(dto: CreateBankAccountDto) {
|
||||||
|
await this.getBankOr404(dto.bankId);
|
||||||
|
return this.prisma.bankAccount.create({
|
||||||
|
data: {
|
||||||
|
bankId: dto.bankId,
|
||||||
|
label: dto.label.trim(),
|
||||||
|
currency: dto.currency,
|
||||||
|
businessLine: dto.businessLine ?? null,
|
||||||
|
active: dto.active ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `currency` is intentionally absent from the update DTO: the movements
|
||||||
|
* already booked in this account are denominated in it, so changing it would
|
||||||
|
* silently re-denominate history rather than convert it.
|
||||||
|
*/
|
||||||
|
async updateAccount(id: string, dto: UpdateBankAccountDto) {
|
||||||
|
await this.requireAccount(id);
|
||||||
|
if (dto.bankId !== undefined) await this.getBankOr404(dto.bankId);
|
||||||
|
return this.prisma.bankAccount.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.bankId !== undefined ? { bankId: dto.bankId } : {}),
|
||||||
|
...(dto.label !== undefined ? { label: dto.label.trim() } : {}),
|
||||||
|
...(dto.businessLine !== undefined
|
||||||
|
? { businessLine: dto.businessLine }
|
||||||
|
: {}),
|
||||||
|
...(dto.active !== undefined ? { active: dto.active } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- writes (append + void) -----------------------------------------------
|
// --- writes (append + void) -----------------------------------------------
|
||||||
|
|
||||||
async createMovement(dto: CreateBankMovementDto) {
|
async createMovement(dto: CreateBankMovementDto) {
|
||||||
const date = new Date(dto.transactionDate);
|
const date = new Date(dto.transactionDate);
|
||||||
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha inválida");
|
||||||
|
const account = await this.requireAccount(dto.bankAccountId);
|
||||||
|
if (!account.active)
|
||||||
|
throw new BadRequestException(
|
||||||
|
`La cuenta "${account.label}" está cerrada; no admite movimientos nuevos.`,
|
||||||
|
);
|
||||||
return this.prisma.bankTransaction.create({
|
return this.prisma.bankTransaction.create({
|
||||||
data: {
|
data: {
|
||||||
|
bankAccountId: account.id,
|
||||||
amount: dto.amount,
|
amount: dto.amount,
|
||||||
transactionDate: date,
|
transactionDate: date,
|
||||||
concept: dto.concept,
|
concept: dto.concept,
|
||||||
|
|||||||
@@ -5,5 +5,8 @@ import { BillingService } from "./billing.service";
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [BillingController],
|
controllers: [BillingController],
|
||||||
providers: [BillingService],
|
providers: [BillingService],
|
||||||
|
// The statements module posts confirmed OCR captures through
|
||||||
|
// BillingService.createBatch rather than writing Transaction rows itself.
|
||||||
|
exports: [BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
+21
-1
@@ -25,6 +25,26 @@ async function bootstrap() {
|
|||||||
throw new Error("SESSION_SECRET must be set (see .env.example)");
|
throw new Error("SESSION_SECRET must be set (see .env.example)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the session cookie carries the Secure flag. This CANNOT simply
|
||||||
|
// follow NODE_ENV: express-session silently declines to send a Secure cookie
|
||||||
|
// over a plain-HTTP connection, so a production image served over http://ial
|
||||||
|
// issues no cookie at all. Login then returns 200 with a user, no session is
|
||||||
|
// established, every later request 403s, and the UI loops back to /login —
|
||||||
|
// which is exactly what happened on the first galactus deploy.
|
||||||
|
//
|
||||||
|
// Leave it ON wherever the app is reached over TLS. Turn it OFF only for a
|
||||||
|
// deployment that is HTTP but reached over an already-encrypted transport
|
||||||
|
// (the galactus install is Tailscale-only, so WireGuard encrypts the wire).
|
||||||
|
// Behind a TLS-terminating proxy, set trust proxy instead of turning this off.
|
||||||
|
// An EMPTY value counts as unset, not as "false". Compose interpolation turns
|
||||||
|
// an absent `${SESSION_COOKIE_SECURE:-}` into the empty string, so testing
|
||||||
|
// `!== undefined` here would silently drop the Secure flag on any deployment
|
||||||
|
// that merely passes the variable through without setting it.
|
||||||
|
const cookieSecureRaw = process.env.SESSION_COOKIE_SECURE;
|
||||||
|
const cookieSecure = cookieSecureRaw
|
||||||
|
? cookieSecureRaw === "true"
|
||||||
|
: process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
session({
|
session({
|
||||||
secret: sessionSecret,
|
secret: sessionSecret,
|
||||||
@@ -32,7 +52,7 @@ async function bootstrap() {
|
|||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: cookieSecure,
|
||||||
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
|
maxAge: 1000 * 60 * 60 * 8, // 8-hour session, matches a staff workday
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ export const INGEST_FILES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type IngestName = (typeof INGEST_FILES)[number];
|
export type IngestName = (typeof INGEST_FILES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix for every command containing a pipe. Without it the exit status of
|
||||||
|
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||||
|
* like a successful job. Both Alpine's busybox ash (the API image) and macOS
|
||||||
|
* `sh` (dev) support it; POSIX does not require it, so `sh -c` is the contract.
|
||||||
|
*/
|
||||||
|
const PIPEFAIL = "set -o pipefail; ";
|
||||||
|
|
||||||
interface MysqlConn {
|
interface MysqlConn {
|
||||||
host: string;
|
host: string;
|
||||||
port: string;
|
port: string;
|
||||||
@@ -175,7 +183,7 @@ export class OpsService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = this.parseDbUrl();
|
const conn = this.opsConn();
|
||||||
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
|
const { cmd, resolvedParams } = await this.buildCommand(kind, params, conn);
|
||||||
|
|
||||||
const job = await this.prisma.opsJob.create({
|
const job = await this.prisma.opsJob.create({
|
||||||
@@ -207,6 +215,37 @@ export class OpsService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credentials mysqldump/mysql run as — deliberately NOT the application
|
||||||
|
* user. `--single-transaction` issues FLUSH TABLES, which needs the global
|
||||||
|
* RELOAD privilege, and the app user is granted only `ALL ON jorgecuadros.*`
|
||||||
|
* plus `USAGE ON *.*`; `--skip-lock-tables` does not avoid it. A restore of a
|
||||||
|
* dump taken before --set-gtid-purged=OFF likewise needs SUPER to replay its
|
||||||
|
* SET @@GLOBAL.GTID_PURGED. So an admin credential is supplied out of band
|
||||||
|
* rather than elevating the runtime user for the sake of one admin screen —
|
||||||
|
* the same choice deploy/scripts/pre-migrate-backup.mjs makes.
|
||||||
|
*
|
||||||
|
* Host, port and database always come from DATABASE_URL: the ops user is a
|
||||||
|
* different login on the SAME server, never a way to point at another one.
|
||||||
|
*
|
||||||
|
* With the vars unset this falls back to the DATABASE_URL credentials, which
|
||||||
|
* is what local development wants — a dev MySQL grants the app user far more.
|
||||||
|
*/
|
||||||
|
private opsConn(): MysqlConn {
|
||||||
|
const conn = this.parseDbUrl();
|
||||||
|
const user = process.env.OPS_DB_ADMIN_USER;
|
||||||
|
const password = process.env.OPS_DB_ADMIN_PASSWORD;
|
||||||
|
if (!user || !password) {
|
||||||
|
this.logger.warn(
|
||||||
|
"OPS_DB_ADMIN_USER/OPS_DB_ADMIN_PASSWORD no configuradas; " +
|
||||||
|
`usando el usuario de la aplicación (${conn.user}) para mysqldump. ` +
|
||||||
|
"En producción esto falla por falta del privilegio RELOAD.",
|
||||||
|
);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
return { ...conn, user, password };
|
||||||
|
}
|
||||||
|
|
||||||
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
|
/** mysql/mysqldump connection flags. The password goes through MYSQL_PWD in
|
||||||
* the child env, never on the command line (which would leak via `ps`). */
|
* the child env, never on the command line (which would leak via `ps`). */
|
||||||
private connFlags(c: MysqlConn): string {
|
private connFlags(c: MysqlConn): string {
|
||||||
@@ -217,6 +256,37 @@ export class OpsService implements OnModuleInit {
|
|||||||
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
return new Date().toISOString().replace(/[:.]/g, "-").replace("T", "_").slice(0, 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One hardened mysqldump, shared by BACKUP and by the safety backups SYNC and
|
||||||
|
* REIMPORT take first. Kept byte-for-byte in spirit with the dump in
|
||||||
|
* deploy/scripts/pre-migrate-backup.mjs — the two write into the same volume
|
||||||
|
* and both are listed as restore points by this same screen.
|
||||||
|
*
|
||||||
|
* --set-gtid-purged=OFF: the production server is the replication SOURCE with
|
||||||
|
* GTID on, so without it every dump embeds SET @@GLOBAL.GTID_PURGED and is
|
||||||
|
* unrestorable onto the very server it came from.
|
||||||
|
*
|
||||||
|
* The table-count assertion is not belt-and-braces: `gzip -t` passes on the
|
||||||
|
* ~372-byte output of a mysqldump that died on its first statement, so a
|
||||||
|
* failed dump would otherwise be recorded as a successful backup. (`set -o
|
||||||
|
* pipefail` is set by the caller for the same reason — without it the exit
|
||||||
|
* status of the pipeline is gzip's, and gzip succeeded.)
|
||||||
|
*
|
||||||
|
* A failed attempt deletes its own output, so a truncated file never appears
|
||||||
|
* in the restore list looking like an ordinary restore point.
|
||||||
|
*/
|
||||||
|
private dumpCommand(flags: string, db: string, out: string): string {
|
||||||
|
return (
|
||||||
|
`( mysqldump ${flags} --single-transaction --routines --triggers ` +
|
||||||
|
`--no-tablespaces --set-gtid-purged=OFF ${db} | gzip -c > ${out} && ` +
|
||||||
|
`gzip -t ${out} && ` +
|
||||||
|
`TABLAS=$(gunzip -c ${out} | grep -c 'CREATE TABLE') && ` +
|
||||||
|
`echo "tablas capturadas: $TABLAS" && ` +
|
||||||
|
`[ "$TABLAS" -ge 1 ] ) || ` +
|
||||||
|
`{ rm -f ${out}; echo 'respaldo incompleto eliminado'; exit 1; }`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async buildCommand(
|
private async buildCommand(
|
||||||
kind: OpsJobKind,
|
kind: OpsJobKind,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
@@ -229,7 +299,7 @@ export class OpsService implements OnModuleInit {
|
|||||||
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
const file = `backup-${this.migrationEnv}-${this.timestamp()}.sql.gz`;
|
||||||
const out = shq(path.join(this.backupDir, file));
|
const out = shq(path.join(this.backupDir, file));
|
||||||
return {
|
return {
|
||||||
cmd: `mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out}`,
|
cmd: `${PIPEFAIL}${this.dumpCommand(flags, db, out)}`,
|
||||||
resolvedParams: { file },
|
resolvedParams: { file },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,7 +311,10 @@ export class OpsService implements OnModuleInit {
|
|||||||
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
throw new NotFoundException(`Respaldo no encontrado: ${name}`);
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
cmd: `gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
|
// pipefail matters here too: a corrupt archive makes gunzip fail while
|
||||||
|
// mysql, fed a truncated stream, can still exit 0 — a restore that
|
||||||
|
// reported success having replayed only part of the dump.
|
||||||
|
cmd: `${PIPEFAIL}gunzip -c ${shq(full)} | mysql ${flags} ${db}`,
|
||||||
resolvedParams: { file: name },
|
resolvedParams: { file: name },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -252,8 +325,8 @@ export class OpsService implements OnModuleInit {
|
|||||||
const py = await this.pythonBin();
|
const py = await this.pythonBin();
|
||||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
const cmd =
|
const cmd =
|
||||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
`echo '== Sincronización aditiva desde carpeta de ingesta ==' && ` +
|
||||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --sync`;
|
||||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
@@ -266,8 +339,8 @@ export class OpsService implements OnModuleInit {
|
|||||||
const py = await this.pythonBin();
|
const py = await this.pythonBin();
|
||||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
const cmd =
|
const cmd =
|
||||||
`echo '== Respaldo de seguridad previo ==' && ` +
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
`mysqldump ${flags} --single-transaction --routines --triggers --no-tablespaces ${db} | gzip -c > ${out} && ` +
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
||||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
return { cmd, resolvedParams: { safetyBackup: file } };
|
||||||
@@ -287,6 +360,15 @@ export class OpsService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `password` is the ops credential from opsConn(), exported as MYSQL_PWD so it
|
||||||
|
* never reaches argv (which `ps` exposes to every process on the host).
|
||||||
|
*
|
||||||
|
* It does not leak into the Python ETL that SYNC and REIMPORT go on to run:
|
||||||
|
* migration/dbenv.py connects with pymysql using the credentials inside
|
||||||
|
* DATABASE_URL and never consults MYSQL_PWD. The ETL keeps running as the
|
||||||
|
* application user, which is what it should be doing.
|
||||||
|
*/
|
||||||
private run(jobId: string, cmd: string, password: string): void {
|
private run(jobId: string, cmd: string, password: string): void {
|
||||||
const child = spawn("sh", ["-c", cmd], {
|
const child = spawn("sh", ["-c", cmd], {
|
||||||
cwd: this.migrationDir,
|
cwd: this.migrationDir,
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* The OCR seam. Everything above this interface works in terms of page text and
|
||||||
|
* word boxes, so the concrete engine is swappable without touching the parsers,
|
||||||
|
* the matcher, or the schema.
|
||||||
|
*
|
||||||
|
* The shipped implementation is self-hosted Tesseract (see tesseract.provider).
|
||||||
|
* That choice is evidence-based rather than assumed: run against 46 pages of
|
||||||
|
* real scanned CFE, CESPT and Telnor statements, it identified the provider on
|
||||||
|
* 46/46 and extracted a usable account reference on 43/46, which is well past
|
||||||
|
* the bar for a queue whose whole point is that a human confirms every row. A
|
||||||
|
* managed document-extraction API (Textract, Document Intelligence, Document
|
||||||
|
* AI) fits behind this same interface if per-page accuracy ever proves
|
||||||
|
* insufficient, with no schema change — but at 300+ pages/month/company it
|
||||||
|
* would carry a real recurring cost for accuracy that is not currently the
|
||||||
|
* bottleneck.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One OCR'd word, with where it sits on the page. */
|
||||||
|
export interface OcrWord {
|
||||||
|
text: string;
|
||||||
|
/** Pixel box in the rendered page image. */
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
/** Engine confidence for this word, 0..1. */
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrPage {
|
||||||
|
/** Full page text, reading order, newline-separated. */
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* Word boxes. Needed because two of the three real layouts are *tables* —
|
||||||
|
* the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with the
|
||||||
|
* value in the row beneath it, which line-oriented text cannot associate.
|
||||||
|
* Parsers fall back to geometry for exactly those fields.
|
||||||
|
*/
|
||||||
|
words: OcrWord[];
|
||||||
|
/** Mean word confidence across the page, 0..1. */
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OcrProvider {
|
||||||
|
/** True when the engine is actually usable in this deployment. */
|
||||||
|
available(): Promise<boolean>;
|
||||||
|
/** Split a PDF into one rendered page image per page. */
|
||||||
|
renderPages(pdf: Buffer): Promise<Buffer[]>;
|
||||||
|
/** OCR a single rendered page image. */
|
||||||
|
recognize(pageImage: Buffer): Promise<OcrPage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OCR_PROVIDER = Symbol("OCR_PROVIDER");
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { OcrPage, OcrProvider, OcrWord } from "./ocr.provider";
|
||||||
|
|
||||||
|
const run = promisify(execFile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-hosted OCR: `pdftoppm` (poppler) to rasterise, `tesseract` to read.
|
||||||
|
*
|
||||||
|
* Both are external binaries rather than a native npm addon, which keeps the
|
||||||
|
* pnpm workspace free of a compiled dependency and makes the alpine runtime
|
||||||
|
* image a two-package change (see docker/api.Dockerfile). Like StorageService,
|
||||||
|
* a missing binary degrades rather than crashes the API: the module reports
|
||||||
|
* itself unavailable and statement ingest returns 503, while every other
|
||||||
|
* feature keeps working.
|
||||||
|
*
|
||||||
|
* The settings below are not arbitrary — they were measured against the real
|
||||||
|
* scanned samples:
|
||||||
|
* - 300 DPI grayscale. The source scans are phone photos of paper at ~5MB a
|
||||||
|
* page; below 300 the small print (RMU, clave catastral) stops resolving,
|
||||||
|
* above it costs time for no additional fields.
|
||||||
|
* - `--psm 6` ("assume a single uniform block of text"). The default page
|
||||||
|
* segmentation splits these dense forms into columns and interleaves them,
|
||||||
|
* which destroys the label-then-value adjacency every parser depends on.
|
||||||
|
* - Spanish traineddata, with a graceful fall back to English if the language
|
||||||
|
* pack is absent — an accented label reads worse but the digits, which are
|
||||||
|
* what actually gets matched, are unaffected.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TesseractOcrProvider implements OcrProvider {
|
||||||
|
private readonly logger = new Logger(TesseractOcrProvider.name);
|
||||||
|
private readonly dpi: number;
|
||||||
|
private readonly lang: string;
|
||||||
|
private probe: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
this.dpi = Number(config.get("OCR_DPI") ?? 300);
|
||||||
|
this.lang = config.get<string>("OCR_LANG") ?? "spa";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached — the binaries do not appear or vanish while the process runs. */
|
||||||
|
available(): Promise<boolean> {
|
||||||
|
if (!this.probe) {
|
||||||
|
this.probe = (async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
run("tesseract", ["--version"]),
|
||||||
|
run("pdftoppm", ["-v"]),
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(
|
||||||
|
"OCR unavailable: `tesseract` and/or `pdftoppm` not found on PATH. " +
|
||||||
|
"Statement ingest is disabled; every other feature is unaffected.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return this.probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async require(): Promise<void> {
|
||||||
|
if (!(await this.available())) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"El servicio de OCR no está disponible en este servidor.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async scratch<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), "stmt-ocr-"));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderPages(pdf: Buffer): Promise<Buffer[]> {
|
||||||
|
await this.require();
|
||||||
|
return this.scratch(async (dir) => {
|
||||||
|
const src = join(dir, "in.pdf");
|
||||||
|
await writeFile(src, pdf);
|
||||||
|
// -gray: these are grayscale scans already; colour triples the bytes
|
||||||
|
// handed to tesseract for no gain in character recognition.
|
||||||
|
await run("pdftoppm", [
|
||||||
|
"-r",
|
||||||
|
String(this.dpi),
|
||||||
|
"-gray",
|
||||||
|
"-png",
|
||||||
|
src,
|
||||||
|
join(dir, "page"),
|
||||||
|
]);
|
||||||
|
const files = (await readdir(dir))
|
||||||
|
.filter((f) => f.startsWith("page") && f.endsWith(".png"))
|
||||||
|
// pdftoppm zero-pads its page numbers, so lexical order is page order.
|
||||||
|
.sort();
|
||||||
|
return Promise.all(files.map((f) => readFile(join(dir, f))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async recognize(pageImage: Buffer): Promise<OcrPage> {
|
||||||
|
await this.require();
|
||||||
|
return this.scratch(async (dir) => {
|
||||||
|
const img = join(dir, "page.png");
|
||||||
|
await writeFile(img, pageImage);
|
||||||
|
|
||||||
|
// One tesseract invocation produces both outputs; TSV carries the word
|
||||||
|
// boxes and per-word confidence, and its text can be reassembled into
|
||||||
|
// reading order, so there is no need to run the engine twice.
|
||||||
|
const out = join(dir, "out");
|
||||||
|
try {
|
||||||
|
await run("tesseract", [img, out, "-l", this.lang, "--psm", "6", "tsv"]);
|
||||||
|
} catch (err) {
|
||||||
|
if (this.lang !== "eng") {
|
||||||
|
this.logger.warn(
|
||||||
|
`Tesseract failed with lang "${this.lang}", retrying with "eng": ${
|
||||||
|
(err as Error).message
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
await run("tesseract", [img, out, "-l", "eng", "--psm", "6", "tsv"]);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsv = await readFile(`${out}.tsv`, "utf8");
|
||||||
|
return parseTsv(tsv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn tesseract's TSV into words plus reassembled text.
|
||||||
|
*
|
||||||
|
* Columns are: level, page_num, block_num, par_num, line_num, word_num, left,
|
||||||
|
* top, width, height, conf, text. Rows with level < 5 are structural (page,
|
||||||
|
* block, paragraph, line) and carry no text; only level 5 is a word. A conf of
|
||||||
|
* -1 marks a structural row, so those are dropped rather than averaged in —
|
||||||
|
* including them would drag every page's confidence toward zero.
|
||||||
|
*/
|
||||||
|
export function parseTsv(tsv: string): OcrPage {
|
||||||
|
const lines = tsv.split("\n");
|
||||||
|
const header = lines[0]?.split("\t") ?? [];
|
||||||
|
const col = (name: string) => header.indexOf(name);
|
||||||
|
const iLeft = col("left");
|
||||||
|
const iTop = col("top");
|
||||||
|
const iWidth = col("width");
|
||||||
|
const iHeight = col("height");
|
||||||
|
const iConf = col("conf");
|
||||||
|
const iText = col("text");
|
||||||
|
const iLine = col("line_num");
|
||||||
|
const iBlock = col("block_num");
|
||||||
|
|
||||||
|
const words: OcrWord[] = [];
|
||||||
|
// Keyed by block+line so the reassembled text preserves the engine's own
|
||||||
|
// reading order instead of sorting words by raw y, which interleaves columns.
|
||||||
|
const byLine = new Map<string, string[]>();
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const f = lines[i].split("\t");
|
||||||
|
if (f.length <= iText) continue;
|
||||||
|
const text = f[iText]?.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
const confidence = Number(f[iConf]);
|
||||||
|
if (!Number.isFinite(confidence) || confidence < 0) continue;
|
||||||
|
|
||||||
|
words.push({
|
||||||
|
text,
|
||||||
|
left: Number(f[iLeft]) || 0,
|
||||||
|
top: Number(f[iTop]) || 0,
|
||||||
|
width: Number(f[iWidth]) || 0,
|
||||||
|
height: Number(f[iHeight]) || 0,
|
||||||
|
confidence: confidence / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const key = `${f[iBlock]}:${f[iLine]}`;
|
||||||
|
const bucket = byLine.get(key);
|
||||||
|
if (bucket) bucket.push(text);
|
||||||
|
else byLine.set(key, [text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = [...byLine.values()].map((w) => w.join(" ")).join("\n");
|
||||||
|
const confidence = words.length
|
||||||
|
? words.reduce((sum, w) => sum + w.confidence, 0) / words.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { text, words, confidence };
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import type { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import type { OcrPage, OcrWord } from "../ocr/ocr.provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one parsed statement page yields. `accountRef` is already normalised to
|
||||||
|
* the form the migrated `PropertyService` columns hold, so the matcher compares
|
||||||
|
* like with like and never has to know about provider-specific formatting.
|
||||||
|
*/
|
||||||
|
export interface ParsedStatement {
|
||||||
|
/** "CFE" | "CESPT" | "TELNOR", or null when no parser claimed the page. */
|
||||||
|
provider: string | null;
|
||||||
|
serviceKind: ServiceKind | null;
|
||||||
|
accountRef: string | null;
|
||||||
|
/** Clave catastral, when printed — a second key to match on. */
|
||||||
|
cadastralKey: string | null;
|
||||||
|
amount: number | null;
|
||||||
|
dueDate: Date | null;
|
||||||
|
period: string | null;
|
||||||
|
/**
|
||||||
|
* Independent corroboration of `accountRef`. CFE and Telnor both print a
|
||||||
|
* payment barcode that repeats the account number (and the amount), so when
|
||||||
|
* the barcode and the label agree the extraction is near-certainly right;
|
||||||
|
* when they disagree, or only one is present, the page is worth a human
|
||||||
|
* glance. Null when the layout has no second source.
|
||||||
|
*/
|
||||||
|
crossChecked: boolean | null;
|
||||||
|
/** Human-readable trail of what was read, surfaced in the review queue. */
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tesseract confuses these glyphs inside numeric runs with some regularity —
|
||||||
|
* a real clave catastral `KB078025` came back as `KBO78025`. Applied ONLY to
|
||||||
|
* fields known to be digits, never to free text, where it would corrupt words.
|
||||||
|
*/
|
||||||
|
const DIGIT_CONFUSIONS: Record<string, string> = {
|
||||||
|
O: "0",
|
||||||
|
o: "0",
|
||||||
|
D: "0",
|
||||||
|
I: "1",
|
||||||
|
l: "1",
|
||||||
|
"|": "1",
|
||||||
|
S: "5",
|
||||||
|
B: "8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toDigits(s: string | null | undefined): string {
|
||||||
|
if (!s) return "";
|
||||||
|
return s
|
||||||
|
.split("")
|
||||||
|
.map((c) => DIGIT_CONFUSIONS[c] ?? c)
|
||||||
|
.join("")
|
||||||
|
.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a printed amount, treating `,` and `.` by position rather than by
|
||||||
|
* assumption. A real Telnor bill OCR'd as "$ 649,00" — blindly stripping commas
|
||||||
|
* as thousands separators turned $649.00 into $64,900, a hundredfold error that
|
||||||
|
* would post silently. Two trailing digits after a single separator are always
|
||||||
|
* cents here; a separator followed by three digits is a thousands group.
|
||||||
|
*/
|
||||||
|
function money(s: string | null | undefined): number | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const cleaned = s.replace(/[\s$]/g, "");
|
||||||
|
|
||||||
|
// 1.234,56 or 1,234.56 — grouped thousands plus optional cents.
|
||||||
|
let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/);
|
||||||
|
if (m) {
|
||||||
|
const whole = m[1].replace(/[.,]/g, "");
|
||||||
|
const cents = m[2] ? m[2].slice(1) : "";
|
||||||
|
return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 649,00 / 649.00 — a single separator with exactly two digits after it.
|
||||||
|
m = cleaned.match(/^(\d+)[.,](\d{2})$/);
|
||||||
|
if (m) return Number(`${m[1]}.${m[2]}`);
|
||||||
|
|
||||||
|
const n = Number(cleaned.replace(/[,.]/g, ""));
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstMatch(text: string, patterns: RegExp[]): string | null {
|
||||||
|
for (const p of patterns) {
|
||||||
|
const m = text.match(p);
|
||||||
|
if (m?.[1]) return m[1].trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS: Record<string, number> = {
|
||||||
|
ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5,
|
||||||
|
JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Parses the three date shapes these statements actually print. */
|
||||||
|
export function parseDate(raw: string | null | undefined): Date | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const s = raw.trim().toUpperCase();
|
||||||
|
|
||||||
|
// 16/07/2026
|
||||||
|
let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||||
|
if (m) return utc(+m[3], +m[2] - 1, +m[1]);
|
||||||
|
|
||||||
|
// 22-JUL-2026 / 22 JUN 26
|
||||||
|
m = s.match(/^(\d{1,2})[-\s]([A-Z]{3})[A-Z]*[-\s](\d{2,4})$/);
|
||||||
|
if (m && MONTHS[m[2]] !== undefined) {
|
||||||
|
const y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
|
||||||
|
return utc(y, MONTHS[m[2]], +m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2026-07-22 (already normalised, e.g. decoded from a barcode)
|
||||||
|
m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (m) return utc(+m[1], +m[2] - 1, +m[3]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utc(y: number, mo: number, d: number): Date | null {
|
||||||
|
const dt = new Date(Date.UTC(y, mo, d));
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the value printed *underneath* a column header.
|
||||||
|
*
|
||||||
|
* The CESPT "RECIBO" is a table: `No. DE CUENTA` is a header cell and its value
|
||||||
|
* sits in the row below it, so no amount of label-adjacent regex on line text
|
||||||
|
* can associate the two. This walks the word boxes instead — find the header
|
||||||
|
* word, then take the nearest word below it whose horizontal centre falls
|
||||||
|
* within the column.
|
||||||
|
*/
|
||||||
|
export function valueUnder(
|
||||||
|
page: OcrPage,
|
||||||
|
header: RegExp,
|
||||||
|
opts: { maxDy?: number; tolerance?: number; match?: RegExp } = {},
|
||||||
|
): string | null {
|
||||||
|
const { maxDy = 300, tolerance = 200, match } = opts;
|
||||||
|
const centre = (w: OcrWord) => ({
|
||||||
|
x: w.left + w.width / 2,
|
||||||
|
y: w.top + w.height / 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const h of page.words.filter((w) => header.test(w.text))) {
|
||||||
|
const hc = centre(h);
|
||||||
|
const below = page.words
|
||||||
|
.filter((w) => {
|
||||||
|
const c = centre(w);
|
||||||
|
return c.y > hc.y && c.y <= hc.y + maxDy && Math.abs(c.x - hc.x) <= tolerance;
|
||||||
|
})
|
||||||
|
.sort((a, b) => centre(a).y - centre(b).y);
|
||||||
|
|
||||||
|
for (const w of below) {
|
||||||
|
if (!match || match.test(w.text)) return w.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- provider detection -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand wordmarks first, page structure only as a fallback — and the two passes
|
||||||
|
* must not be interleaved. Scanned logos OCR badly (one CESPT header came back
|
||||||
|
* as "E BAJA ES PAGO / EALIFORNIA", with neither "CESPT" nor "COMISIÓN ESTATAL"
|
||||||
|
* readable), so the structural pass is what rescues those pages. But a Telnor
|
||||||
|
* bill contains the words "Pagar antes de", which a CFE structural rule
|
||||||
|
* evaluated first will happily claim — running all brand checks before any
|
||||||
|
* structural check is what keeps that from happening.
|
||||||
|
*/
|
||||||
|
const BRAND: [string, RegExp][] = [
|
||||||
|
["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i],
|
||||||
|
["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i],
|
||||||
|
["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i],
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAYOUT: [string, RegExp][] = [
|
||||||
|
["CFE", /NO\.?\s*DE\s*SERVICIO|L[IÍ]MITE\s*DE\s*PAGO|PERIODO\s*FACTURADO/i],
|
||||||
|
["CESPT", /SALDO\s+CORRIENTE|CLAVE\s*CATASTRAL|No\.?\s*DE\s*CUENTA/i],
|
||||||
|
["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function detectProvider(text: string): string | null {
|
||||||
|
for (const group of [BRAND, LAYOUT]) {
|
||||||
|
for (const [name, pattern] of group) {
|
||||||
|
if (pattern.test(text)) return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CFE (electric) ---------------------------------------------------------
|
||||||
|
|
||||||
|
function parseCfe(page: OcrPage): ParsedStatement {
|
||||||
|
const text = page.text;
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
// The payment barcode line repeats the service number, the due date (YYMMDD)
|
||||||
|
// and the amount in one fixed-width run, and reads far more reliably than the
|
||||||
|
// label: on one sample the label came back as "0059603001917" (a digit too
|
||||||
|
// many) while its barcode gave the correct "005960300191". So the barcode
|
||||||
|
// wins, and the label becomes the cross-check rather than the source.
|
||||||
|
const barcode = text.match(/\b01\s+([0-9OIlSBD]{12})\s+([0-9OIlSBD]{6})\s+([0-9OIlSBD]{9})\b/);
|
||||||
|
const label = firstMatch(text, [/NO\.?\s*DE\s*SERVICIO\s*[:;.]?\s*([0-9OIlSBD]{10,14})/i]);
|
||||||
|
|
||||||
|
let accountRef: string | null = null;
|
||||||
|
let amount: number | null = null;
|
||||||
|
let dueDate: Date | null = null;
|
||||||
|
let crossChecked: boolean | null = null;
|
||||||
|
|
||||||
|
if (barcode) {
|
||||||
|
// Leading zeros are print padding: DATMEX.rpu holds the bare 10 digits.
|
||||||
|
accountRef = toDigits(barcode[1]).replace(/^0+/, "");
|
||||||
|
amount = Number(toDigits(barcode[3]));
|
||||||
|
const d = toDigits(barcode[2]);
|
||||||
|
dueDate = parseDate(`20${d.slice(0, 2)}-${d.slice(2, 4)}-${d.slice(4, 6)}`);
|
||||||
|
notes.push("importe y vencimiento leídos del código de barras");
|
||||||
|
if (label) {
|
||||||
|
crossChecked = toDigits(label).replace(/^0+/, "") === accountRef;
|
||||||
|
if (!crossChecked) {
|
||||||
|
notes.push(
|
||||||
|
`el número impreso (${toDigits(label).replace(/^0+/, "")}) no coincide con el código de barras`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (label) {
|
||||||
|
accountRef = toDigits(label).replace(/^0+/, "");
|
||||||
|
notes.push("sin código de barras legible; número tomado de la etiqueta");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount == null) {
|
||||||
|
amount = money(firstMatch(text, [/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d*)/i]));
|
||||||
|
}
|
||||||
|
if (!dueDate) {
|
||||||
|
dueDate = parseDate(
|
||||||
|
firstMatch(text, [/L[IÍ]MITE\s*DE\s*PAGO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2,4})/i]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "CFE",
|
||||||
|
serviceKind: "ELECTRIC",
|
||||||
|
accountRef: accountRef || null,
|
||||||
|
cadastralKey: null,
|
||||||
|
amount,
|
||||||
|
dueDate,
|
||||||
|
period: firstMatch(text, [
|
||||||
|
/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2}\s*-\s*\d{1,2}\s+\w{3}\s+\d{2})/i,
|
||||||
|
]),
|
||||||
|
crossChecked,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CESPT (water) ----------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two different layouts arrive under the same brand:
|
||||||
|
* - the line-oriented "COMPROBANTE DE PAGO" (`Cuenta : 7604192`), and
|
||||||
|
* - the tabular "RECIBO", where `No. DE CUENTA` is a column header.
|
||||||
|
* Line patterns are tried first; anything they miss falls through to the
|
||||||
|
* geometric read, which is what the tabular layout needs.
|
||||||
|
*/
|
||||||
|
function parseCespt(page: OcrPage): ParsedStatement {
|
||||||
|
const text = page.text;
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
let account = firstMatch(text, [/Cuenta\s*[:;.]?\s*([0-9OIlSBD]{5,9})/i]);
|
||||||
|
if (!account) {
|
||||||
|
account = valueUnder(page, /^CUENTA$/i, { match: /^[0-9OIlSBD]{5,9}$/ });
|
||||||
|
if (account) notes.push("número de cuenta leído de la columna del recibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
let clave = firstMatch(text, [/Cve\.?\s*Cat\.?\s*[:;.]?\s*([A-Z]{2}\s?[0-9OIlSBD]{6})/i]);
|
||||||
|
if (!clave) {
|
||||||
|
clave = valueUnder(page, /^CATASTRAL$/i, { match: /^[A-Z]{2}[0-9OIlSBD]{6}$/i });
|
||||||
|
if (clave) notes.push("clave catastral leída de la columna del recibo");
|
||||||
|
}
|
||||||
|
|
||||||
|
let due = firstMatch(text, [/Fecha\s*Venc\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]);
|
||||||
|
if (!due) due = valueUnder(page, /^VENCIMIENTO$/i, { match: /^\d{2}\/\d{2}\/\d{4}$/ });
|
||||||
|
|
||||||
|
const amount = money(
|
||||||
|
firstMatch(text, [
|
||||||
|
/TOTAL\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i,
|
||||||
|
/SALDO\s+CORRIENTE[^\n]*?([\d,]+\.\d{2})/i,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leading zeros are print padding here too: the RECIBO prints `0457341` for
|
||||||
|
// what DATMEX.agua holds as `457341`.
|
||||||
|
const accountRef = account ? toDigits(account).replace(/^0+/, "") : null;
|
||||||
|
const cadastralKey = clave
|
||||||
|
? clave.replace(/\s/g, "").slice(0, 2).toUpperCase() +
|
||||||
|
toDigits(clave.replace(/\s/g, "").slice(2))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "CESPT",
|
||||||
|
serviceKind: "WATER",
|
||||||
|
accountRef: accountRef || null,
|
||||||
|
cadastralKey: cadastralKey || null,
|
||||||
|
amount,
|
||||||
|
dueDate: parseDate(due),
|
||||||
|
period: null,
|
||||||
|
crossChecked: null,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TELNOR (telephone) -----------------------------------------------------
|
||||||
|
|
||||||
|
function parseTelnor(page: OcrPage): ParsedStatement {
|
||||||
|
const text = page.text;
|
||||||
|
const notes: string[] = [];
|
||||||
|
|
||||||
|
const label = firstMatch(text, [
|
||||||
|
/Tel[eé]fono\s*[:;.]?\s*([0-9OIlSBD]{3}\s?[0-9OIlSBD]{3}\s?[0-9OIlSBD]{4})/i,
|
||||||
|
]);
|
||||||
|
// The payment stub prints phone (10 digits) + amount in cents (9) + a check
|
||||||
|
// digit: `6646093444 000099900 7` for a $999.00 bill. Reading the amount as
|
||||||
|
// 10 digits swallows the check digit and inflates the figure 100-fold.
|
||||||
|
const barcode = text.match(/\b(\d{10})(\d{9})\d\b/);
|
||||||
|
|
||||||
|
let accountRef: string | null = null;
|
||||||
|
let crossChecked: boolean | null = null;
|
||||||
|
|
||||||
|
// The bill prints the number with its 664 Tijuana LADA; DATMEX stores the
|
||||||
|
// bare local 7 digits, so the LADA is dropped rather than the stored value
|
||||||
|
// being padded — padding would guess at an area code for the 500+ existing
|
||||||
|
// rows that never recorded one.
|
||||||
|
if (label) accountRef = toDigits(label).slice(-7);
|
||||||
|
if (barcode) {
|
||||||
|
const fromBarcode = barcode[1].slice(-7);
|
||||||
|
if (accountRef) {
|
||||||
|
crossChecked = fromBarcode === accountRef;
|
||||||
|
if (!crossChecked) notes.push("el teléfono impreso no coincide con el código de barras");
|
||||||
|
} else {
|
||||||
|
accountRef = fromBarcode;
|
||||||
|
notes.push("teléfono leído del código de barras");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let amount = money(firstMatch(text, [/Total\s*a\s*Pagar\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i]));
|
||||||
|
if (amount == null && barcode) {
|
||||||
|
amount = Number(barcode[2]) / 100;
|
||||||
|
notes.push("importe leído del código de barras");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: "TELNOR",
|
||||||
|
serviceKind: "TELEPHONE",
|
||||||
|
accountRef: accountRef || null,
|
||||||
|
cadastralKey: null,
|
||||||
|
amount,
|
||||||
|
dueDate: parseDate(
|
||||||
|
firstMatch(text, [/Pagar\s*antes\s*de\s*[:;.]?\s*(\d{2}-\w{3}-\d{4})/i]),
|
||||||
|
),
|
||||||
|
period: firstMatch(text, [/Mes\s*de\s*Facturaci[oó]n\s*[:;.]?\s*(\w+)/i]),
|
||||||
|
crossChecked,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARSERS: Record<string, (page: OcrPage) => ParsedStatement> = {
|
||||||
|
CFE: parseCfe,
|
||||||
|
CESPT: parseCespt,
|
||||||
|
TELNOR: parseTelnor,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY: ParsedStatement = {
|
||||||
|
provider: null,
|
||||||
|
serviceKind: null,
|
||||||
|
accountRef: null,
|
||||||
|
cadastralKey: null,
|
||||||
|
amount: null,
|
||||||
|
dueDate: null,
|
||||||
|
period: null,
|
||||||
|
crossChecked: null,
|
||||||
|
notes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Detect the provider and run its parser. */
|
||||||
|
export function parseStatement(page: OcrPage): ParsedStatement {
|
||||||
|
const provider = detectProvider(page.text);
|
||||||
|
if (!provider) return { ...EMPTY, notes: ["no se reconoció el proveedor"] };
|
||||||
|
return PARSERS[provider](page);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { ServiceKind } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { ParsedStatement } from "./parsers/statement-parser";
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
propertyServiceId: string | null;
|
||||||
|
customerId: string | null;
|
||||||
|
/** Why it landed here — shown in the review queue verbatim. */
|
||||||
|
note: string;
|
||||||
|
/** True only for an unambiguous hit on the scoped field. */
|
||||||
|
confident: boolean;
|
||||||
|
/** Populated when more than one service claims the same number. */
|
||||||
|
candidates: { propertyServiceId: string; customerId: string; customerName: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a parsed statement to the customer who should be billed for it.
|
||||||
|
*
|
||||||
|
* Two rules govern everything here.
|
||||||
|
*
|
||||||
|
* **Match on one scoped field, never fuzzily across all identifiers.** Each
|
||||||
|
* service kind has exactly one column its statements print, and only that
|
||||||
|
* column is consulted. A blanket search over accountNumber/meterNumber/route
|
||||||
|
* would let a water account number collide with an unrelated phone number, and
|
||||||
|
* the resulting mis-post would look perfectly ordinary in the ledger.
|
||||||
|
*
|
||||||
|
* **Never match on the customer name.** The name on a utility bill is the
|
||||||
|
* account's registrant, which drifts from the current owner and is often years
|
||||||
|
* stale — one sample CESPT receipt is printed to "ARNAIZ ROSAS ELSA AURORA"
|
||||||
|
* for an account this office holds under "CATT, RANDY", who is not the same
|
||||||
|
* person. Names are displayed for the reviewer to sanity-check, and are never
|
||||||
|
* an input to matching.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class StatementMatcherService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Which PropertyService column a given kind's statements actually print. */
|
||||||
|
private fieldFor(kind: ServiceKind): "accountNumber" | "meterNumber" | null {
|
||||||
|
switch (kind) {
|
||||||
|
case "ELECTRIC": // CFE "NO. DE SERVICIO" -> DATMEX.rpu
|
||||||
|
case "WATER": // CESPT "Cuenta" / "No. DE CUENTA" -> DATMEX.agua
|
||||||
|
case "TELEPHONE": // Telnor "Teléfono" (LADA stripped) -> DATMEX.telefono
|
||||||
|
case "FEDERAL_ZONE":
|
||||||
|
case "CABLE":
|
||||||
|
return "accountNumber";
|
||||||
|
case "GAS": // no account column in DATMEX; the number lived in notes
|
||||||
|
return "meterNumber";
|
||||||
|
// PROPERTY_TAX deliberately has no scoped column: what its
|
||||||
|
// accountNumber holds is DATMEX.predial, which is neither unique nor
|
||||||
|
// printed on any statement. Predial bills match on the clave catastral
|
||||||
|
// alone — see matchByCadastralKey.
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async match(parsed: ParsedStatement, expectedKind: ServiceKind): Promise<MatchResult> {
|
||||||
|
const kind = parsed.serviceKind ?? expectedKind;
|
||||||
|
|
||||||
|
// The uploader labels a batch with one service kind. If the parser reads a
|
||||||
|
// page as a different provider, that is a mis-sorted page, not a match —
|
||||||
|
// posting it would book a phone bill as a water charge.
|
||||||
|
if (parsed.serviceKind && parsed.serviceKind !== expectedKind) {
|
||||||
|
return this.unmatched(
|
||||||
|
`la página parece de ${parsed.provider} (${parsed.serviceKind}) pero el lote es de ${expectedKind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = this.fieldFor(kind);
|
||||||
|
|
||||||
|
if (field && parsed.accountRef) {
|
||||||
|
const hit = await this.byServiceField(kind, field, parsed.accountRef);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary key. The clave catastral is printed on CESPT bills as well as
|
||||||
|
// predial ones, so it rescues a page whose account number did not OCR —
|
||||||
|
// which happened on real samples, where the clave read cleanly and the
|
||||||
|
// account number did not.
|
||||||
|
if (parsed.cadastralKey) {
|
||||||
|
const hit = await this.byCadastralKey(kind, parsed.cadastralKey);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!field && !parsed.cadastralKey) {
|
||||||
|
return this.unmatched(
|
||||||
|
kind === "PROPERTY_TAX"
|
||||||
|
? "el predial sólo se puede identificar por clave catastral y no se leyó ninguna"
|
||||||
|
: `no hay campo de búsqueda definido para ${kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.unmatched(
|
||||||
|
parsed.accountRef
|
||||||
|
? `no se encontró ningún servicio de ${kind} con la referencia ${parsed.accountRef}`
|
||||||
|
: "no se pudo leer la referencia de la cuenta",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async byServiceField(
|
||||||
|
kind: ServiceKind,
|
||||||
|
field: "accountNumber" | "meterNumber",
|
||||||
|
ref: string,
|
||||||
|
): Promise<MatchResult | null> {
|
||||||
|
const rows = await this.prisma.propertyService.findMany({
|
||||||
|
where: { kind, [field]: ref },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
property: {
|
||||||
|
select: { customerId: true, customer: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = rows.map((r) => ({
|
||||||
|
propertyServiceId: r.id,
|
||||||
|
customerId: r.property.customerId,
|
||||||
|
customerName: r.property.customer.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Duplicate account numbers do occur in the legacy data (the office's own
|
||||||
|
// DUPLICADOS report existed for a reason), so every candidate is surfaced
|
||||||
|
// for the reviewer to choose rather than one being picked arbitrarily.
|
||||||
|
if (rows.length > 1) {
|
||||||
|
return {
|
||||||
|
propertyServiceId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: `${rows.length} servicios comparten la referencia ${ref}`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
propertyServiceId: candidates[0].propertyServiceId,
|
||||||
|
customerId: candidates[0].customerId,
|
||||||
|
note: `coincidencia exacta por ${field === "accountNumber" ? "número de cuenta" : "medidor"} ${ref}`,
|
||||||
|
confident: true,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async byCadastralKey(
|
||||||
|
kind: ServiceKind,
|
||||||
|
key: string,
|
||||||
|
): Promise<MatchResult | null> {
|
||||||
|
const props = await this.prisma.property.findMany({
|
||||||
|
where: { cadastralKey: key },
|
||||||
|
select: {
|
||||||
|
customerId: true,
|
||||||
|
customer: { select: { name: true } },
|
||||||
|
services: { where: { kind }, select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (props.length === 0) return null;
|
||||||
|
|
||||||
|
const candidates = props.flatMap((p) =>
|
||||||
|
(p.services.length ? p.services.map((s) => s.id) : [null]).map((sid) => ({
|
||||||
|
propertyServiceId: sid as string,
|
||||||
|
customerId: p.customerId,
|
||||||
|
customerName: p.customer.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length > 1) {
|
||||||
|
return {
|
||||||
|
propertyServiceId: null,
|
||||||
|
customerId: null,
|
||||||
|
note: `${candidates.length} propiedades comparten la clave catastral ${key}`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The clave identifies the property with certainty, but it is a *secondary*
|
||||||
|
// key: it was not the number the statement was issued against. Left for
|
||||||
|
// review so the confirm also teaches the matcher the account number, rather
|
||||||
|
// than the same page needing the fallback again next month.
|
||||||
|
return {
|
||||||
|
propertyServiceId: candidates[0].propertyServiceId ?? null,
|
||||||
|
customerId: candidates[0].customerId,
|
||||||
|
note: `identificado por clave catastral ${key}; confirme para registrar también el número de cuenta`,
|
||||||
|
confident: false,
|
||||||
|
candidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private unmatched(note: string): MatchResult {
|
||||||
|
return {
|
||||||
|
propertyServiceId: null,
|
||||||
|
customerId: null,
|
||||||
|
note,
|
||||||
|
confident: false,
|
||||||
|
candidates: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
export class CreateStatementBatchDto {
|
||||||
|
@IsEnum(ServiceKind) serviceKind!: ServiceKind;
|
||||||
|
@IsOptional() @IsString() label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff correction of one document's extracted fields or its match. */
|
||||||
|
export class ReviewDocumentDto {
|
||||||
|
@IsOptional() @IsString() accountRef?: string;
|
||||||
|
@IsOptional() @IsNumber() amount?: number;
|
||||||
|
@IsOptional() @IsString() period?: string;
|
||||||
|
@IsOptional() @IsString() dueDate?: string;
|
||||||
|
@IsOptional() @IsString() matchedPropertyServiceId?: string;
|
||||||
|
@IsOptional() @IsString() matchedCustomerId?: string;
|
||||||
|
// Restricted to the review-reachable states: a client cannot declare a
|
||||||
|
// document POSTED, because only a successful ledger write may do that.
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(StatementDocumentStatus)
|
||||||
|
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a batch's confirmed documents. The check-level fields are shared by
|
||||||
|
* every line, exactly as on the manual batch-capture screen — an OCR batch is
|
||||||
|
* still "these receipts, paid by this check".
|
||||||
|
*/
|
||||||
|
export class ConfirmBatchDto {
|
||||||
|
@IsString() @MinLength(1) checkNumber!: string;
|
||||||
|
@IsString() @MinLength(1) transactionDate!: string;
|
||||||
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
|
/** Overrides the concept derived from the batch's service kind. */
|
||||||
|
@IsOptional() @IsString() typeId?: string;
|
||||||
|
/** Post as outstanding (sin fondos) — captured but not yet funded. */
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
/** Also post documents a reviewer explicitly marked CONFIRMED. */
|
||||||
|
@IsOptional() @IsBoolean() includeReviewed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListBatchesQuery {
|
||||||
|
@IsOptional() @IsInt() page?: number;
|
||||||
|
@IsOptional() @IsInt() pageSize?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FilesInterceptor } from "@nestjs/platform-express";
|
||||||
|
import type { ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||||
|
import { AbilityGuard } from "../auth/ability.guard";
|
||||||
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
|
import { AuditService } from "../common/audit.service";
|
||||||
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { StatementsService } from "./statements.service";
|
||||||
|
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
||||||
|
*
|
||||||
|
* Nothing here writes to the ledger directly — confirming a batch delegates to
|
||||||
|
* BillingService, so an OCR-captured charge is indistinguishable from a
|
||||||
|
* hand-keyed one except for its `captureSource`.
|
||||||
|
*/
|
||||||
|
@Controller("statements")
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
export class StatementsController {
|
||||||
|
constructor(
|
||||||
|
private readonly statements: StatementsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string } | undefined)?.id ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this deployment can ingest scans at all — the UI hides automatic
|
||||||
|
* capture without it. Both halves are needed: OCR to read the page, object
|
||||||
|
* storage to keep it.
|
||||||
|
*/
|
||||||
|
@Get("status")
|
||||||
|
async status() {
|
||||||
|
return {
|
||||||
|
ocrAvailable: await this.statements.ocrAvailable(),
|
||||||
|
storageAvailable: this.statements.storageAvailable(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("batches")
|
||||||
|
listBatches(@Query("page") page?: string, @Query("pageSize") pageSize?: string) {
|
||||||
|
return this.statements.listBatches(
|
||||||
|
Math.max(1, Number(page) || 1),
|
||||||
|
Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("batches/:id")
|
||||||
|
getBatch(@Param("id") id: string) {
|
||||||
|
return this.statements.getBatch(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("batches/:id/documents")
|
||||||
|
listDocuments(@Param("id") id: string, @Query("status") status?: string) {
|
||||||
|
return this.statements.listDocuments(
|
||||||
|
id,
|
||||||
|
(status || undefined) as StatementDocumentStatus | undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rendered page, so a reviewer can compare it against what was read. */
|
||||||
|
@Get("documents/:id/page")
|
||||||
|
async pageImage(@Param("id") id: string, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const { stream, contentType, contentLength } = await this.statements.pageImage(id);
|
||||||
|
res.set({
|
||||||
|
"Content-Type": contentType ?? "image/png",
|
||||||
|
...(contentLength ? { "Content-Length": String(contentLength) } : {}),
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@Post("batches")
|
||||||
|
@RequireAbility("statement:ingest")
|
||||||
|
@UseInterceptors(
|
||||||
|
// A month of one company's statements is a handful of multi-page scans;
|
||||||
|
// 25 files at 50MB covers that with room to spare.
|
||||||
|
FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }),
|
||||||
|
)
|
||||||
|
async createBatch(
|
||||||
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||||
|
@Query("serviceKind") serviceKind: ServiceKind,
|
||||||
|
@Query("label") label: string | undefined,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const batch = await this.statements.createBatch(
|
||||||
|
files ?? [],
|
||||||
|
serviceKind,
|
||||||
|
this.actingId(req),
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
void this.audit.log(this.actingId(req), "statement.batch.create", {
|
||||||
|
batchId: batch.id,
|
||||||
|
serviceKind,
|
||||||
|
fileCount: batch.fileCount,
|
||||||
|
});
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("documents/:id")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async review(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ReviewDocumentDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const doc = await this.statements.review(id, dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.document.review", {
|
||||||
|
documentId: id,
|
||||||
|
status: doc.status,
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("documents/:id/reject")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async reject(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const doc = await this.statements.reject(id, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.document.reject", {
|
||||||
|
documentId: id,
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post every matched document in the batch, against one check. */
|
||||||
|
@Post("batches/:id/confirm")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async confirm(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: ConfirmBatchDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const result = await this.statements.confirmBatch(id, dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.batch.confirm", {
|
||||||
|
batchId: id,
|
||||||
|
posted: result.posted,
|
||||||
|
total: result.total,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { BillingModule } from "../billing/billing.module";
|
||||||
|
import { StatementsController } from "./statements.controller";
|
||||||
|
import { StatementsService } from "./statements.service";
|
||||||
|
import { StatementMatcherService } from "./statement-matcher.service";
|
||||||
|
import { OCR_PROVIDER } from "./ocr/ocr.provider";
|
||||||
|
import { TesseractOcrProvider } from "./ocr/tesseract.provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The concrete OCR engine is bound here and nowhere else — everything
|
||||||
|
* downstream depends on the OcrProvider interface, so swapping Tesseract for a
|
||||||
|
* managed extraction API is a one-line change in this file.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [BillingModule],
|
||||||
|
controllers: [StatementsController],
|
||||||
|
providers: [
|
||||||
|
StatementsService,
|
||||||
|
StatementMatcherService,
|
||||||
|
{ provide: OCR_PROVIDER, useClass: TesseractOcrProvider },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class StatementsModule {}
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
Prisma,
|
||||||
|
type ServiceKind,
|
||||||
|
type StatementDocumentStatus,
|
||||||
|
} from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { StorageService } from "../storage/storage.service";
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
|
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||||
|
import { parseStatement } from "./parsers/statement-parser";
|
||||||
|
import { StatementMatcherService } from "./statement-matcher.service";
|
||||||
|
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default ledger concept per service kind. The names are the legacy
|
||||||
|
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
||||||
|
* per confirm rather than hard-coded as ids, which differ per environment.
|
||||||
|
*/
|
||||||
|
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||||
|
ELECTRIC: "ELECTRIC",
|
||||||
|
WATER: "WATER",
|
||||||
|
TELEPHONE: "TELEPHONE",
|
||||||
|
GAS: "GAS BUTANO",
|
||||||
|
PROPERTY_TAX: "PROPERTY TAXES",
|
||||||
|
FEDERAL_ZONE: "FEDERAL ZONE",
|
||||||
|
CABLE: "CABLE",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statuses a document can still be worked on from. */
|
||||||
|
const OPEN: StatementDocumentStatus[] = ["NEEDS_REVIEW", "MATCHED", "CONFIRMED"];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StatementsService {
|
||||||
|
private readonly logger = new Logger(StatementsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly matcher: StatementMatcherService,
|
||||||
|
@Inject(OCR_PROVIDER) private readonly ocr: OcrProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ocrAvailable(): Promise<boolean> {
|
||||||
|
return this.ocr.available();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scans are stored as blobs, so no object storage means no intake. */
|
||||||
|
storageAvailable(): boolean {
|
||||||
|
return this.storage.available;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ingest ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a batch of scanned PDFs and start processing.
|
||||||
|
*
|
||||||
|
* Processing is kicked off but deliberately not awaited: 300 pages of OCR is
|
||||||
|
* minutes of CPU, far past any sane HTTP timeout. The caller gets the batch
|
||||||
|
* id immediately and polls its status, which is also what lets the review
|
||||||
|
* queue show partial progress.
|
||||||
|
*/
|
||||||
|
async createBatch(
|
||||||
|
files: UploadedFileLike[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
uploadedById: string,
|
||||||
|
label?: string,
|
||||||
|
) {
|
||||||
|
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
||||||
|
if (!(await this.ocr.available())) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"El servidor no tiene OCR instalado; no se pueden procesar recibos.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Checked here rather than at the first `put`, which would only surface as
|
||||||
|
// a FAILED batch minutes later.
|
||||||
|
if (!this.storage.available) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"El almacenamiento de documentos no está configurado; no se pueden " +
|
||||||
|
"guardar los recibos escaneados.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const batch = await this.prisma.statementBatch.create({
|
||||||
|
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buffers are held for the background pass; the request's own copies would
|
||||||
|
// otherwise be garbage once the response is sent.
|
||||||
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||||
|
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
||||||
|
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batch.id },
|
||||||
|
data: { status: "FAILED", error: (err as Error).message },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render → OCR → parse → match, one document row per page. */
|
||||||
|
private async process(
|
||||||
|
batchId: string,
|
||||||
|
files: { buffer: Buffer; name?: string }[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
) {
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "PROCESSING" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let pageNumber = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
// The source PDF is kept as well as the page images: it is the artifact
|
||||||
|
// the office actually received, and the only way to re-run a corrected
|
||||||
|
// parser over the original later.
|
||||||
|
const sourceKey = `statement/${batchId}/source-${pageNumber + 1}.pdf`;
|
||||||
|
await this.storage.put(sourceKey, file.buffer, "application/pdf");
|
||||||
|
|
||||||
|
const pages = await this.ocr.renderPages(file.buffer);
|
||||||
|
for (const image of pages) {
|
||||||
|
pageNumber += 1;
|
||||||
|
const storageKey = `statement/${batchId}/page-${pageNumber}.png`;
|
||||||
|
await this.storage.put(storageKey, image, "image/png");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ocr = await this.ocr.recognize(image);
|
||||||
|
const parsed = parseStatement(ocr);
|
||||||
|
const match = await this.matcher.match(parsed, serviceKind);
|
||||||
|
|
||||||
|
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||||
|
// A confident field match is only trusted when nothing contradicts
|
||||||
|
// it: a barcode that disagrees with the printed number means one of
|
||||||
|
// the two was misread, and which one is a judgement call.
|
||||||
|
const trusted = match.confident && parsed.crossChecked !== false;
|
||||||
|
|
||||||
|
await this.prisma.statementDocument.create({
|
||||||
|
data: {
|
||||||
|
batchId,
|
||||||
|
pageNumber,
|
||||||
|
storageKey,
|
||||||
|
status: trusted ? "MATCHED" : "NEEDS_REVIEW",
|
||||||
|
ocrRawText: ocr.text,
|
||||||
|
ocrConfidence: new Prisma.Decimal(ocr.confidence.toFixed(3)),
|
||||||
|
provider: parsed.provider,
|
||||||
|
extractedAccountRef: parsed.accountRef,
|
||||||
|
extractedAmount:
|
||||||
|
parsed.amount != null ? new Prisma.Decimal(parsed.amount) : null,
|
||||||
|
extractedPeriod: parsed.period,
|
||||||
|
extractedDueDate: parsed.dueDate,
|
||||||
|
extractedCadastralKey: parsed.cadastralKey,
|
||||||
|
matchedPropertyServiceId: match.propertyServiceId,
|
||||||
|
matchedCustomerId: match.customerId,
|
||||||
|
matchNote: notes.join("; ").slice(0, 190),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// One unreadable page must not abandon the other 299.
|
||||||
|
await this.prisma.statementDocument.create({
|
||||||
|
data: {
|
||||||
|
batchId,
|
||||||
|
pageNumber,
|
||||||
|
storageKey,
|
||||||
|
status: "OCR_FAILED",
|
||||||
|
matchNote: (err as Error).message.slice(0, 190),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "READY_FOR_REVIEW" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reads ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async listBatches(page: number, pageSize: number) {
|
||||||
|
const [total, items] = await this.prisma.$transaction([
|
||||||
|
this.prisma.statementBatch.count(),
|
||||||
|
this.prisma.statementBatch.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: {
|
||||||
|
uploadedBy: { select: { name: true } },
|
||||||
|
_count: { select: { documents: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBatch(id: string) {
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { uploadedBy: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||||
|
|
||||||
|
const counts = await this.prisma.statementDocument.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
where: { batchId: id },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const totals = await this.prisma.statementDocument.aggregate({
|
||||||
|
where: { batchId: id, status: { in: OPEN } },
|
||||||
|
_sum: { extractedAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...batch,
|
||||||
|
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||||
|
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDocuments(batchId: string, status?: StatementDocumentStatus) {
|
||||||
|
return this.prisma.statementDocument.findMany({
|
||||||
|
where: { batchId, ...(status ? { status } : {}) },
|
||||||
|
orderBy: { pageNumber: "asc" },
|
||||||
|
include: {
|
||||||
|
matchedCustomer: { select: { id: true, name: true } },
|
||||||
|
matchedPropertyService: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
kind: true,
|
||||||
|
accountNumber: true,
|
||||||
|
meterNumber: true,
|
||||||
|
property: { select: { id: true, addressLine1: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rendered page image, so a reviewer can read what the parser read. */
|
||||||
|
async pageImage(documentId: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({
|
||||||
|
where: { id: documentId },
|
||||||
|
select: { storageKey: true },
|
||||||
|
});
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
return this.storage.getStream(doc.storageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- review ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Staff correction of an extracted field or of the match itself. */
|
||||||
|
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
if (doc.status === "POSTED") {
|
||||||
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing the service implies its owner; deriving the customer here rather
|
||||||
|
// than trusting a client-supplied pair is what stops a page being posted to
|
||||||
|
// one customer's ledger against another customer's service.
|
||||||
|
let matchedCustomerId = doc.matchedCustomerId;
|
||||||
|
let matchedPropertyServiceId = dto.matchedPropertyServiceId ?? undefined;
|
||||||
|
|
||||||
|
if (dto.matchedPropertyServiceId) {
|
||||||
|
const svc = await this.prisma.propertyService.findUnique({
|
||||||
|
where: { id: dto.matchedPropertyServiceId },
|
||||||
|
select: { property: { select: { customerId: true } } },
|
||||||
|
});
|
||||||
|
if (!svc) throw new BadRequestException("Servicio no encontrado.");
|
||||||
|
matchedCustomerId = svc.property.customerId;
|
||||||
|
} else if (dto.matchedCustomerId) {
|
||||||
|
matchedCustomerId = dto.matchedCustomerId;
|
||||||
|
|
||||||
|
// A reviewer picks a *customer*, not one of their service rows. Without
|
||||||
|
// a service the posting still works, but the confirmed reference has
|
||||||
|
// nowhere to be written back, so the same account would land in review
|
||||||
|
// again next month — which is exactly the behaviour that is supposed to
|
||||||
|
// make gas (whose numbers the migration never populated) a one-time cost.
|
||||||
|
// So: if the batch's service kind resolves to exactly one of that
|
||||||
|
// customer's services that has no reference yet, attach it. Exactly one
|
||||||
|
// — with two candidates there is no way to tell which meter or line the
|
||||||
|
// bill belongs to, and guessing would write a real number onto the wrong
|
||||||
|
// service.
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id: doc.batchId },
|
||||||
|
select: { serviceKind: true },
|
||||||
|
});
|
||||||
|
if (batch) {
|
||||||
|
const field = batch.serviceKind === "GAS" ? "meterNumber" : "accountNumber";
|
||||||
|
const blank = await this.prisma.propertyService.findMany({
|
||||||
|
where: {
|
||||||
|
kind: batch.serviceKind,
|
||||||
|
[field]: null,
|
||||||
|
property: { customerId: matchedCustomerId },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
take: 2,
|
||||||
|
});
|
||||||
|
if (blank.length === 1) matchedPropertyServiceId = blank[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.statementDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
extractedAccountRef: dto.accountRef ?? undefined,
|
||||||
|
extractedAmount:
|
||||||
|
dto.amount != null ? new Prisma.Decimal(dto.amount) : undefined,
|
||||||
|
extractedPeriod: dto.period ?? undefined,
|
||||||
|
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||||
|
matchedPropertyServiceId,
|
||||||
|
matchedCustomerId,
|
||||||
|
status: dto.status ?? "MATCHED",
|
||||||
|
reviewedById,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, reviewedById: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
if (doc.status === "POSTED") {
|
||||||
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
|
}
|
||||||
|
return this.prisma.statementDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: "REJECTED", reviewedById, reviewedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- posting --------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post every confirmable document in a batch to the ledger.
|
||||||
|
*
|
||||||
|
* This goes through `BillingService.createBatch` — the same method the manual
|
||||||
|
* "Editor" screen uses — rather than writing `Transaction` rows directly, so
|
||||||
|
* OCR-sourced and hand-keyed receipts share one write path, one validation
|
||||||
|
* path and one audit trail. `source: "OCR"` and a per-line `captureRef` of
|
||||||
|
* the document id give the duplicate-post guard something to key on, so a
|
||||||
|
* batch confirmed twice cannot double-charge anyone.
|
||||||
|
*/
|
||||||
|
async confirmBatch(batchId: string, dto: ConfirmBatchDto, reviewedById: string) {
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({
|
||||||
|
where: { id: batchId },
|
||||||
|
});
|
||||||
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||||
|
|
||||||
|
const docs = await this.prisma.statementDocument.findMany({
|
||||||
|
where: {
|
||||||
|
batchId,
|
||||||
|
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
||||||
|
matchedCustomerId: { not: null },
|
||||||
|
},
|
||||||
|
orderBy: { pageNumber: "asc" },
|
||||||
|
});
|
||||||
|
if (!docs.length) {
|
||||||
|
throw new BadRequestException("No hay documentos listos para registrar.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = docs.filter((d) => d.extractedAmount == null);
|
||||||
|
if (missing.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Falta el importe en ${missing.length} documento(s): página(s) ` +
|
||||||
|
missing.map((d) => d.pageNumber).join(", "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
||||||
|
|
||||||
|
const result = await this.billing.createBatch(
|
||||||
|
{
|
||||||
|
domain: "UTILITY",
|
||||||
|
transactionDate: dto.transactionDate,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
currency: dto.currency ?? "MXN",
|
||||||
|
typeId,
|
||||||
|
lines: docs.map((d) => ({
|
||||||
|
customerId: d.matchedCustomerId!,
|
||||||
|
// Charges are negative in this ledger: a negative amount is what the
|
||||||
|
// customer owes. The parser reads the printed (positive) figure, so
|
||||||
|
// the sign is applied here, at the single point where a statement
|
||||||
|
// becomes a ledger row.
|
||||||
|
amount: -Math.abs(Number(d.extractedAmount)),
|
||||||
|
reference: d.extractedAccountRef ?? undefined,
|
||||||
|
period: d.extractedPeriod ?? undefined,
|
||||||
|
outstanding: dto.outstanding ?? false,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ source: "OCR", refs: docs.map((d) => d.id) },
|
||||||
|
);
|
||||||
|
|
||||||
|
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
||||||
|
// the created rows zip straight back onto the documents that produced them.
|
||||||
|
await this.prisma.$transaction(
|
||||||
|
docs.map((d, i) =>
|
||||||
|
this.prisma.statementDocument.update({
|
||||||
|
where: { id: d.id },
|
||||||
|
data: {
|
||||||
|
status: "POSTED",
|
||||||
|
postedTransactionId: result.items[i].id,
|
||||||
|
reviewedById,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Teach the matcher. When a document was matched by clave catastral or by
|
||||||
|
// hand because the scoped field was blank, writing the reference back means
|
||||||
|
// next month's statement for the same account matches on its own — this is
|
||||||
|
// what turns gas (whose numbers the migration never populated) from a
|
||||||
|
// permanent review queue into a one-time cost.
|
||||||
|
await this.learnAccountRefs(docs, batch.serviceKind);
|
||||||
|
|
||||||
|
await this.closeIfDone(batchId);
|
||||||
|
|
||||||
|
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write a confirmed reference onto a service that had none. */
|
||||||
|
private async learnAccountRefs(
|
||||||
|
docs: { matchedPropertyServiceId: string | null; extractedAccountRef: string | null }[],
|
||||||
|
kind: ServiceKind,
|
||||||
|
) {
|
||||||
|
const field = kind === "GAS" ? "meterNumber" : "accountNumber";
|
||||||
|
for (const d of docs) {
|
||||||
|
if (!d.matchedPropertyServiceId || !d.extractedAccountRef) continue;
|
||||||
|
await this.prisma.propertyService.updateMany({
|
||||||
|
// Only fills a hole — never overwrites a number already on file, which
|
||||||
|
// would let one misread page rewrite good reference data.
|
||||||
|
where: { id: d.matchedPropertyServiceId, [field]: null },
|
||||||
|
data: { [field]: d.extractedAccountRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async closeIfDone(batchId: string) {
|
||||||
|
const open = await this.prisma.statementDocument.count({
|
||||||
|
where: { batchId, status: { in: OPEN } },
|
||||||
|
});
|
||||||
|
if (open === 0) {
|
||||||
|
await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: { status: "COMPLETED", completedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async conceptFor(kind: ServiceKind): Promise<string | undefined> {
|
||||||
|
const name = CONCEPT_BY_KIND[kind];
|
||||||
|
if (!name) return undefined;
|
||||||
|
const row = await this.prisma.typeTransaction.findFirst({
|
||||||
|
where: { nameEn: name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return row?.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,16 @@ export class StorageService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the deployment has object storage at all. Callers use this to
|
||||||
|
* refuse work up front instead of failing halfway through — a recibo batch
|
||||||
|
* that dies on its first `put` leaves a FAILED batch and no explanation the
|
||||||
|
* office can act on.
|
||||||
|
*/
|
||||||
|
get available(): boolean {
|
||||||
|
return this.client !== null;
|
||||||
|
}
|
||||||
|
|
||||||
private require(): S3Client {
|
private require(): S3Client {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new ServiceUnavailableException(
|
throw new ServiceUnavailableException(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const safeSelect = {
|
|||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
active: true,
|
active: true,
|
||||||
|
uiScale: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
} satisfies Prisma.UserSelect;
|
} satisfies Prisma.UserSelect;
|
||||||
@@ -101,6 +102,18 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service preference write — no ability check, because the only account
|
||||||
|
* it can touch is the caller's own (the controller passes the session id).
|
||||||
|
*/
|
||||||
|
updatePreferences(id: string, uiScale: number): Promise<SafeUserRow> {
|
||||||
|
return this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { uiScale },
|
||||||
|
select: safeSelect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
|
async resetPassword(id: string, password: string): Promise<SafeUserRow> {
|
||||||
await this.ensureExists(id);
|
await this.ensureExists(id);
|
||||||
const passwordHash = await argon2.hash(password);
|
const passwordHash = await argon2.hash(password);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import {
|
||||||
|
createBankAccount,
|
||||||
|
createBankInstitution,
|
||||||
|
listBankAccounts,
|
||||||
|
listBankInstitutions,
|
||||||
|
updateBankAccount,
|
||||||
|
updateBankInstitution,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { domainLabel } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BankAccount,
|
||||||
|
BankInstitution,
|
||||||
|
Currency,
|
||||||
|
TransactionDomain,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chequera accounts admin — docs/RECEIPT_CAPTURE_SPEC.md §3.
|
||||||
|
*
|
||||||
|
* Two levels: the bank (institution) and the accounts held at it. Opening an
|
||||||
|
* account is rare and consequential — its currency is what every movement
|
||||||
|
* booked into it is denominated in, and it can't be changed afterwards without
|
||||||
|
* silently re-denominating history, so the edit form deliberately has no
|
||||||
|
* currency field.
|
||||||
|
*
|
||||||
|
* Accounts are never deleted: `bank_transactions.bankAccountId` is a required
|
||||||
|
* FK, so a used account can't be removed without destroying its register.
|
||||||
|
* Closing one (`active: false`) hides it from new captures while leaving the
|
||||||
|
* history readable, matching this app's never-hard-delete convention.
|
||||||
|
*/
|
||||||
|
const CURRENCIES: Currency[] = ["MXN", "USD"];
|
||||||
|
const BUSINESS_LINES: TransactionDomain[] = ["UTILITY", "INSURANCE", "TRUST"];
|
||||||
|
|
||||||
|
export default function CuentasChequeraPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Cuentas />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Cuentas() {
|
||||||
|
const canEdit = useCan("bank:manage-accounts");
|
||||||
|
const [banks, setBanks] = useState<BankInstitution[] | null>(null);
|
||||||
|
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
Promise.all([listBankInstitutions(), listBankAccounts()])
|
||||||
|
.then(([b, a]) => {
|
||||||
|
setBanks(b);
|
||||||
|
setAccounts(a);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudieron cargar las cuentas."));
|
||||||
|
}
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Cuentas de chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tiene permisos para administrar cuentas bancarias.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">
|
||||||
|
<Link href="/banco">Chequera</Link>
|
||||||
|
</p>
|
||||||
|
<h1 className="page-title">Cuentas de chequera</h1>
|
||||||
|
<p className="section-note">
|
||||||
|
Cada cuenta es una chequera física y se lleva por separado. La moneda
|
||||||
|
se fija al darla de alta porque todos sus movimientos quedan
|
||||||
|
registrados en ella; para cambiarla hay que abrir otra cuenta. Las
|
||||||
|
cuentas no se eliminan: se cierran, y su historial sigue consultable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
{!banks || !accounts ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
<span className="spinner" aria-label="Cargando" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<BanksSection banks={banks} onChanged={reload} />
|
||||||
|
<AccountsSection
|
||||||
|
banks={banks}
|
||||||
|
accounts={accounts}
|
||||||
|
onChanged={reload}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ banks */
|
||||||
|
|
||||||
|
function BanksSection({
|
||||||
|
banks,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
banks: BankInstitution[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [country, setCountry] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setAdding(true);
|
||||||
|
setName("");
|
||||||
|
setCountry("");
|
||||||
|
}
|
||||||
|
function startEdit(b: BankInstitution) {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(b.id);
|
||||||
|
setName(b.name);
|
||||||
|
setCountry(b.country ?? "");
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!name.trim()) {
|
||||||
|
window.alert("El nombre del banco es obligatorio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const payload = { name: name.trim(), country: country.trim() };
|
||||||
|
if (editingId) await updateBankInstitution(editingId, payload);
|
||||||
|
else await createBankInstitution(payload);
|
||||||
|
cancel();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo guardar el banco.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Banco <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Ej. Scotiabank"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">País</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={country}
|
||||||
|
onChange={(e) => setCountry(e.target.value)}
|
||||||
|
placeholder="MX / US"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Bancos
|
||||||
|
<span className="section-count"> {banks.length}</span>
|
||||||
|
</h3>
|
||||||
|
{!adding && editingId === null && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banks.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin bancos registrados.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Banco</th>
|
||||||
|
<th>País</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{banks.map((b) =>
|
||||||
|
editingId === b.id ? (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td colSpan={3}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>{b.name}</td>
|
||||||
|
<td>{b.country || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(b)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- accounts */
|
||||||
|
|
||||||
|
function AccountsSection({
|
||||||
|
banks,
|
||||||
|
accounts,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
banks: BankInstitution[];
|
||||||
|
accounts: BankAccount[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [bankId, setBankId] = useState("");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||||
|
const [businessLine, setBusinessLine] = useState<string>("");
|
||||||
|
const [active, setActive] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
setEditingId(null);
|
||||||
|
setAdding(true);
|
||||||
|
setBankId(banks[0]?.id ?? "");
|
||||||
|
setLabel("");
|
||||||
|
setCurrency("MXN");
|
||||||
|
setBusinessLine("");
|
||||||
|
setActive(true);
|
||||||
|
}
|
||||||
|
function startEdit(a: BankAccount) {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(a.id);
|
||||||
|
setBankId(a.bankId);
|
||||||
|
setLabel(a.label);
|
||||||
|
setCurrency(a.currency);
|
||||||
|
setBusinessLine(a.businessLine ?? "");
|
||||||
|
setActive(a.active);
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
setAdding(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!bankId) {
|
||||||
|
window.alert("Selecciona el banco de la cuenta.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!label.trim()) {
|
||||||
|
window.alert("El nombre de la cuenta es obligatorio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const line = businessLine
|
||||||
|
? (businessLine as TransactionDomain)
|
||||||
|
: undefined;
|
||||||
|
if (editingId) {
|
||||||
|
// No `currency`: see the file header.
|
||||||
|
await updateBankAccount(editingId, {
|
||||||
|
bankId,
|
||||||
|
label: label.trim(),
|
||||||
|
businessLine: line,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createBankAccount({
|
||||||
|
bankId,
|
||||||
|
label: label.trim(),
|
||||||
|
currency,
|
||||||
|
businessLine: line,
|
||||||
|
active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo guardar la cuenta.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = (
|
||||||
|
<div className="child-editor">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Banco <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={bankId}
|
||||||
|
onChange={(e) => setBankId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{banks.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Nombre de la cuenta <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="Ej. Seguros — Bank of America (USD)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">
|
||||||
|
Moneda <span aria-hidden>*</span>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={currency}
|
||||||
|
disabled={editingId !== null}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as Currency)}
|
||||||
|
>
|
||||||
|
{CURRENCIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{editingId !== null && (
|
||||||
|
<span className="section-note">
|
||||||
|
No se puede cambiar: los movimientos ya registrados están en esta
|
||||||
|
moneda.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Línea de negocio</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={businessLine}
|
||||||
|
onChange={(e) => setBusinessLine(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Sin asignar</option>
|
||||||
|
{BUSINESS_LINES.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{domainLabel(d)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="section-note">
|
||||||
|
Referencia nada más: una chequera puede pagar de varias líneas.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className="field"
|
||||||
|
style={{ flexDirection: "row", alignItems: "center", gap: 8 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={(e) => setActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="field-label" style={{ margin: 0 }}>
|
||||||
|
Cuenta abierta
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={submit}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Guardando…" : editingId ? "Guardar" : "Agregar"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={cancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16, marginBottom: 14 }}>
|
||||||
|
<div className="child-head">
|
||||||
|
<h3 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Cuentas
|
||||||
|
<span className="section-count"> {accounts.length}</span>
|
||||||
|
</h3>
|
||||||
|
{!adding && editingId === null && banks.length > 0 && (
|
||||||
|
<button type="button" className="btn btn-outline" onClick={startAdd}>
|
||||||
|
+ Agregar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banks.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Registra primero el banco donde está la cuenta.
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 && !adding ? (
|
||||||
|
<div className="empty-inline">Sin cuentas registradas.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cuenta</th>
|
||||||
|
<th>Banco</th>
|
||||||
|
<th>Moneda</th>
|
||||||
|
<th>Línea</th>
|
||||||
|
<th>Estatus</th>
|
||||||
|
<th className="num">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{adding && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{accounts.map((a) =>
|
||||||
|
editingId === a.id ? (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td colSpan={6}>{editor}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.label}</td>
|
||||||
|
<td>{a.bankName}</td>
|
||||||
|
<td className="mono">{a.currency}</td>
|
||||||
|
<td>{a.businessLine ? domainLabel(a.businessLine) : "—"}</td>
|
||||||
|
<td>{a.active ? "Abierta" : "Cerrada"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="row-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => startEdit(a)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<Link href="/banco" className="btn btn-ghost">
|
||||||
|
Ver movimientos
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+243
-47
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { ContextReports } from "@/components/ContextReports";
|
import { ContextReports } from "@/components/ContextReports";
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
getBankFacets,
|
getBankFacets,
|
||||||
getBankStats,
|
getBankStats,
|
||||||
getBankSummary,
|
getBankSummary,
|
||||||
|
listBankAccounts,
|
||||||
listBankMovements,
|
listBankMovements,
|
||||||
voidBankMovement,
|
voidBankMovement,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -22,6 +24,7 @@ import {
|
|||||||
monthName,
|
monthName,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
|
BankAccount,
|
||||||
BankCleared,
|
BankCleared,
|
||||||
BankDirection,
|
BankDirection,
|
||||||
BankFacets,
|
BankFacets,
|
||||||
@@ -32,23 +35,28 @@ import type {
|
|||||||
BankSummary,
|
BankSummary,
|
||||||
BankTotals,
|
BankTotals,
|
||||||
CreateBankMovementInput,
|
CreateBankMovementInput,
|
||||||
|
Currency,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bank register (chequera) browser — plan step 7.
|
* Bank register (chequera) browser — plan step 7, multi-account since the
|
||||||
|
* step-11 multi-bank work.
|
||||||
*
|
*
|
||||||
* This is the office's OWN checking account, not customer money. It is a
|
* This is the office's OWN checking accounts, not customer money. It is a
|
||||||
* separate page from /estado-cuenta on purpose: nothing here belongs in a
|
* separate page from /estado-cuenta on purpose: nothing here belongs in a
|
||||||
* customer's statement and the two sets of figures are never combined.
|
* customer's statement and the two sets of figures are never combined.
|
||||||
*
|
*
|
||||||
* Two views:
|
* Two views, both scoped to the ONE account picked at the top:
|
||||||
* - "Movimientos": the register itself — every deposit and payment, by date,
|
* - "Movimientos": the register itself — every deposit and payment, by date,
|
||||||
* payee, cheque number or amount.
|
* payee, cheque number or amount.
|
||||||
* - "Resumen": ingresos vs egresos per year, and per month inside a year,
|
* - "Resumen": ingresos vs egresos per year, and per month inside a year,
|
||||||
* with the running net movement since the register opened in 2013.
|
* with the running net movement since the register opened.
|
||||||
*
|
*
|
||||||
* Single currency (MXN) — the source has no currency column. See the module
|
* Every amount is read in the selected account's currency. There is no "all
|
||||||
* header in `bank.service.ts` for why there is no category/ramo filter.
|
* accounts" option on purpose — Utilities banks in MXN and Seguros in USD, so
|
||||||
|
* one combined figure would be a number that never existed, exactly what
|
||||||
|
* /estado-cuenta's per-currency rule avoids. See the module header in
|
||||||
|
* `bank.service.ts` for why there is no category/ramo filter.
|
||||||
*/
|
*/
|
||||||
type View = "movimientos" | "resumen";
|
type View = "movimientos" | "resumen";
|
||||||
|
|
||||||
@@ -81,9 +89,18 @@ export default function BancoPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remembers the last chequera a person looked at, per browser. */
|
||||||
|
const ACCOUNT_KEY = "banco.bankAccountId";
|
||||||
|
|
||||||
function BankBrowser() {
|
function BankBrowser() {
|
||||||
const canCapture = useCan("bank:create");
|
const canCapture = useCan("bank:create");
|
||||||
const canVoid = useCan("bank:void");
|
const canVoid = useCan("bank:void");
|
||||||
|
const canManageAccounts = useCan("bank:manage-accounts");
|
||||||
|
|
||||||
|
const [accounts, setAccounts] = useState<BankAccount[] | null>(null);
|
||||||
|
const [accountId, setAccountId] = useState<string | null>(null);
|
||||||
|
const [accountsError, setAccountsError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [stats, setStats] = useState<BankStats | null>(null);
|
const [stats, setStats] = useState<BankStats | null>(null);
|
||||||
const [facets, setFacets] = useState<BankFacets | null>(null);
|
const [facets, setFacets] = useState<BankFacets | null>(null);
|
||||||
const [view, setView] = useState<View>("movimientos");
|
const [view, setView] = useState<View>("movimientos");
|
||||||
@@ -104,16 +121,66 @@ function BankBrowser() {
|
|||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const account = accounts?.find((a) => a.id === accountId) ?? null;
|
||||||
|
const currency = account?.currency ?? "MXN";
|
||||||
|
|
||||||
|
// Accounts load first: nothing else on this page can be requested until one
|
||||||
|
// is selected, because every read is scoped to exactly one chequera.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getBankStats().then(setStats).catch(() => setStats(null));
|
listBankAccounts()
|
||||||
getBankFacets().then(setFacets).catch(() => setFacets(null));
|
.then((rows) => {
|
||||||
|
setAccounts(rows);
|
||||||
|
const remembered =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem(ACCOUNT_KEY)
|
||||||
|
: null;
|
||||||
|
const pick =
|
||||||
|
rows.find((a) => a.id === remembered) ??
|
||||||
|
rows.find((a) => a.active) ??
|
||||||
|
rows[0];
|
||||||
|
setAccountId(pick?.id ?? null);
|
||||||
|
if (rows.length === 0) setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setAccountsError(e?.message ?? "No se pudieron cargar las cuentas.");
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function pickAccount(id: string) {
|
||||||
|
setAccountId(id);
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
window.localStorage.setItem(ACCOUNT_KEY, id);
|
||||||
|
// The previous account's figures must not linger while the new ones load.
|
||||||
|
setStats(null);
|
||||||
|
setFacets(null);
|
||||||
|
setMovements(null);
|
||||||
|
setSummary(null);
|
||||||
|
setSummaryYear(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshStats = useCallback(() => {
|
||||||
|
if (!accountId) return;
|
||||||
|
getBankStats(accountId)
|
||||||
|
.then(setStats)
|
||||||
|
.catch(() => setStats(null));
|
||||||
|
}, [accountId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accountId) return;
|
||||||
|
refreshStats();
|
||||||
|
getBankFacets(accountId)
|
||||||
|
.then(setFacets)
|
||||||
|
.catch(() => setFacets(null));
|
||||||
|
}, [accountId, refreshStats]);
|
||||||
|
|
||||||
const runSearch = useCallback(
|
const runSearch = useCallback(
|
||||||
(p: number) => {
|
(p: number) => {
|
||||||
|
if (!accountId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
listBankMovements({
|
listBankMovements({
|
||||||
|
bankAccountId: accountId,
|
||||||
query: query || undefined,
|
query: query || undefined,
|
||||||
direction: direction || undefined,
|
direction: direction || undefined,
|
||||||
cleared: cleared || undefined,
|
cleared: cleared || undefined,
|
||||||
@@ -132,23 +199,23 @@ function BankBrowser() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[query, direction, cleared, from, to, sort],
|
[accountId, query, direction, cleared, from, to, sort],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== "movimientos") return;
|
if (view !== "movimientos" || !accountId) return;
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
debounceRef.current = setTimeout(() => runSearch(1), 280);
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
};
|
};
|
||||||
}, [runSearch, view]);
|
}, [runSearch, view, accountId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view !== "resumen") return;
|
if (view !== "resumen" || !accountId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
getBankSummary(summaryYear ?? undefined)
|
getBankSummary(accountId, summaryYear ?? undefined)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setSummary(res);
|
setSummary(res);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -157,7 +224,7 @@ function BankBrowser() {
|
|||||||
setError(e?.message ?? "No se pudo cargar el resumen.");
|
setError(e?.message ?? "No se pudo cargar el resumen.");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, [view, summaryYear]);
|
}, [view, summaryYear, accountId]);
|
||||||
|
|
||||||
function goToPage(p: number) {
|
function goToPage(p: number) {
|
||||||
runSearch(p);
|
runSearch(p);
|
||||||
@@ -194,20 +261,71 @@ function BankBrowser() {
|
|||||||
setSort("date_desc");
|
setSort("date_desc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accountsError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{accountsError}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No chequera on file: the register has nothing it could be scoped to.
|
||||||
|
if (accounts && accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<p className="eyebrow">Cuentas propias de la oficina</p>
|
||||||
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
</div>
|
||||||
|
<div className="state-box">
|
||||||
|
<div className="state-glyph" aria-hidden>
|
||||||
|
⌗
|
||||||
|
</div>
|
||||||
|
<h3>Sin cuentas registradas</h3>
|
||||||
|
<p>
|
||||||
|
{canManageAccounts ? (
|
||||||
|
<>
|
||||||
|
Registra una cuenta bancaria en{" "}
|
||||||
|
<Link href="/banco/cuentas">Cuentas de chequera</Link> para
|
||||||
|
empezar a capturar movimientos.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Pide a un administrador que registre una cuenta bancaria."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="page-head rise">
|
<div className="page-head rise">
|
||||||
<p className="eyebrow">Cuenta propia de la oficina</p>
|
<p className="eyebrow">Cuenta propia de la oficina</p>
|
||||||
<h1 className="page-title">Chequera</h1>
|
<h1 className="page-title">Chequera</h1>
|
||||||
|
<AccountPicker
|
||||||
|
accounts={accounts}
|
||||||
|
accountId={accountId}
|
||||||
|
onPick={pickAccount}
|
||||||
|
canManageAccounts={canManageAccounts}
|
||||||
|
/>
|
||||||
<BankStatStrip
|
<BankStatStrip
|
||||||
stats={stats}
|
stats={stats}
|
||||||
|
currency={currency}
|
||||||
direction={view === "movimientos" ? direction : ""}
|
direction={view === "movimientos" ? direction : ""}
|
||||||
onPickDirection={pickDirection}
|
onPickDirection={pickDirection}
|
||||||
/>
|
/>
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
Movimientos de la cuenta bancaria de la oficina, en pesos. No forma
|
Movimientos de{" "}
|
||||||
parte del estado de cuenta de los clientes y sus cifras no se suman
|
<strong>{account ? account.label : "la cuenta seleccionada"}</strong>,
|
||||||
con las de ellos.
|
en {currency}. Cada cuenta se lee por separado: las cifras de dos
|
||||||
|
chequeras nunca se suman, igual que los saldos por moneda del estado
|
||||||
|
de cuenta. Tampoco forman parte del estado de cuenta de los clientes.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<ContextReports
|
<ContextReports
|
||||||
@@ -252,7 +370,7 @@ function BankBrowser() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{view === "movimientos" && canCapture && (
|
{view === "movimientos" && canCapture && account?.active && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
@@ -263,12 +381,20 @@ function BankBrowser() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "movimientos" && captureOpen && (
|
{account && !account.active && (
|
||||||
|
<div className="section-note">
|
||||||
|
Esta cuenta está cerrada: su historial se consulta, pero no admite
|
||||||
|
movimientos nuevos.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === "movimientos" && captureOpen && account && (
|
||||||
<BankCaptureForm
|
<BankCaptureForm
|
||||||
|
account={account}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setCaptureOpen(false);
|
setCaptureOpen(false);
|
||||||
runSearch(movements?.page ?? 1);
|
runSearch(movements?.page ?? 1);
|
||||||
getBankStats().then(setStats).catch(() => setStats(null));
|
refreshStats();
|
||||||
}}
|
}}
|
||||||
onCancel={() => setCaptureOpen(false)}
|
onCancel={() => setCaptureOpen(false)}
|
||||||
/>
|
/>
|
||||||
@@ -389,7 +515,7 @@ function BankBrowser() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === "movimientos" && movements && !loading && (
|
{view === "movimientos" && movements && !loading && (
|
||||||
<FilteredTotals totals={movements.totals} />
|
<FilteredTotals totals={movements.totals} currency={currency} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -402,6 +528,7 @@ function BankBrowser() {
|
|||||||
<SummaryView
|
<SummaryView
|
||||||
summary={summary}
|
summary={summary}
|
||||||
year={summaryYear}
|
year={summaryYear}
|
||||||
|
currency={currency}
|
||||||
onPickYear={pickYear}
|
onPickYear={pickYear}
|
||||||
/>
|
/>
|
||||||
) : movements && movements.total === 0 ? (
|
) : movements && movements.total === 0 ? (
|
||||||
@@ -430,12 +557,11 @@ function BankBrowser() {
|
|||||||
<BankRow
|
<BankRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
m={m}
|
m={m}
|
||||||
|
currency={currency}
|
||||||
canVoid={canVoid}
|
canVoid={canVoid}
|
||||||
onVoided={() => {
|
onVoided={() => {
|
||||||
runSearch(movements?.page ?? 1);
|
runSearch(movements?.page ?? 1);
|
||||||
getBankStats()
|
refreshStats();
|
||||||
.then(setStats)
|
|
||||||
.catch(() => setStats(null));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -456,13 +582,66 @@ function BankBrowser() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which chequera the whole page is reading. There is no "todas las cuentas"
|
||||||
|
* option and there must not be one — see the file header.
|
||||||
|
*/
|
||||||
|
function AccountPicker({
|
||||||
|
accounts,
|
||||||
|
accountId,
|
||||||
|
onPick,
|
||||||
|
canManageAccounts,
|
||||||
|
}: {
|
||||||
|
accounts: BankAccount[] | null;
|
||||||
|
accountId: string | null;
|
||||||
|
onPick: (id: string) => void;
|
||||||
|
canManageAccounts: boolean;
|
||||||
|
}) {
|
||||||
|
if (!accounts) {
|
||||||
|
return (
|
||||||
|
<div className="skeleton" style={{ height: 34, width: 260, marginTop: 12 }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="filter-row"
|
||||||
|
style={{ marginTop: 12, alignItems: "flex-end" }}
|
||||||
|
>
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Cuenta</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={accountId ?? ""}
|
||||||
|
onChange={(e) => onPick(e.target.value)}
|
||||||
|
aria-label="Cuenta de chequera"
|
||||||
|
>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.label} · {a.currency}
|
||||||
|
{a.active ? "" : " (cerrada)"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{canManageAccounts && (
|
||||||
|
<Link href="/banco/cuentas" className="btn btn-ghost">
|
||||||
|
Administrar cuentas
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
|
/** Headline figures; the ingreso/egreso cells double as register shortcuts. */
|
||||||
function BankStatStrip({
|
function BankStatStrip({
|
||||||
stats,
|
stats,
|
||||||
|
currency,
|
||||||
direction,
|
direction,
|
||||||
onPickDirection,
|
onPickDirection,
|
||||||
}: {
|
}: {
|
||||||
stats: BankStats | null;
|
stats: BankStats | null;
|
||||||
|
currency: Currency;
|
||||||
direction: BankDirection | "";
|
direction: BankDirection | "";
|
||||||
onPickDirection: (d: BankDirection) => void;
|
onPickDirection: (d: BankDirection) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -493,7 +672,7 @@ function BankStatStrip({
|
|||||||
aria-pressed={direction === "income"}
|
aria-pressed={direction === "income"}
|
||||||
>
|
>
|
||||||
<div className="stat-value tx-amount pos">
|
<div className="stat-value tx-amount pos">
|
||||||
{formatMoney(stats.income, "MXN")}
|
{formatMoney(stats.income, currency)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-label">
|
<div className="stat-label">
|
||||||
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
En ingresos · {formatNumber(stats.incomeCount)} movimientos
|
||||||
@@ -508,14 +687,14 @@ function BankStatStrip({
|
|||||||
aria-pressed={direction === "expense"}
|
aria-pressed={direction === "expense"}
|
||||||
>
|
>
|
||||||
<div className="stat-value tx-amount neg">
|
<div className="stat-value tx-amount neg">
|
||||||
{formatMoney(stats.expense, "MXN")}
|
{formatMoney(stats.expense, currency)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-label">
|
<div className="stat-label">
|
||||||
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
En egresos · {formatNumber(stats.expenseCount)} movimientos
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div className="stat-cell">
|
<div className="stat-cell">
|
||||||
<div className="stat-value">{formatMoney(stats.net, "MXN")}</div>
|
<div className="stat-value">{formatMoney(stats.net, currency)}</div>
|
||||||
{/* Not the bank balance: the register carries no opening balance. */}
|
{/* Not the bank balance: the register carries no opening balance. */}
|
||||||
<div className="stat-label">Movimiento neto acumulado</div>
|
<div className="stat-label">Movimiento neto acumulado</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -544,27 +723,33 @@ function BankStatStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Totals for everything the current filter matched, not just the page. */
|
/** Totals for everything the current filter matched, not just the page. */
|
||||||
function FilteredTotals({ totals }: { totals: BankTotals }) {
|
function FilteredTotals({
|
||||||
|
totals,
|
||||||
|
currency,
|
||||||
|
}: {
|
||||||
|
totals: BankTotals;
|
||||||
|
currency: Currency;
|
||||||
|
}) {
|
||||||
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
|
if (totals.incomeCount + totals.expenseCount + totals.voidCount === 0)
|
||||||
return null;
|
return null;
|
||||||
return (
|
return (
|
||||||
<div className="filtered-totals">
|
<div className="filtered-totals">
|
||||||
<div className="filtered-total">
|
<div className="filtered-total">
|
||||||
<span className="filtered-total-cur">MXN</span>
|
<span className="filtered-total-cur">{currency}</span>
|
||||||
<span>
|
<span>
|
||||||
<strong className="tx-amount pos">
|
<strong className="tx-amount pos">
|
||||||
{formatMoney(totals.income, "MXN")}
|
{formatMoney(totals.income, currency)}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
en ingresos · {formatNumber(totals.incomeCount)}
|
en ingresos · {formatNumber(totals.incomeCount)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong className="tx-amount neg">
|
<strong className="tx-amount neg">
|
||||||
{formatMoney(totals.expense, "MXN")}
|
{formatMoney(totals.expense, currency)}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
en egresos · {formatNumber(totals.expenseCount)}
|
en egresos · {formatNumber(totals.expenseCount)}
|
||||||
</span>
|
</span>
|
||||||
<span className="filtered-total-net">
|
<span className="filtered-total-net">
|
||||||
Neto <strong>{formatMoney(totals.net, "MXN")}</strong>
|
Neto <strong>{formatMoney(totals.net, currency)}</strong>
|
||||||
</span>
|
</span>
|
||||||
{totals.voidCount > 0 && (
|
{totals.voidCount > 0 && (
|
||||||
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
<span>{formatNumber(totals.voidCount)} cancelados</span>
|
||||||
@@ -576,10 +761,12 @@ function FilteredTotals({ totals }: { totals: BankTotals }) {
|
|||||||
|
|
||||||
function BankRow({
|
function BankRow({
|
||||||
m,
|
m,
|
||||||
|
currency,
|
||||||
canVoid,
|
canVoid,
|
||||||
onVoided,
|
onVoided,
|
||||||
}: {
|
}: {
|
||||||
m: BankListItem;
|
m: BankListItem;
|
||||||
|
currency: Currency;
|
||||||
canVoid: boolean;
|
canVoid: boolean;
|
||||||
onVoided: () => void;
|
onVoided: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -620,7 +807,7 @@ function BankRow({
|
|||||||
<td>{bankSourceLabel(m.source)}</td>
|
<td>{bankSourceLabel(m.source)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
<span className={`tx-amount ${bankTone(m.direction)}`}>
|
||||||
{m.direction === "void" ? "—" : formatMoney(m.amount, "MXN")}
|
{m.direction === "void" ? "—" : formatMoney(m.amount, currency)}
|
||||||
</span>
|
</span>
|
||||||
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
<div className="tx-cur">{bankDirectionLabel(m.direction)}</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -650,10 +837,12 @@ function BankRow({
|
|||||||
function SummaryView({
|
function SummaryView({
|
||||||
summary,
|
summary,
|
||||||
year,
|
year,
|
||||||
|
currency,
|
||||||
onPickYear,
|
onPickYear,
|
||||||
}: {
|
}: {
|
||||||
summary: BankSummary | null;
|
summary: BankSummary | null;
|
||||||
year: number | null;
|
year: number | null;
|
||||||
|
currency: Currency;
|
||||||
onPickYear: (y: number) => void;
|
onPickYear: (y: number) => void;
|
||||||
}) {
|
}) {
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
@@ -687,12 +876,12 @@ function SummaryView({
|
|||||||
<td className="num">{formatNumber(r.count)}</td>
|
<td className="num">{formatNumber(r.count)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount pos">
|
<span className="tx-amount pos">
|
||||||
{formatMoney(r.income, "MXN")}
|
{formatMoney(r.income, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount neg">
|
<span className="tx-amount neg">
|
||||||
{formatMoney(r.expense, "MXN")}
|
{formatMoney(r.expense, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
@@ -701,10 +890,10 @@ function SummaryView({
|
|||||||
Number(r.net) < 0 ? "neg" : "pos"
|
Number(r.net) < 0 ? "neg" : "pos"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatMoney(r.net, "MXN")}
|
{formatMoney(r.net, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num mono">{formatMoney(r.cumulative, "MXN")}</td>
|
<td className="num mono">{formatMoney(r.cumulative, currency)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -724,7 +913,7 @@ function SummaryView({
|
|||||||
<span className="section-rule cuenta" />
|
<span className="section-rule cuenta" />
|
||||||
<h2 className="section-title">Meses de {year}</h2>
|
<h2 className="section-title">Meses de {year}</h2>
|
||||||
<span className="section-count">
|
<span className="section-count">
|
||||||
abre en {formatMoney(summary.opening, "MXN")}
|
abre en {formatMoney(summary.opening, currency)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -747,12 +936,12 @@ function SummaryView({
|
|||||||
<td className="num">{formatNumber(r.count)}</td>
|
<td className="num">{formatNumber(r.count)}</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount pos">
|
<span className="tx-amount pos">
|
||||||
{formatMoney(r.income, "MXN")}
|
{formatMoney(r.income, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
<span className="tx-amount neg">
|
<span className="tx-amount neg">
|
||||||
{formatMoney(r.expense, "MXN")}
|
{formatMoney(r.expense, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num">
|
<td className="num">
|
||||||
@@ -761,11 +950,11 @@ function SummaryView({
|
|||||||
Number(r.net) < 0 ? "neg" : "pos"
|
Number(r.net) < 0 ? "neg" : "pos"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{formatMoney(r.net, "MXN")}
|
{formatMoney(r.net, currency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num mono">
|
<td className="num mono">
|
||||||
{formatMoney(r.cumulative, "MXN")}
|
{formatMoney(r.cumulative, currency)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -779,13 +968,16 @@ function SummaryView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Inline capture form for a single chequera movement. Single currency (MXN);
|
/** Inline capture form for a single chequera movement. The amount is in the
|
||||||
* sign convention: positive = ingreso, negative = egreso. Booked rows are
|
* selected account's currency; sign convention: positive = ingreso, negative
|
||||||
* never edited — fix mistakes with voidBankMovement + a fresh capture. */
|
* = egreso. Booked rows are never edited — fix mistakes with voidBankMovement
|
||||||
|
* + a fresh capture. */
|
||||||
function BankCaptureForm({
|
function BankCaptureForm({
|
||||||
|
account,
|
||||||
onSaved,
|
onSaved,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
|
account: BankAccount;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -818,6 +1010,7 @@ function BankCaptureForm({
|
|||||||
}
|
}
|
||||||
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
const signed = direction === "income" ? Math.abs(abs) : -Math.abs(abs);
|
||||||
const payload: CreateBankMovementInput = {
|
const payload: CreateBankMovementInput = {
|
||||||
|
bankAccountId: account.id,
|
||||||
amount: signed,
|
amount: signed,
|
||||||
transactionDate,
|
transactionDate,
|
||||||
concept: s(concept),
|
concept: s(concept),
|
||||||
@@ -843,9 +1036,12 @@ function BankCaptureForm({
|
|||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
{error && <div className="state-box state-error">{error}</div>}
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
<h2 className="section-title" style={{ marginBottom: 4 }}>
|
||||||
Capturar movimiento de chequera
|
Capturar movimiento de chequera
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="section-note" style={{ marginBottom: 14 }}>
|
||||||
|
Se registra en <strong>{account.label}</strong>, en {account.currency}.
|
||||||
|
</p>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">
|
<span className="field-label">
|
||||||
@@ -874,7 +1070,7 @@ function BankCaptureForm({
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">
|
<span className="field-label">
|
||||||
Monto (MXN) <span aria-hidden>*</span>
|
Monto ({account.currency}) <span aria-hidden>*</span>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|||||||
@@ -1,557 +1,11 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
import { AppShell } from "@/components/AppShell";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { Captura } from "@/components/Captura";
|
||||||
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
|
|
||||||
import { useCan } from "@/lib/abilities";
|
|
||||||
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
|
|
||||||
import type {
|
|
||||||
BatchCreateInput,
|
|
||||||
BillingFacets,
|
|
||||||
ByCheckResponse,
|
|
||||||
Currency,
|
|
||||||
LedgerCurrency,
|
|
||||||
TransactionDomain,
|
|
||||||
} from "@/lib/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Batch capture by check — the "Editor" screen from the legacy system
|
|
||||||
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2).
|
|
||||||
*
|
|
||||||
* Staff key many customers' receipts against ONE physical check before cutting
|
|
||||||
* it, then check that the captured total matches the check's amount. That
|
|
||||||
* reconciliation is the whole point, so the running total is the most prominent
|
|
||||||
* thing on the page and an optional "importe del cheque" field turns it into a
|
|
||||||
* live difference.
|
|
||||||
*
|
|
||||||
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
|
|
||||||
* by it answers every by-check question (see the "Reporte por cheque" report).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DOMAINS: { key: TransactionDomain; label: string }[] = [
|
|
||||||
{ key: "UTILITY", label: "Servicios" },
|
|
||||||
{ key: "INSURANCE", label: "Seguros" },
|
|
||||||
{ key: "TRUST", label: "Fideicomiso" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Line {
|
|
||||||
/** Local row key — lines have no server identity until the batch posts. */
|
|
||||||
key: number;
|
|
||||||
customerId: string;
|
|
||||||
customerName: string;
|
|
||||||
amount: string;
|
|
||||||
reference: string;
|
|
||||||
period: string;
|
|
||||||
outstanding: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function blankLine(key: number): Line {
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
customerId: "",
|
|
||||||
customerName: "",
|
|
||||||
amount: "",
|
|
||||||
reference: "",
|
|
||||||
period: "",
|
|
||||||
outstanding: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** Daily capture, opened on the manual (key-by-hand) mode. */
|
||||||
export default function BatchCapturePage() {
|
export default function BatchCapturePage() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<BatchCapture />
|
<Captura initialMode="manual" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BatchCapture() {
|
|
||||||
const canCapture = useCan("ledger:create");
|
|
||||||
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
|
||||||
|
|
||||||
// Check-level fields — shared by every line.
|
|
||||||
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
|
|
||||||
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
|
|
||||||
const [typeId, setTypeId] = useState("");
|
|
||||||
const [checkNumber, setCheckNumber] = useState("");
|
|
||||||
const [transactionDate, setTransactionDate] = useState(
|
|
||||||
new Date().toISOString().slice(0, 10),
|
|
||||||
);
|
|
||||||
/** The physical check's amount, for reconciliation only — never submitted. */
|
|
||||||
const [checkAmount, setCheckAmount] = useState("");
|
|
||||||
|
|
||||||
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
|
|
||||||
const [nextKey, setNextKey] = useState(4);
|
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filled = lines.filter(
|
|
||||||
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Charges are captured as positive numbers and signed on submit, matching
|
|
||||||
// MovementForm — staff type what's on the bill, not a negative.
|
|
||||||
const total = useMemo(
|
|
||||||
() =>
|
|
||||||
filled
|
|
||||||
.filter((l) => !l.outstanding)
|
|
||||||
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
|
||||||
[filled],
|
|
||||||
);
|
|
||||||
const outstandingTotal = useMemo(
|
|
||||||
() =>
|
|
||||||
filled
|
|
||||||
.filter((l) => l.outstanding)
|
|
||||||
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
|
||||||
[filled],
|
|
||||||
);
|
|
||||||
|
|
||||||
const checkAmt = Number(checkAmount);
|
|
||||||
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
|
|
||||||
const diff = hasCheckAmt ? checkAmt - total : 0;
|
|
||||||
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
|
|
||||||
|
|
||||||
function update(key: number, patch: Partial<Line>) {
|
|
||||||
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLine() {
|
|
||||||
setLines((ls) => [...ls, blankLine(nextKey)]);
|
|
||||||
setNextKey((k) => k + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLine(key: number) {
|
|
||||||
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!checkNumber.trim()) {
|
|
||||||
setError("Indica el número de cheque.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (filled.length === 0) {
|
|
||||||
setError("Captura al menos una línea con cliente y monto.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dupes = filled
|
|
||||||
.map((l) => l.customerId)
|
|
||||||
.filter((id, i, arr) => arr.indexOf(id) !== i);
|
|
||||||
if (dupes.length) {
|
|
||||||
const names = filled
|
|
||||||
.filter((l) => dupes.includes(l.customerId))
|
|
||||||
.map((l) => l.customerName);
|
|
||||||
if (
|
|
||||||
!window.confirm(
|
|
||||||
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
|
|
||||||
", ",
|
|
||||||
)}). ¿Continuar?`,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: BatchCreateInput = {
|
|
||||||
domain,
|
|
||||||
transactionDate,
|
|
||||||
checkNumber: checkNumber.trim(),
|
|
||||||
currency: currency as Currency,
|
|
||||||
typeId: typeId || undefined,
|
|
||||||
lines: filled.map((l) => ({
|
|
||||||
customerId: l.customerId,
|
|
||||||
// Every line of a check batch is a charge the office paid out.
|
|
||||||
amount: -Math.abs(Number(l.amount)),
|
|
||||||
reference: l.reference.trim() || undefined,
|
|
||||||
period: l.period.trim() || undefined,
|
|
||||||
outstanding: l.outstanding || undefined,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await createMovementBatch(payload);
|
|
||||||
// Re-read through the by-check view so the confirmation shows what's
|
|
||||||
// actually stored (including anything captured against this check
|
|
||||||
// earlier), not just what this request sent.
|
|
||||||
setPosted(await getByCheck(payload.checkNumber));
|
|
||||||
} catch (e2) {
|
|
||||||
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setPosted(null);
|
|
||||||
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
|
|
||||||
setNextKey((k) => k + 3);
|
|
||||||
setCheckNumber("");
|
|
||||||
setCheckAmount("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!canCapture) {
|
|
||||||
return (
|
|
||||||
<div className="state-box state-error">
|
|
||||||
No tienes permiso para capturar movimientos.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (posted) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-head">
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Lote capturado</h1>
|
|
||||||
<p className="eyebrow">
|
|
||||||
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
|
|
||||||
{posted.count === 1 ? "movimiento" : "movimientos"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 10 }}>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={reset}>
|
|
||||||
Capturar otro cheque
|
|
||||||
</button>
|
|
||||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
|
||||||
Volver a estado de cuenta
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="filtered-totals" style={{ marginBottom: 16 }}>
|
|
||||||
{posted.totals.map((t) => (
|
|
||||||
<div className="filtered-total" key={t.currency}>
|
|
||||||
<span className="filtered-total-cur">{t.currency}</span>
|
|
||||||
<span className="filtered-total-net">
|
|
||||||
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
|
|
||||||
</span>
|
|
||||||
<span>{formatNumber(t.count)} movimientos</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{posted.outstandingCount > 0 && (
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span>
|
|
||||||
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
|
|
||||||
total)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tx-scroll">
|
|
||||||
<table className="tx-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Cliente</th>
|
|
||||||
<th>Referencia</th>
|
|
||||||
<th>Periodo</th>
|
|
||||||
<th>Estado</th>
|
|
||||||
<th className="num">Monto</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{posted.items.map((i) => (
|
|
||||||
<tr key={i.id}>
|
|
||||||
<td>
|
|
||||||
<Link
|
|
||||||
href={`/estado-cuenta/${i.customerId}`}
|
|
||||||
className="inline-link"
|
|
||||||
>
|
|
||||||
{i.customerName}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td>{i.reference || "—"}</td>
|
|
||||||
<td>{i.period || "—"}</td>
|
|
||||||
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
|
|
||||||
<td className="num">
|
|
||||||
<span className="tx-amount neg">
|
|
||||||
{formatMoney(i.amount, i.currency)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="muted" style={{ marginTop: 14 }}>
|
|
||||||
Para imprimir la conciliación, usa el reporte{" "}
|
|
||||||
<Link
|
|
||||||
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
|
|
||||||
posted.checkNumber,
|
|
||||||
)}`}
|
|
||||||
className="inline-link"
|
|
||||||
>
|
|
||||||
Reporte por cheque
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="page-head">
|
|
||||||
<div>
|
|
||||||
<h1 className="page-title">Captura por cheque</h1>
|
|
||||||
<p className="eyebrow">
|
|
||||||
Captura los recibos de varios clientes contra un mismo cheque y
|
|
||||||
concilia el total antes de guardar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
|
||||||
Cancelar
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <div className="state-box state-error">{error}</div>}
|
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
|
||||||
Datos del cheque
|
|
||||||
</h2>
|
|
||||||
<div className="form-grid">
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Número de cheque *</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={checkNumber}
|
|
||||||
onChange={(e) => setCheckNumber(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Fecha *</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="date"
|
|
||||||
required
|
|
||||||
value={transactionDate}
|
|
||||||
onChange={(e) => setTransactionDate(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Línea de negocio *</span>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={domain}
|
|
||||||
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
|
||||||
>
|
|
||||||
{DOMAINS.map((d) => (
|
|
||||||
<option key={d.key} value={d.key}>
|
|
||||||
{d.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Moneda *</span>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={currency}
|
|
||||||
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
|
||||||
>
|
|
||||||
<option value="MXN">Pesos (MXN)</option>
|
|
||||||
<option value="USD">Dólares (USD)</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Concepto</span>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={typeId}
|
|
||||||
onChange={(e) => setTypeId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">(sin concepto)</option>
|
|
||||||
{facets?.types.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{txTypeLabel({ nameEn: t.name })}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="field">
|
|
||||||
<span className="field-label">Importe del cheque</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={checkAmount}
|
|
||||||
onChange={(e) => setCheckAmount(e.target.value)}
|
|
||||||
placeholder="Para conciliar"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: 14,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h2 className="section-title" style={{ margin: 0 }}>
|
|
||||||
Recibos ({formatNumber(filled.length)})
|
|
||||||
</h2>
|
|
||||||
<button type="button" className="btn btn-outline" onClick={addLine}>
|
|
||||||
Agregar línea
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tx-scroll">
|
|
||||||
<table className="tx-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style={{ minWidth: 240 }}>Cliente *</th>
|
|
||||||
<th style={{ minWidth: 120 }}>Referencia</th>
|
|
||||||
<th style={{ minWidth: 100 }}>Periodo</th>
|
|
||||||
<th style={{ minWidth: 110 }} className="num">
|
|
||||||
Monto *
|
|
||||||
</th>
|
|
||||||
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
|
|
||||||
<th style={{ width: 1 }} />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{lines.map((l) => (
|
|
||||||
<tr key={l.key}>
|
|
||||||
<td>
|
|
||||||
<CustomerPicker
|
|
||||||
value={l.customerId}
|
|
||||||
valueName={l.customerId ? l.customerName : undefined}
|
|
||||||
onPick={(id, name) =>
|
|
||||||
update(l.key, { customerId: id, customerName: name })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={l.reference}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(l.key, { reference: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={l.period}
|
|
||||||
onChange={(e) => update(l.key, { period: e.target.value })}
|
|
||||||
placeholder="2026-07"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
className="input num"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
value={l.amount}
|
|
||||||
onChange={(e) => update(l.key, { amount: e.target.value })}
|
|
||||||
placeholder="0.00"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ textAlign: "center" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={l.outstanding}
|
|
||||||
onChange={(e) =>
|
|
||||||
update(l.key, { outstanding: e.target.checked })
|
|
||||||
}
|
|
||||||
aria-label="Sin fondos"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ padding: "4px 10px", fontSize: 12 }}
|
|
||||||
onClick={() => removeLine(l.key)}
|
|
||||||
disabled={lines.length === 1}
|
|
||||||
>
|
|
||||||
Quitar
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
|
||||||
Conciliación
|
|
||||||
</h2>
|
|
||||||
<div className="filtered-totals">
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span className="filtered-total-cur">{currency}</span>
|
|
||||||
<span className="filtered-total-net">
|
|
||||||
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
|
|
||||||
</span>
|
|
||||||
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
|
|
||||||
</div>
|
|
||||||
{outstandingTotal > 0 && (
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span>
|
|
||||||
Sin fondos{" "}
|
|
||||||
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
|
|
||||||
(no suma al cheque)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasCheckAmt && (
|
|
||||||
<div className="filtered-total">
|
|
||||||
<span className="filtered-total-net">
|
|
||||||
{reconciled ? (
|
|
||||||
<strong className="tx-amount pos">Cuadra con el cheque</strong>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Diferencia{" "}
|
|
||||||
<strong className="tx-amount neg">
|
|
||||||
{formatMoney(String(diff), currency)}
|
|
||||||
</strong>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={saving || filled.length === 0}
|
|
||||||
>
|
|
||||||
{saving
|
|
||||||
? "Guardando…"
|
|
||||||
: `Capturar ${formatNumber(filled.length)} ${
|
|
||||||
filled.length === 1 ? "recibo" : "recibos"
|
|
||||||
}`}
|
|
||||||
</button>
|
|
||||||
<Link href="/estado-cuenta" className="btn btn-outline">
|
|
||||||
Cancelar
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+674
-381
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import {
|
|||||||
getPolicyStats,
|
getPolicyStats,
|
||||||
getPropertyStats,
|
getPropertyStats,
|
||||||
getStats,
|
getStats,
|
||||||
|
listBankAccounts,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useAuth } from "@/lib/abilities";
|
import { useAuth } from "@/lib/abilities";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
trustStatusLabel,
|
trustStatusLabel,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
|
BankAccount,
|
||||||
BankStats,
|
BankStats,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
CustomerStats,
|
CustomerStats,
|
||||||
@@ -41,7 +43,29 @@ interface DashboardData {
|
|||||||
policies: PolicyStats | null;
|
policies: PolicyStats | null;
|
||||||
properties: PropertyStats | null;
|
properties: PropertyStats | null;
|
||||||
billing: BillingStats | null;
|
billing: BillingStats | null;
|
||||||
|
/** Figures for ONE chequera — see `bankAccount` for which. */
|
||||||
bank: BankStats | null;
|
bank: BankStats | null;
|
||||||
|
/**
|
||||||
|
* The chequera the card above is reading. The office keeps more than one, in
|
||||||
|
* different currencies, so this card shows the default account rather than a
|
||||||
|
* cross-account total, which would be a figure that never existed.
|
||||||
|
*/
|
||||||
|
bankAccount: BankAccount | null;
|
||||||
|
bankAccountCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same default as /banco, so the two screens agree on which chequera opens. */
|
||||||
|
function defaultAccount(accounts: BankAccount[]): BankAccount | null {
|
||||||
|
const remembered =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? window.localStorage.getItem("banco.bankAccountId")
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
accounts.find((a) => a.id === remembered) ??
|
||||||
|
accounts.find((a) => a.active) ??
|
||||||
|
accounts[0] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HomeDashboard() {
|
function HomeDashboard() {
|
||||||
@@ -52,25 +76,42 @@ function HomeDashboard() {
|
|||||||
properties: null,
|
properties: null,
|
||||||
billing: null,
|
billing: null,
|
||||||
bank: null,
|
bank: null,
|
||||||
|
bankAccount: null,
|
||||||
|
bankAccountCount: 0,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
|
// The chequera figures need an account id, so that read is a two-step:
|
||||||
|
// list the accounts, then ask the default one for its stats.
|
||||||
|
const bank = listBankAccounts().then(async (accounts) => {
|
||||||
|
const account = defaultAccount(accounts);
|
||||||
|
if (!account) return { account: null, stats: null, count: 0 };
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
stats: await getBankStats(account.id),
|
||||||
|
count: accounts.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
Promise.allSettled([
|
Promise.allSettled([
|
||||||
getStats(),
|
getStats(),
|
||||||
getPolicyStats(),
|
getPolicyStats(),
|
||||||
getPropertyStats(),
|
getPropertyStats(),
|
||||||
getBillingStats(),
|
getBillingStats(),
|
||||||
getBankStats(),
|
bank,
|
||||||
]).then((results) => {
|
]).then((results) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
|
const bankResult = results[4].status === "fulfilled" ? results[4].value : null;
|
||||||
setData({
|
setData({
|
||||||
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
customers: results[0].status === "fulfilled" ? results[0].value : null,
|
||||||
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
policies: results[1].status === "fulfilled" ? results[1].value : null,
|
||||||
properties: results[2].status === "fulfilled" ? results[2].value : null,
|
properties: results[2].status === "fulfilled" ? results[2].value : null,
|
||||||
billing: results[3].status === "fulfilled" ? results[3].value : null,
|
billing: results[3].status === "fulfilled" ? results[3].value : null,
|
||||||
bank: results[4].status === "fulfilled" ? results[4].value : null,
|
bank: bankResult?.stats ?? null,
|
||||||
|
bankAccount: bankResult?.account ?? null,
|
||||||
|
bankAccountCount: bankResult?.count ?? 0,
|
||||||
});
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@@ -260,22 +301,27 @@ function HomeDashboard() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
title="Chequera del despacho"
|
title="Chequera del despacho"
|
||||||
primary={
|
primary={
|
||||||
data.bank ? (
|
data.bank && data.bankAccount ? (
|
||||||
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
<span className={data.bank.net.startsWith("-") ? "money-neg" : "money-pos"}>
|
||||||
{formatMoney(data.bank.net, "MXN")}
|
{formatMoney(data.bank.net, data.bankAccount.currency)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Names the account, because this is one chequera's figure and the
|
||||||
|
// office has more than one — they are never added together.
|
||||||
sub={
|
sub={
|
||||||
data.bank
|
data.bank && data.bankAccount
|
||||||
? balancePhrase(data.bank.net)
|
? `${data.bankAccount.label} · ${balancePhrase(data.bank.net)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
meta={
|
meta={
|
||||||
data.bank
|
data.bank
|
||||||
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes`
|
? `${formatNumber(data.bank.movements)} movimientos · ${formatNumber(data.bank.pending)} pendientes` +
|
||||||
|
(data.bankAccountCount > 1
|
||||||
|
? ` · ${formatNumber(data.bankAccountCount)} cuentas en total`
|
||||||
|
: "")
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { readBuildInfoFromEnv } from "@/lib/build-info";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Jorge Cuadros & Asociados — Plataforma",
|
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||||
@@ -20,6 +21,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
process.env.API_ORIGIN ??
|
process.env.API_ORIGIN ??
|
||||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
||||||
"http://localhost:3001";
|
"http://localhost:3001";
|
||||||
|
// Same reason as the API origin: read on the server per request so the built
|
||||||
|
// image is not pinned to one build identity in its client bundle.
|
||||||
|
const build = readBuildInfoFromEnv();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
@@ -27,7 +31,20 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
{/* Must run before the app bundle so lib/api.ts sees it at import. */}
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};`,
|
__html:
|
||||||
|
`window.__API_ORIGIN__=${JSON.stringify(apiOrigin)};` +
|
||||||
|
`window.__APP_BUILD__=${JSON.stringify(build)};`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Text-size preference, applied before first paint so the page never
|
||||||
|
flashes at the default size. Mirrors lib/ui-scale.ts — keep the key
|
||||||
|
and the clamp in sync with it. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
`try{var s=parseFloat(localStorage.getItem("jc.ui-scale"));` +
|
||||||
|
`if(isFinite(s))document.documentElement.style.setProperty(` +
|
||||||
|
`"--ui-scale",String(Math.min(1.5,Math.max(0.9,s))));}catch(e){}`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/* Google Fonts via <link> so an offline build still runs with the
|
{/* Google Fonts via <link> so an offline build still runs with the
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import {
|
||||||
|
confirmStatementBatch,
|
||||||
|
getStatementBatch,
|
||||||
|
listStatementDocuments,
|
||||||
|
rejectStatementDocument,
|
||||||
|
reviewStatementDocument,
|
||||||
|
statementPageUrl,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
ConfirmBatchInput,
|
||||||
|
StatementBatchDetail,
|
||||||
|
StatementDocument,
|
||||||
|
StatementDocumentStatus,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review queue for one batch of scanned bills.
|
||||||
|
*
|
||||||
|
* The reviewer's job is to answer one question per page — "is this the right
|
||||||
|
* customer for this amount?" — so the page image sits next to the extracted
|
||||||
|
* fields and every row can be corrected in place. Rows the matcher is sure
|
||||||
|
* about are pre-approved and can be posted in bulk; everything else is listed
|
||||||
|
* first, because that is the work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<StatementDocumentStatus, string> = {
|
||||||
|
PENDING_OCR: "En proceso",
|
||||||
|
OCR_FAILED: "No se pudo leer",
|
||||||
|
NEEDS_REVIEW: "Requiere revisión",
|
||||||
|
MATCHED: "Identificado",
|
||||||
|
CONFIRMED: "Confirmado",
|
||||||
|
POSTED: "Registrado",
|
||||||
|
REJECTED: "Descartado",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rows still needing a decision, listed before the settled ones. */
|
||||||
|
const OPEN_FIRST: StatementDocumentStatus[] = [
|
||||||
|
"NEEDS_REVIEW",
|
||||||
|
"OCR_FAILED",
|
||||||
|
"MATCHED",
|
||||||
|
"CONFIRMED",
|
||||||
|
"POSTED",
|
||||||
|
"REJECTED",
|
||||||
|
"PENDING_OCR",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function RecibosBatchPage({ params }: { params: { id: string } }) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<BatchReview id={params.id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BatchReview({ id }: { id: string }) {
|
||||||
|
const canReview = useCan("statement:review");
|
||||||
|
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||||
|
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [b, d] = await Promise.all([
|
||||||
|
getStatementBatch(id),
|
||||||
|
listStatementDocuments(id),
|
||||||
|
]);
|
||||||
|
setBatch(b);
|
||||||
|
setDocs(d);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo cargar el lote.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const processing = batch?.status === "PROCESSING" || batch?.status === "UPLOADED";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!processing) return;
|
||||||
|
const t = setInterval(() => void load(), 4000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [processing, load]);
|
||||||
|
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...docs].sort(
|
||||||
|
(a, b) =>
|
||||||
|
OPEN_FIRST.indexOf(a.status) - OPEN_FIRST.indexOf(b.status) ||
|
||||||
|
a.pageNumber - b.pageNumber,
|
||||||
|
),
|
||||||
|
[docs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const readyCount = docs.filter(
|
||||||
|
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
if (loading) return <div className="state-box">Cargando…</div>;
|
||||||
|
if (!batch) return <div className="state-box state-error">{error ?? "No encontrado."}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">
|
||||||
|
Recibos — {serviceKindLabel(batch.serviceKind)}
|
||||||
|
{batch.label ? ` · ${batch.label}` : ""}
|
||||||
|
</h1>
|
||||||
|
<p className="page-sub">
|
||||||
|
{formatDate(batch.createdAt)} · {docs.length} página(s) ·{" "}
|
||||||
|
{STATUS_LABEL_BATCH[batch.status] ?? batch.status}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link className="btn btn-ghost" href="/recibos">
|
||||||
|
Volver a captura
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
{processing && (
|
||||||
|
<div className="state-box">
|
||||||
|
Leyendo los recibos… esta pantalla se actualiza sola.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||||
|
|
||||||
|
{canReview && readyCount > 0 && (
|
||||||
|
<ConfirmCard
|
||||||
|
batchId={id}
|
||||||
|
readyCount={readyCount}
|
||||||
|
onDone={load}
|
||||||
|
setError={setError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="stack">
|
||||||
|
{sorted.map((doc) => (
|
||||||
|
<DocumentRow
|
||||||
|
key={doc.id}
|
||||||
|
doc={doc}
|
||||||
|
canReview={canReview}
|
||||||
|
onChange={load}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABEL_BATCH: Record<string, string> = {
|
||||||
|
UPLOADED: "Recibido",
|
||||||
|
PROCESSING: "Procesando",
|
||||||
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
|
COMPLETED: "Registrado",
|
||||||
|
FAILED: "Falló",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
batch,
|
||||||
|
readyCount,
|
||||||
|
}: {
|
||||||
|
batch: StatementBatchDetail;
|
||||||
|
readyCount: number;
|
||||||
|
}) {
|
||||||
|
const entries = Object.entries(batch.byStatus) as [StatementDocumentStatus, number][];
|
||||||
|
return (
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 20 }}>
|
||||||
|
{entries.map(([status, count]) => (
|
||||||
|
<div key={status}>
|
||||||
|
<div className="page-sub">{STATUS_LABEL[status] ?? status}</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{count}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<div className="page-sub">Importe pendiente</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>
|
||||||
|
{formatMoney(batch.pendingTotal, "MXN")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="page-sub">Listos para registrar</div>
|
||||||
|
<div style={{ fontSize: "1.4rem", fontWeight: 600 }}>{readyCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posting is by check, exactly as on the manual capture screen — an OCR batch
|
||||||
|
* is still "these bills, paid with this check", so the same fields are asked
|
||||||
|
* for and the same ledger path is used.
|
||||||
|
*/
|
||||||
|
function ConfirmCard({
|
||||||
|
batchId,
|
||||||
|
readyCount,
|
||||||
|
onDone,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
batchId: string;
|
||||||
|
readyCount: number;
|
||||||
|
onDone: () => void;
|
||||||
|
setError: (m: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [checkNumber, setCheckNumber] = useState("");
|
||||||
|
const [transactionDate, setTransactionDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
const [outstanding, setOutstanding] = useState(false);
|
||||||
|
const [includeReviewed, setIncludeReviewed] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!checkNumber.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const input: ConfirmBatchInput = {
|
||||||
|
checkNumber: checkNumber.trim(),
|
||||||
|
transactionDate,
|
||||||
|
outstanding,
|
||||||
|
includeReviewed,
|
||||||
|
};
|
||||||
|
const r = await confirmStatementBatch(batchId, input);
|
||||||
|
setResult(
|
||||||
|
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
||||||
|
);
|
||||||
|
setCheckNumber("");
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
|
Registrar {readyCount} recibo(s)
|
||||||
|
</h2>
|
||||||
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Cheque</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={checkNumber}
|
||||||
|
onChange={(e) => setCheckNumber(e.target.value)}
|
||||||
|
placeholder="Número de cheque"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Fecha</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input"
|
||||||
|
value={transactionDate}
|
||||||
|
onChange={(e) => setTransactionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={outstanding}
|
||||||
|
onChange={(e) => setOutstanding(e.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
Sin fondos (queda pendiente)
|
||||||
|
</label>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeReviewed}
|
||||||
|
onChange={(e) => setIncludeReviewed(e.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
Incluir los confirmados a mano
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!checkNumber.trim() || busy}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{busy ? "Registrando…" : "Registrar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<div className="state-box" style={{ marginTop: 12 }}>
|
||||||
|
{result}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||||
|
Se registran como cargos del cliente, por la misma vía que la captura
|
||||||
|
manual. Un lote registrado dos veces no duplica cobros.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentRow({
|
||||||
|
doc,
|
||||||
|
canReview,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
doc: StatementDocument;
|
||||||
|
canReview: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(
|
||||||
|
doc.status === "NEEDS_REVIEW" || doc.status === "OCR_FAILED",
|
||||||
|
);
|
||||||
|
const [amount, setAmount] = useState(doc.extractedAmount ?? "");
|
||||||
|
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||||
|
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||||
|
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const settled = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||||
|
|
||||||
|
async function save(status: "MATCHED" | "CONFIRMED") {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await reviewStatementDocument(doc.id, {
|
||||||
|
accountRef: accountRef.trim() || undefined,
|
||||||
|
amount: amount ? Number(amount) : undefined,
|
||||||
|
matchedCustomerId: customerId || undefined,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? "No se pudo guardar.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reject() {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await rejectStatementDocument(doc.id);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? "No se pudo descartar.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 16 }}>
|
||||||
|
<div
|
||||||
|
className="inline-form"
|
||||||
|
style={{ justifyContent: "space-between", flexWrap: "wrap", gap: 12 }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||||
|
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
||||||
|
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||||
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||||
|
{doc.matchedCustomer ? (
|
||||||
|
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||||
|
{doc.matchedCustomer.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"Sin cliente asignado"
|
||||||
|
)}
|
||||||
|
{doc.extractedAccountRef && ` · cuenta ${doc.extractedAccountRef}`}
|
||||||
|
{doc.extractedCadastralKey && ` · clave ${doc.extractedCadastralKey}`}
|
||||||
|
</div>
|
||||||
|
{doc.matchNote && (
|
||||||
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||||
|
{doc.matchNote}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="inline-form" style={{ gap: 8 }}>
|
||||||
|
<strong>
|
||||||
|
{doc.extractedAmount
|
||||||
|
? formatMoney(doc.extractedAmount, "MXN")
|
||||||
|
: "sin importe"}
|
||||||
|
</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? "Ocultar" : "Ver recibo"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div style={{ marginTop: 12, display: "grid", gap: 16 }}>
|
||||||
|
{/* The scan itself — the reviewer's source of truth, not the OCR. */}
|
||||||
|
<img
|
||||||
|
src={statementPageUrl(doc.id)}
|
||||||
|
alt={`Recibo página ${doc.pageNumber}`}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
border: "1px solid var(--border, #ddd)",
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canReview && !settled && (
|
||||||
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Cuenta</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={accountRef}
|
||||||
|
onChange={(e) => setAccountRef(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Importe</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div style={{ minWidth: 260 }}>
|
||||||
|
<span className="page-sub">Cliente</span>
|
||||||
|
<CustomerPicker
|
||||||
|
value={customerId}
|
||||||
|
valueName={customerName}
|
||||||
|
onPick={(cid, name) => {
|
||||||
|
setCustomerId(cid);
|
||||||
|
setCustomerName(name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || !customerId}
|
||||||
|
onClick={() => save("MATCHED")}
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={reject}
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && <div className="state-box state-error">{err}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { Captura } from "@/components/Captura";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same capture screen as `/estado-cuenta/lote`, opened on the automatic
|
||||||
|
* (scanned recibos + OCR) mode. Kept as its own route so links from a batch
|
||||||
|
* review page and older bookmarks land on the right tab.
|
||||||
|
*/
|
||||||
|
export default function RecibosPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Captura initialMode="auto" />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readBuildInfoFromEnv } from "@/lib/build-info";
|
||||||
|
|
||||||
|
// Read per request, never prerendered — the whole point is to report what THIS
|
||||||
|
// running container is, and a baked answer would defeat that.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The web tier's counterpart to the API's GET /version.
|
||||||
|
*
|
||||||
|
* Without this, the only way to see what the web container is running was to
|
||||||
|
* scrape window.__APP_BUILD__ out of the HTML. The deploy workflow compares the
|
||||||
|
* two tiers' gitSha to catch a half-applied release, so it needs a stable,
|
||||||
|
* parseable answer from both sides.
|
||||||
|
*/
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ service: "web", ...readBuildInfoFromEnv() });
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { logout, me } from "@/lib/api";
|
import { getApiVersion, logout, me, updateUiScale, type ServiceVersion } from "@/lib/api";
|
||||||
|
import { webBuildInfo } from "@/lib/build-info";
|
||||||
import { AuthContext, can } from "@/lib/abilities";
|
import { AuthContext, can } from "@/lib/abilities";
|
||||||
import { ROLE_LABEL } from "@/lib/labels";
|
import { ROLE_LABEL } from "@/lib/labels";
|
||||||
|
import {
|
||||||
|
DEFAULT_UI_SCALE,
|
||||||
|
applyUiScale,
|
||||||
|
normalizeUiScale,
|
||||||
|
readUiScale,
|
||||||
|
saveUiScale,
|
||||||
|
} from "@/lib/ui-scale";
|
||||||
|
import { FontScaleControl } from "./FontScaleControl";
|
||||||
import type { AuthUser, Ability } from "@/lib/types";
|
import type { AuthUser, Ability } from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,23 +23,87 @@ import type { AuthUser, Ability } from "@/lib/types";
|
|||||||
* content. Provides the AuthContext so any page can read the user's
|
* content. Provides the AuthContext so any page can read the user's
|
||||||
* abilities. Used by every authenticated page.
|
* abilities. Used by every authenticated page.
|
||||||
*/
|
*/
|
||||||
const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[] = [
|
|
||||||
{ href: "/inicio", label: "Inicio", exact: true },
|
type NavLink = {
|
||||||
{ href: "/clientes", label: "Clientes" },
|
href: string;
|
||||||
{ href: "/servicios", label: "Propiedades" },
|
label: string;
|
||||||
{ href: "/polizas", label: "Pólizas" },
|
ability?: Ability;
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
exact?: boolean;
|
||||||
// Daily data-entry screen (the legacy "Editor"), so it earns a top-level
|
/** Extra path prefixes that belong to this entry (e.g. a second route into
|
||||||
// entry rather than living one click inside the Movimientos tab. Hidden from
|
* the same screen), so they highlight it instead of nothing. */
|
||||||
// VIEWER, who can't capture anyway — the page itself also refuses.
|
aliases?: string[];
|
||||||
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
|
};
|
||||||
{ href: "/banco", label: "Chequera" },
|
type NavEntry =
|
||||||
{ href: "/reportes", label: "Reportes" },
|
| ({ kind: "link" } & NavLink)
|
||||||
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
| { kind: "group"; label: string; items: NavLink[] };
|
||||||
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
|
||||||
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
/**
|
||||||
|
* Top nav. Daily screens stay one click away; the movement screens and the
|
||||||
|
* admin screens are grouped behind menus so the bar doesn't saturate as the
|
||||||
|
* app grows. A group disappears entirely when the user can't see any of its
|
||||||
|
* items (gating here is cosmetic — the API enforces every write).
|
||||||
|
*/
|
||||||
|
const NAV: NavEntry[] = [
|
||||||
|
{ kind: "link", href: "/inicio", label: "Inicio", exact: true },
|
||||||
|
{ kind: "link", href: "/clientes", label: "Clientes" },
|
||||||
|
{ kind: "link", href: "/polizas", label: "Pólizas" },
|
||||||
|
{ kind: "link", href: "/servicios", label: "Propiedades" },
|
||||||
|
{
|
||||||
|
kind: "group",
|
||||||
|
label: "Cobranza",
|
||||||
|
items: [
|
||||||
|
// Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who
|
||||||
|
// can't capture anyway — the page itself also refuses. Both capture modes
|
||||||
|
// live behind this one entry: keying receipts by hand, and scanning a
|
||||||
|
// stack of bills for OCR (the `/recibos` route opens the same screen on
|
||||||
|
// its automatic tab).
|
||||||
|
{
|
||||||
|
href: "/estado-cuenta/lote",
|
||||||
|
label: "Captura",
|
||||||
|
ability: "ledger:create",
|
||||||
|
aliases: ["/recibos"],
|
||||||
|
},
|
||||||
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
|
{ href: "/banco", label: "Chequera" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ kind: "link", href: "/reportes", label: "Reportes" },
|
||||||
|
{
|
||||||
|
kind: "group",
|
||||||
|
label: "Admin",
|
||||||
|
items: [
|
||||||
|
{ href: "/catalogos", label: "Catálogos", ability: "lookup:manage" },
|
||||||
|
{
|
||||||
|
href: "/banco/cuentas",
|
||||||
|
label: "Cuentas de chequera",
|
||||||
|
ability: "bank:manage-accounts",
|
||||||
|
},
|
||||||
|
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
|
||||||
|
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Every nav destination, flattened out of the groups. */
|
||||||
|
const NAV_LINKS: NavLink[] = NAV.flatMap((entry) =>
|
||||||
|
entry.kind === "link" ? [entry] : entry.items,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The nav the given user may see, with empty groups dropped. */
|
||||||
|
function visibleNav(user: AuthUser | null): NavEntry[] {
|
||||||
|
const allowed = (item: NavLink) => !item.ability || can(user, item.ability);
|
||||||
|
const out: NavEntry[] = [];
|
||||||
|
for (const entry of NAV) {
|
||||||
|
if (entry.kind === "link") {
|
||||||
|
if (allowed(entry)) out.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = entry.items.filter(allowed);
|
||||||
|
if (items.length > 0) out.push({ ...entry, items });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which nav entry is highlighted for a path. Longest matching href wins, so a
|
* Which nav entry is highlighted for a path. Longest matching href wins, so a
|
||||||
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
|
* nested route (`/estado-cuenta/lote`) highlights its own entry instead of also
|
||||||
@@ -40,10 +113,12 @@ const NAV: { href: string; label: string; ability?: Ability; exact?: boolean }[]
|
|||||||
function activeHref(pathname: string | null): string | null {
|
function activeHref(pathname: string | null): string | null {
|
||||||
if (!pathname) return null;
|
if (!pathname) return null;
|
||||||
let best: string | null = null;
|
let best: string | null = null;
|
||||||
for (const item of NAV) {
|
for (const item of NAV_LINKS) {
|
||||||
|
const under = (href: string) =>
|
||||||
|
pathname === href || pathname.startsWith(`${href}/`);
|
||||||
const match = item.exact
|
const match = item.exact
|
||||||
? pathname === item.href
|
? pathname === item.href
|
||||||
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
: under(item.href) || (item.aliases?.some(under) ?? false);
|
||||||
if (match && (best === null || item.href.length > best.length)) {
|
if (match && (best === null || item.href.length > best.length)) {
|
||||||
best = item.href;
|
best = item.href;
|
||||||
}
|
}
|
||||||
@@ -51,22 +126,161 @@ function activeHref(pathname: string | null): string | null {
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One collapsible group in the desktop bar. Opens on click, closes on outside
|
||||||
|
* click, Escape, or navigation. The trigger stays highlighted while any of its
|
||||||
|
* children is the current page.
|
||||||
|
*/
|
||||||
|
function NavMenu({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
current,
|
||||||
|
pathname,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
items: NavLink[];
|
||||||
|
current: string | null;
|
||||||
|
pathname: string | null;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const holdsCurrent = items.some((item) => item.href === current);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(event: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="appbar-menu" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`appbar-link appbar-menu-trigger${holdsCurrent ? " active" : ""}`}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<span className="appbar-caret" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="appbar-dropdown" role="menu">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
role="menuitem"
|
||||||
|
className={`appbar-dropdown-link${current === item.href ? " active" : ""}`}
|
||||||
|
aria-current={current === item.href ? "page" : undefined}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is deployed, from both halves. build.yml builds api + web in one matrix
|
||||||
|
* run, so their versions cannot drift at build time — but they can at DEPLOY
|
||||||
|
* time, if a stack is applied with only one image's tag moved. Showing both and
|
||||||
|
* flagging a mismatch is the cheap check that catches a half-applied release.
|
||||||
|
*/
|
||||||
|
function BuildFooter() {
|
||||||
|
const web = webBuildInfo();
|
||||||
|
const [api, setApi] = useState<ServiceVersion | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
getApiVersion()
|
||||||
|
.then((v) => {
|
||||||
|
if (alive) setApi(v);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The shell already redirects to /login when the API is unreachable;
|
||||||
|
// a missing version line is not worth a second error surface.
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Compare the COMMIT, not the version string. On a branch build both tiers
|
||||||
|
// report APP_VERSION "master", so comparing versions cannot see drift — which
|
||||||
|
// is exactly how a stale web image once sat next to a current API with this
|
||||||
|
// footer showing nothing wrong. The sha is the only field that actually
|
||||||
|
// differs between two builds of the same branch.
|
||||||
|
const mismatch = api !== null && api.gitSha !== web.gitSha;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="shell-footer">
|
||||||
|
<span>Jorge Cuadros & Asociados</span>
|
||||||
|
{/* The FULL 40-char commit, not an abbreviation: this line exists to be
|
||||||
|
pasted into `git show` or compared against a registry tag, and a
|
||||||
|
7-char prefix makes both a manual step. It is what GIT_SHA already
|
||||||
|
carries — build.yml bakes in `github.sha` whole. */}
|
||||||
|
<span
|
||||||
|
className="shell-footer-build"
|
||||||
|
title={`web ${web.version} (${web.gitSha}) — ${web.buildDate}`}
|
||||||
|
>
|
||||||
|
v{web.version} · {web.gitSha}
|
||||||
|
{mismatch && api ? ` · API ${api.gitSha}` : ""}
|
||||||
|
</span>
|
||||||
|
{mismatch && (
|
||||||
|
<span className="shell-footer-warn" role="status">
|
||||||
|
versiones desincronizadas
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [checking, setChecking] = useState(true);
|
const [checking, setChecking] = useState(true);
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE);
|
||||||
const current = activeHref(pathname);
|
const current = activeHref(pathname);
|
||||||
|
const nav = visibleNav(user);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
me()
|
me()
|
||||||
.then((u) => {
|
.then((u) => {
|
||||||
if (alive) {
|
if (!alive) return;
|
||||||
setUser(u);
|
setUser(u);
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
// The account wins over the localStorage copy the pre-hydration script
|
||||||
|
// painted with: that copy is this browser's, while the account follows
|
||||||
|
// the person between machines. Re-save so the next cold paint here is
|
||||||
|
// already correct.
|
||||||
|
const accountScale = normalizeUiScale(u.uiScale ?? DEFAULT_UI_SCALE);
|
||||||
|
setUiScale(accountScale);
|
||||||
|
applyUiScale(accountScale);
|
||||||
|
saveUiScale(accountScale);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
@@ -76,6 +290,39 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Before /auth/me answers, show whatever the pre-hydration script applied so
|
||||||
|
// the control isn't briefly out of step with the page.
|
||||||
|
useEffect(() => {
|
||||||
|
setUiScale(readUiScale());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function changeUiScale(next: number) {
|
||||||
|
setUiScale(next);
|
||||||
|
applyUiScale(next);
|
||||||
|
saveUiScale(next);
|
||||||
|
setUser((prev) => (prev ? { ...prev, uiScale: next } : prev));
|
||||||
|
// Fire and forget: the change is already applied and cached locally, so a
|
||||||
|
// failed write only means it won't follow the user to another machine.
|
||||||
|
updateUiScale(next).catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigating away closes the mobile drawer — the route change is the only
|
||||||
|
// "done" signal we get from a <Link>.
|
||||||
|
useEffect(() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawerOpen) return;
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [drawerOpen]);
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
setLoggingOut(true);
|
setLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
@@ -117,24 +364,30 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="appbar-nav" aria-label="Principal">
|
<nav className="appbar-nav" aria-label="Principal">
|
||||||
{NAV.filter((item) => !item.ability || can(user, item.ability)).map(
|
{nav.map((entry) =>
|
||||||
(item) => {
|
entry.kind === "link" ? (
|
||||||
const active = current === item.href;
|
<Link
|
||||||
return (
|
key={entry.href}
|
||||||
<Link
|
href={entry.href}
|
||||||
key={item.href}
|
className={`appbar-link${current === entry.href ? " active" : ""}`}
|
||||||
href={item.href}
|
aria-current={current === entry.href ? "page" : undefined}
|
||||||
className={`appbar-link${active ? " active" : ""}`}
|
>
|
||||||
aria-current={active ? "page" : undefined}
|
{entry.label}
|
||||||
>
|
</Link>
|
||||||
{item.label}
|
) : (
|
||||||
</Link>
|
<NavMenu
|
||||||
);
|
key={entry.label}
|
||||||
},
|
label={entry.label}
|
||||||
|
items={entry.items}
|
||||||
|
current={current}
|
||||||
|
pathname={pathname}
|
||||||
|
/>
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
<span className="appbar-spacer" />
|
<span className="appbar-spacer" />
|
||||||
<div className="appbar-user">
|
<div className="appbar-user">
|
||||||
|
<FontScaleControl value={uiScale} onChange={changeUiScale} />
|
||||||
{user && (
|
{user && (
|
||||||
<span className="appbar-user-name">
|
<span className="appbar-user-name">
|
||||||
{user.name}
|
{user.name}
|
||||||
@@ -150,9 +403,59 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="appbar-burger"
|
||||||
|
aria-label={drawerOpen ? "Cerrar menú" : "Abrir menú"}
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
onClick={() => setDrawerOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span className={`burger-icon${drawerOpen ? " open" : ""}`} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{drawerOpen && (
|
||||||
|
<nav className="appbar-drawer" aria-label="Principal (móvil)">
|
||||||
|
{nav.map((entry) =>
|
||||||
|
entry.kind === "link" ? (
|
||||||
|
<Link
|
||||||
|
key={entry.href}
|
||||||
|
href={entry.href}
|
||||||
|
className={`appbar-drawer-link${current === entry.href ? " active" : ""}`}
|
||||||
|
aria-current={current === entry.href ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div key={entry.label} className="appbar-drawer-group">
|
||||||
|
<span className="appbar-drawer-heading">{entry.label}</span>
|
||||||
|
{entry.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`appbar-drawer-link${current === item.href ? " active" : ""}`}
|
||||||
|
aria-current={current === item.href ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<FontScaleControl
|
||||||
|
value={uiScale}
|
||||||
|
onChange={changeUiScale}
|
||||||
|
variant="inline"
|
||||||
|
/>
|
||||||
|
{user && (
|
||||||
|
<div className="appbar-drawer-user">
|
||||||
|
{user.name} · {ROLE_LABEL[user.role]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="shell-main">{children}</main>
|
<main className="shell-main">{children}</main>
|
||||||
|
<BuildFooter />
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ManualCheckCapture } from "@/components/ManualCheckCapture";
|
||||||
|
import { StatementIntake } from "@/components/StatementIntake";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daily capture screen (the legacy "Editor"), with two ways in:
|
||||||
|
*
|
||||||
|
* - **manual** — key each customer's receipt against one check by hand.
|
||||||
|
* - **auto** — scan the stack of paper bills and let OCR propose customer and
|
||||||
|
* amount for every page, which a human still confirms.
|
||||||
|
*
|
||||||
|
* Both end in the same place: charges on the customer's ledger, posted against
|
||||||
|
* one check. They are modes of one screen rather than two menu entries because
|
||||||
|
* it is one job — staff pick the mode by what's on the desk that morning, a
|
||||||
|
* stack of bills or a keyboard.
|
||||||
|
*
|
||||||
|
* `/estado-cuenta/lote` opens on manual, `/recibos` opens on auto; both render
|
||||||
|
* this component, so an old bookmark still lands on the right tab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CaptureMode = "manual" | "auto";
|
||||||
|
|
||||||
|
const MODE_HINT: Record<CaptureMode, string> = {
|
||||||
|
manual:
|
||||||
|
"Captura los recibos de varios clientes contra un mismo cheque y concilia el total antes de guardar.",
|
||||||
|
auto: "Escanea los recibos del mes y el sistema propone cliente e importe para cada página. Nada se registra sin tu confirmación.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Captura({ initialMode = "manual" }: { initialMode?: CaptureMode }) {
|
||||||
|
const canCapture = useCan("ledger:create");
|
||||||
|
const canIngest = useCan("statement:ingest");
|
||||||
|
|
||||||
|
// Gating is cosmetic (the API enforces every write), but a user who only has
|
||||||
|
// one of the two abilities should land on the mode they can actually use.
|
||||||
|
const modes: { key: CaptureMode; label: string }[] = [
|
||||||
|
...(canCapture ? [{ key: "manual" as const, label: "Captura manual" }] : []),
|
||||||
|
...(canIngest
|
||||||
|
? [{ key: "auto" as const, label: "Captura automática (OCR)" }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<CaptureMode>(
|
||||||
|
modes.some((m) => m.key === initialMode) ? initialMode : (modes[0]?.key ?? "manual"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (modes.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
No tienes permiso para capturar movimientos.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Captura</h1>
|
||||||
|
<p className="eyebrow">{MODE_HINT[mode]}</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||||
|
Volver a estado de cuenta
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modes.length > 1 && (
|
||||||
|
<div className="seg" role="tablist" style={{ marginBottom: 16 }}>
|
||||||
|
{modes.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === m.key}
|
||||||
|
className={`seg-btn ${mode === m.key ? "active" : ""}`}
|
||||||
|
onClick={() => setMode(m.key)}
|
||||||
|
>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "manual" ? <ManualCheckCapture /> : <StatementIntake />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { UI_SCALES } from "@/lib/ui-scale";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text-size picker. Controlled: AppShell owns the value and handles applying
|
||||||
|
* and persisting it, because the same setting is edited from two places (the
|
||||||
|
* appbar popover and the mobile drawer) and reconciled against the account on
|
||||||
|
* load.
|
||||||
|
*
|
||||||
|
* `variant="menu"` is the compact appbar popover; `variant="inline"` is the
|
||||||
|
* flat row used inside the mobile drawer, where a popover inside a popover
|
||||||
|
* would be awkward.
|
||||||
|
*/
|
||||||
|
export function FontScaleControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
variant = "menu",
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (scale: number) => void;
|
||||||
|
variant?: "menu" | "inline";
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
function onPointerDown(event: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onPointerDown);
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onPointerDown);
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
function choose(next: number) {
|
||||||
|
onChange(next);
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = UI_SCALES.map((option) => ({
|
||||||
|
...option,
|
||||||
|
selected: Math.abs(value - option.value) < 0.001,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (variant === "inline") {
|
||||||
|
return (
|
||||||
|
<div className="scale-inline" role="radiogroup" aria-label="Tamaño de texto">
|
||||||
|
<span className="appbar-drawer-heading">Tamaño de texto</span>
|
||||||
|
<div className="scale-inline-options">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={option.selected}
|
||||||
|
className={`scale-chip${option.selected ? " active" : ""}`}
|
||||||
|
style={{ fontSize: `${option.value}em` }}
|
||||||
|
onClick={() => choose(option.value)}
|
||||||
|
>
|
||||||
|
{option.short}
|
||||||
|
<span className="sr-only"> {option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="appbar-menu" ref={ref}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="appbar-scale-trigger"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-label="Tamaño de texto"
|
||||||
|
title="Tamaño de texto"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">
|
||||||
|
A<span className="appbar-scale-big">A</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="appbar-dropdown appbar-dropdown-right" role="menu">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={option.selected}
|
||||||
|
className={`appbar-dropdown-link scale-option${option.selected ? " active" : ""}`}
|
||||||
|
onClick={() => choose(option.value)}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: `${option.value}em` }}>{option.short}</span>
|
||||||
|
<span className="scale-option-label">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import { createMovementBatch, getBillingFacets, getByCheck } from "@/lib/api";
|
||||||
|
import { formatMoney, formatNumber, txTypeLabel } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BatchCreateInput,
|
||||||
|
BillingFacets,
|
||||||
|
ByCheckResponse,
|
||||||
|
Currency,
|
||||||
|
LedgerCurrency,
|
||||||
|
TransactionDomain,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch capture by check — the "Editor" screen from the legacy system
|
||||||
|
* (docs/RECEIPT_CAPTURE_SPEC.md §1.2), and the manual half of the Captura
|
||||||
|
* screen (see `Captura`).
|
||||||
|
*
|
||||||
|
* Staff key many customers' receipts against ONE physical check before cutting
|
||||||
|
* it, then check that the captured total matches the check's amount. That
|
||||||
|
* reconciliation is the whole point, so the running total is the most prominent
|
||||||
|
* thing on the page and an optional "importe del cheque" field turns it into a
|
||||||
|
* live difference.
|
||||||
|
*
|
||||||
|
* No batch entity is persisted: `checkNumber` is a plain column, and grouping
|
||||||
|
* by it answers every by-check question (see the "Reporte por cheque" report).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOMAINS: { key: TransactionDomain; label: string }[] = [
|
||||||
|
{ key: "UTILITY", label: "Servicios" },
|
||||||
|
{ key: "INSURANCE", label: "Seguros" },
|
||||||
|
{ key: "TRUST", label: "Fideicomiso" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
/** Local row key — lines have no server identity until the batch posts. */
|
||||||
|
key: number;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: string;
|
||||||
|
reference: string;
|
||||||
|
period: string;
|
||||||
|
outstanding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankLine(key: number): Line {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
customerId: "",
|
||||||
|
customerName: "",
|
||||||
|
amount: "",
|
||||||
|
reference: "",
|
||||||
|
period: "",
|
||||||
|
outstanding: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualCheckCapture() {
|
||||||
|
const [facets, setFacets] = useState<BillingFacets | null>(null);
|
||||||
|
|
||||||
|
// Check-level fields — shared by every line.
|
||||||
|
const [domain, setDomain] = useState<TransactionDomain>("UTILITY");
|
||||||
|
const [currency, setCurrency] = useState<LedgerCurrency>("MXN");
|
||||||
|
const [typeId, setTypeId] = useState("");
|
||||||
|
const [checkNumber, setCheckNumber] = useState("");
|
||||||
|
const [transactionDate, setTransactionDate] = useState(
|
||||||
|
new Date().toISOString().slice(0, 10),
|
||||||
|
);
|
||||||
|
/** The physical check's amount, for reconciliation only — never submitted. */
|
||||||
|
const [checkAmount, setCheckAmount] = useState("");
|
||||||
|
|
||||||
|
const [lines, setLines] = useState<Line[]>([blankLine(1), blankLine(2), blankLine(3)]);
|
||||||
|
const [nextKey, setNextKey] = useState(4);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [posted, setPosted] = useState<ByCheckResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filled = lines.filter(
|
||||||
|
(l) => l.customerId && l.amount.trim() !== "" && Number.isFinite(Number(l.amount)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Charges are captured as positive numbers and signed on submit, matching
|
||||||
|
// MovementForm — staff type what's on the bill, not a negative.
|
||||||
|
const total = useMemo(
|
||||||
|
() =>
|
||||||
|
filled
|
||||||
|
.filter((l) => !l.outstanding)
|
||||||
|
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
||||||
|
[filled],
|
||||||
|
);
|
||||||
|
const outstandingTotal = useMemo(
|
||||||
|
() =>
|
||||||
|
filled
|
||||||
|
.filter((l) => l.outstanding)
|
||||||
|
.reduce((sum, l) => sum + Math.abs(Number(l.amount)), 0),
|
||||||
|
[filled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkAmt = Number(checkAmount);
|
||||||
|
const hasCheckAmt = checkAmount.trim() !== "" && Number.isFinite(checkAmt);
|
||||||
|
const diff = hasCheckAmt ? checkAmt - total : 0;
|
||||||
|
const reconciled = hasCheckAmt && Math.abs(diff) < 0.005;
|
||||||
|
|
||||||
|
function update(key: number, patch: Partial<Line>) {
|
||||||
|
setLines((ls) => ls.map((l) => (l.key === key ? { ...l, ...patch } : l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine() {
|
||||||
|
setLines((ls) => [...ls, blankLine(nextKey)]);
|
||||||
|
setNextKey((k) => k + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine(key: number) {
|
||||||
|
setLines((ls) => (ls.length === 1 ? ls : ls.filter((l) => l.key !== key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!checkNumber.trim()) {
|
||||||
|
setError("Indica el número de cheque.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (filled.length === 0) {
|
||||||
|
setError("Captura al menos una línea con cliente y monto.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dupes = filled
|
||||||
|
.map((l) => l.customerId)
|
||||||
|
.filter((id, i, arr) => arr.indexOf(id) !== i);
|
||||||
|
if (dupes.length) {
|
||||||
|
const names = filled
|
||||||
|
.filter((l) => dupes.includes(l.customerId))
|
||||||
|
.map((l) => l.customerName);
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Hay más de una línea para el mismo cliente (${[...new Set(names)].join(
|
||||||
|
", ",
|
||||||
|
)}). ¿Continuar?`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: BatchCreateInput = {
|
||||||
|
domain,
|
||||||
|
transactionDate,
|
||||||
|
checkNumber: checkNumber.trim(),
|
||||||
|
currency: currency as Currency,
|
||||||
|
typeId: typeId || undefined,
|
||||||
|
lines: filled.map((l) => ({
|
||||||
|
customerId: l.customerId,
|
||||||
|
// Every line of a check batch is a charge the office paid out.
|
||||||
|
amount: -Math.abs(Number(l.amount)),
|
||||||
|
reference: l.reference.trim() || undefined,
|
||||||
|
period: l.period.trim() || undefined,
|
||||||
|
outstanding: l.outstanding || undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await createMovementBatch(payload);
|
||||||
|
// Re-read through the by-check view so the confirmation shows what's
|
||||||
|
// actually stored (including anything captured against this check
|
||||||
|
// earlier), not just what this request sent.
|
||||||
|
setPosted(await getByCheck(payload.checkNumber));
|
||||||
|
} catch (e2) {
|
||||||
|
setError((e2 as Error)?.message ?? "No se pudo guardar el lote.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setPosted(null);
|
||||||
|
setLines([blankLine(nextKey), blankLine(nextKey + 1), blankLine(nextKey + 2)]);
|
||||||
|
setNextKey((k) => k + 3);
|
||||||
|
setCheckNumber("");
|
||||||
|
setCheckAmount("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posted) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<h2 className="page-title">Lote capturado</h2>
|
||||||
|
<p className="eyebrow">
|
||||||
|
Cheque {posted.checkNumber} · {formatNumber(posted.count)}{" "}
|
||||||
|
{posted.count === 1 ? "movimiento" : "movimientos"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 10 }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={reset}>
|
||||||
|
Capturar otro cheque
|
||||||
|
</button>
|
||||||
|
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||||
|
Volver a estado de cuenta
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filtered-totals" style={{ marginBottom: 16 }}>
|
||||||
|
{posted.totals.map((t) => (
|
||||||
|
<div className="filtered-total" key={t.currency}>
|
||||||
|
<span className="filtered-total-cur">{t.currency}</span>
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
Total del cheque <strong>{formatMoney(t.total, t.currency)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>{formatNumber(t.count)} movimientos</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{posted.outstandingCount > 0 && (
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span>
|
||||||
|
{formatNumber(posted.outstandingCount)} sin fondos (no suman al
|
||||||
|
total)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Periodo</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th className="num">Monto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{posted.items.map((i) => (
|
||||||
|
<tr key={i.id}>
|
||||||
|
<td>
|
||||||
|
<Link
|
||||||
|
href={`/estado-cuenta/${i.customerId}`}
|
||||||
|
className="inline-link"
|
||||||
|
>
|
||||||
|
{i.customerName}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td>{i.reference || "—"}</td>
|
||||||
|
<td>{i.period || "—"}</td>
|
||||||
|
<td>{i.outstanding ? "Sin fondos" : "Pagado"}</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className="tx-amount neg">
|
||||||
|
{formatMoney(i.amount, i.currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="muted" style={{ marginTop: 14 }}>
|
||||||
|
Para imprimir la conciliación, usa el reporte{" "}
|
||||||
|
<Link
|
||||||
|
href={`/reportes/cheque-count?checkNumber=${encodeURIComponent(
|
||||||
|
posted.checkNumber,
|
||||||
|
)}`}
|
||||||
|
className="inline-link"
|
||||||
|
>
|
||||||
|
Reporte por cheque
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||||
|
Datos del cheque
|
||||||
|
</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Número de cheque *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={checkNumber}
|
||||||
|
onChange={(e) => setCheckNumber(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Fecha *</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={transactionDate}
|
||||||
|
onChange={(e) => setTransactionDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Línea de negocio *</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={domain}
|
||||||
|
onChange={(e) => setDomain(e.target.value as TransactionDomain)}
|
||||||
|
>
|
||||||
|
{DOMAINS.map((d) => (
|
||||||
|
<option key={d.key} value={d.key}>
|
||||||
|
{d.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Moneda *</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as LedgerCurrency)}
|
||||||
|
>
|
||||||
|
<option value="MXN">Pesos (MXN)</option>
|
||||||
|
<option value="USD">Dólares (USD)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Concepto</span>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={typeId}
|
||||||
|
onChange={(e) => setTypeId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">(sin concepto)</option>
|
||||||
|
{facets?.types.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{txTypeLabel({ nameEn: t.name })}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Importe del cheque</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={checkAmount}
|
||||||
|
onChange={(e) => setCheckAmount(e.target.value)}
|
||||||
|
placeholder="Para conciliar"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>
|
||||||
|
Recibos ({formatNumber(filled.length)})
|
||||||
|
</h2>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={addLine}>
|
||||||
|
Agregar línea
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ minWidth: 240 }}>Cliente *</th>
|
||||||
|
<th style={{ minWidth: 120 }}>Referencia</th>
|
||||||
|
<th style={{ minWidth: 100 }}>Periodo</th>
|
||||||
|
<th style={{ minWidth: 110 }} className="num">
|
||||||
|
Monto *
|
||||||
|
</th>
|
||||||
|
<th style={{ whiteSpace: "nowrap" }}>Sin fondos</th>
|
||||||
|
<th style={{ width: 1 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lines.map((l) => (
|
||||||
|
<tr key={l.key}>
|
||||||
|
<td>
|
||||||
|
<CustomerPicker
|
||||||
|
value={l.customerId}
|
||||||
|
valueName={l.customerId ? l.customerName : undefined}
|
||||||
|
onPick={(id, name) =>
|
||||||
|
update(l.key, { customerId: id, customerName: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={l.reference}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(l.key, { reference: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={l.period}
|
||||||
|
onChange={(e) => update(l.key, { period: e.target.value })}
|
||||||
|
placeholder="2026-07"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="input num"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={l.amount}
|
||||||
|
onChange={(e) => update(l.key, { amount: e.target.value })}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "center" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={l.outstanding}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(l.key, { outstanding: e.target.checked })
|
||||||
|
}
|
||||||
|
aria-label="Sin fondos"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ padding: "4px 10px", fontSize: 12 }}
|
||||||
|
onClick={() => removeLine(l.key)}
|
||||||
|
disabled={lines.length === 1}
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>
|
||||||
|
Conciliación
|
||||||
|
</h2>
|
||||||
|
<div className="filtered-totals">
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span className="filtered-total-cur">{currency}</span>
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
Capturado <strong>{formatMoney(String(-total), currency)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>{formatNumber(filled.filter((l) => !l.outstanding).length)} recibos</span>
|
||||||
|
</div>
|
||||||
|
{outstandingTotal > 0 && (
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span>
|
||||||
|
Sin fondos{" "}
|
||||||
|
<strong>{formatMoney(String(-outstandingTotal), currency)}</strong>{" "}
|
||||||
|
(no suma al cheque)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasCheckAmt && (
|
||||||
|
<div className="filtered-total">
|
||||||
|
<span className="filtered-total-net">
|
||||||
|
{reconciled ? (
|
||||||
|
<strong className="tx-amount pos">Cuadra con el cheque</strong>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Diferencia{" "}
|
||||||
|
<strong className="tx-amount neg">
|
||||||
|
{formatMoney(String(diff), currency)}
|
||||||
|
</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving || filled.length === 0}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? "Guardando…"
|
||||||
|
: `Capturar ${formatNumber(filled.length)} ${
|
||||||
|
filled.length === 1 ? "recibo" : "recibos"
|
||||||
|
}`}
|
||||||
|
</button>
|
||||||
|
<Link href="/estado-cuenta" className="btn btn-outline">
|
||||||
|
Cancelar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
getStatementStatus,
|
||||||
|
listStatementBatches,
|
||||||
|
uploadStatementBatch,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatDate, SERVICE_KIND_LABELS, serviceKindLabel } from "@/lib/labels";
|
||||||
|
import type { ServiceKind, StatementBatch, StatementBatchStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic capture — statement OCR intake (docs/RECEIPT_CAPTURE_SPEC.md §2).
|
||||||
|
*
|
||||||
|
* Each utility company mails 300+ paper bills a month, one per customer, which
|
||||||
|
* staff otherwise key in by hand on the manual tab of the same "Captura"
|
||||||
|
* screen. Here they scan the stack instead, and the machine proposes customer +
|
||||||
|
* amount for every page; a human still confirms before anything reaches the
|
||||||
|
* ledger. Same daily job, same ledger path — only the input differs, which is
|
||||||
|
* why it lives as a mode of Captura rather than a screen of its own.
|
||||||
|
*
|
||||||
|
* One batch = one service kind, because the matcher is scoped per kind: a
|
||||||
|
* water account number and a phone number are compared against different
|
||||||
|
* columns, and mixing them in one upload is how a bill gets posted to the
|
||||||
|
* wrong customer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The kinds the parsers actually recognise today. */
|
||||||
|
const SUPPORTED: ServiceKind[] = ["ELECTRIC", "WATER", "TELEPHONE"];
|
||||||
|
/** Uploadable, but every page will land in review until a parser learns it. */
|
||||||
|
const OTHER_KINDS: ServiceKind[] = ["GAS", "PROPERTY_TAX", "FEDERAL_ZONE", "CABLE"];
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<StatementBatchStatus, string> = {
|
||||||
|
UPLOADED: "Recibido",
|
||||||
|
PROCESSING: "Procesando…",
|
||||||
|
READY_FOR_REVIEW: "Listo para revisar",
|
||||||
|
COMPLETED: "Registrado",
|
||||||
|
FAILED: "Falló",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatementIntake() {
|
||||||
|
const canIngest = useCan("statement:ingest");
|
||||||
|
const [batches, setBatches] = useState<StatementBatch[]>([]);
|
||||||
|
const [ocrAvailable, setOcrAvailable] = useState<boolean | null>(null);
|
||||||
|
const [storageAvailable, setStorageAvailable] = useState<boolean | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [list, status] = await Promise.all([
|
||||||
|
listStatementBatches(),
|
||||||
|
getStatementStatus(),
|
||||||
|
]);
|
||||||
|
setBatches(list.items);
|
||||||
|
setOcrAvailable(status.ocrAvailable);
|
||||||
|
setStorageAvailable(status.storageAvailable);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudieron cargar los lotes.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// A batch of 300 pages takes minutes to OCR, so the list refreshes itself
|
||||||
|
// while anything is still working rather than making staff reload.
|
||||||
|
const working = batches.some(
|
||||||
|
(b) => b.status === "PROCESSING" || b.status === "UPLOADED",
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!working) return;
|
||||||
|
const t = setInterval(() => void load(), 4000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [working, load]);
|
||||||
|
|
||||||
|
const ready = ocrAvailable === true && storageAvailable === true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
{ocrAvailable === false && (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
Este servidor no tiene OCR instalado, así que no se pueden leer recibos
|
||||||
|
escaneados. Usa la captura manual; el resto del sistema funciona con
|
||||||
|
normalidad.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{storageAvailable === false && (
|
||||||
|
<div className="state-box state-error">
|
||||||
|
Este servidor no tiene configurado el almacenamiento de documentos, así
|
||||||
|
que no hay dónde guardar los recibos escaneados. Usa la captura manual
|
||||||
|
mientras se configura.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canIngest && ready && <UploadCard onDone={load} />}
|
||||||
|
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
|
Lotes
|
||||||
|
</h2>
|
||||||
|
{loading ? (
|
||||||
|
<div className="state-box">Cargando…</div>
|
||||||
|
) : batches.length === 0 ? (
|
||||||
|
<div className="state-box">Todavía no hay lotes de recibos.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Servicio</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th className="num">Páginas</th>
|
||||||
|
<th>Subido por</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{batches.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td style={{ whiteSpace: "nowrap" }}>{formatDate(b.createdAt)}</td>
|
||||||
|
<td>{serviceKindLabel(b.serviceKind)}</td>
|
||||||
|
<td>{b.label || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<StatusTag status={b.status} />
|
||||||
|
{b.error && (
|
||||||
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||||
|
{b.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">{b._count?.documents ?? 0}</td>
|
||||||
|
<td>{b.uploadedBy?.name ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<Link className="btn btn-ghost" href={`/recibos/${b.id}`}>
|
||||||
|
Revisar
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusTag({ status }: { status: StatementBatchStatus }) {
|
||||||
|
return <span className="tag">{STATUS_LABEL[status] ?? status}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadCard({ onDone }: { onDone: () => void }) {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [serviceKind, setServiceKind] = useState<ServiceKind>("ELECTRIC");
|
||||||
|
const [label, setLabel] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!files.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await uploadStatementBatch(files, serviceKind, label.trim() || undefined);
|
||||||
|
setFiles([]);
|
||||||
|
setLabel("");
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudo subir el lote.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupported = !SUPPORTED.includes(serviceKind);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
|
Subir recibos escaneados
|
||||||
|
</h2>
|
||||||
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Servicio</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={serviceKind}
|
||||||
|
onChange={(e) => setServiceKind(e.target.value as ServiceKind)}
|
||||||
|
>
|
||||||
|
<optgroup label="Con lectura automática">
|
||||||
|
{SUPPORTED.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{SERVICE_KIND_LABELS[k] ?? k}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Sin lectura automática (revisión manual)">
|
||||||
|
{OTHER_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{SERVICE_KIND_LABELS[k] ?? k}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Referencia (opcional)</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
placeholder="ej. CFE julio 2026"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Archivos PDF</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="input"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!files.length || busy}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{busy ? "Subiendo…" : `Procesar ${files.length || ""}`.trim()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unsupported && (
|
||||||
|
<div className="state-box" style={{ marginTop: 12 }}>
|
||||||
|
Todavía no hay lectura automática para{" "}
|
||||||
|
{SERVICE_KIND_LABELS[serviceKind] ?? serviceKind}: cada página quedará
|
||||||
|
para revisión manual. Al confirmarlas se guarda el número de cuenta,
|
||||||
|
así que los recibos del mes siguiente sí se reconocerán solos.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||||
|
Un lote es de un solo servicio. Cada página del PDF se trata como un
|
||||||
|
recibo distinto, salvo que el proveedor imprima varias hojas por cliente.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+201
-9
@@ -6,22 +6,34 @@ import type {
|
|||||||
BalanceFilter,
|
BalanceFilter,
|
||||||
BalanceListResponse,
|
BalanceListResponse,
|
||||||
BalanceSort,
|
BalanceSort,
|
||||||
|
BankAccount,
|
||||||
BankCleared,
|
BankCleared,
|
||||||
BankDirection,
|
BankDirection,
|
||||||
BankFacets,
|
BankFacets,
|
||||||
|
BankInstitution,
|
||||||
BankListResponse,
|
BankListResponse,
|
||||||
BankSort,
|
BankSort,
|
||||||
BankStats,
|
BankStats,
|
||||||
BankSummary,
|
BankSummary,
|
||||||
BatchCreateInput,
|
BatchCreateInput,
|
||||||
|
ConfirmBatchInput,
|
||||||
|
ConfirmBatchResult,
|
||||||
BatchCreateResponse,
|
BatchCreateResponse,
|
||||||
BillingFacets,
|
BillingFacets,
|
||||||
BillingStats,
|
BillingStats,
|
||||||
BusinessLine,
|
BusinessLine,
|
||||||
ByCheckResponse,
|
ByCheckResponse,
|
||||||
|
CreateBankAccountInput,
|
||||||
|
CreateBankInput,
|
||||||
CreateBankMovementInput,
|
CreateBankMovementInput,
|
||||||
CreateMovementInput,
|
CreateMovementInput,
|
||||||
|
UpdateBankAccountInput,
|
||||||
ResolveOutstandingInput,
|
ResolveOutstandingInput,
|
||||||
|
ReviewDocumentInput,
|
||||||
|
StatementBatch,
|
||||||
|
StatementBatchDetail,
|
||||||
|
StatementDocument,
|
||||||
|
StatementDocumentStatus,
|
||||||
CustomerDetail,
|
CustomerDetail,
|
||||||
CustomerInput,
|
CustomerInput,
|
||||||
CustomerListResponse,
|
CustomerListResponse,
|
||||||
@@ -136,10 +148,30 @@ export function me(): Promise<AuthUser> {
|
|||||||
return apiFetch<AuthUser>("/auth/me");
|
return apiFetch<AuthUser>("/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persist the caller's own text-size preference on their account. */
|
||||||
|
export function updateUiScale(uiScale: number): Promise<AuthUser> {
|
||||||
|
return apiFetch<AuthUser>("/auth/preferences", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ uiScale }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function logout(): Promise<{ success: boolean }> {
|
export function logout(): Promise<{ success: boolean }> {
|
||||||
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceVersion {
|
||||||
|
service: string;
|
||||||
|
version: string;
|
||||||
|
gitSha: string;
|
||||||
|
buildDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the API container reports it is running. Unauthenticated by design. */
|
||||||
|
export function getApiVersion(): Promise<ServiceVersion> {
|
||||||
|
return apiFetch<ServiceVersion>("/version");
|
||||||
|
}
|
||||||
|
|
||||||
export function getStats(): Promise<CustomerStats> {
|
export function getStats(): Promise<CustomerStats> {
|
||||||
return apiFetch<CustomerStats>("/customers/stats");
|
return apiFetch<CustomerStats>("/customers/stats");
|
||||||
}
|
}
|
||||||
@@ -601,7 +633,13 @@ export function getByCheck(checkNumber: string): Promise<ByCheckResponse> {
|
|||||||
|
|
||||||
/* ------------------------------------------------- Bank register (chequera) */
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every read below is scoped to one chequera. `bankAccountId` is required, not
|
||||||
|
* defaulted to "all accounts": the office's registers are in different
|
||||||
|
* currencies, and a combined total would be a figure that never existed.
|
||||||
|
*/
|
||||||
export interface BankQuery {
|
export interface BankQuery {
|
||||||
|
bankAccountId: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
@@ -614,7 +652,7 @@ export interface BankQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams({ bankAccountId: q.bankAccountId });
|
||||||
if (q.query) params.set("query", q.query);
|
if (q.query) params.set("query", q.query);
|
||||||
if (q.page) params.set("page", String(q.page));
|
if (q.page) params.set("page", String(q.page));
|
||||||
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||||
@@ -623,20 +661,78 @@ export function listBankMovements(q: BankQuery): Promise<BankListResponse> {
|
|||||||
if (q.from) params.set("from", q.from);
|
if (q.from) params.set("from", q.from);
|
||||||
if (q.to) params.set("to", q.to);
|
if (q.to) params.set("to", q.to);
|
||||||
if (q.sort) params.set("sort", q.sort);
|
if (q.sort) params.set("sort", q.sort);
|
||||||
const qs = params.toString();
|
return apiFetch<BankListResponse>(`/bank?${params.toString()}`);
|
||||||
return apiFetch<BankListResponse>(`/bank${qs ? `?${qs}` : ""}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBankStats(): Promise<BankStats> {
|
export function getBankStats(bankAccountId: string): Promise<BankStats> {
|
||||||
return apiFetch<BankStats>("/bank/stats");
|
return apiFetch<BankStats>(
|
||||||
|
`/bank/stats?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBankFacets(): Promise<BankFacets> {
|
export function getBankFacets(bankAccountId: string): Promise<BankFacets> {
|
||||||
return apiFetch<BankFacets>("/bank/facets");
|
return apiFetch<BankFacets>(
|
||||||
|
`/bank/facets?bankAccountId=${encodeURIComponent(bankAccountId)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBankSummary(year?: number): Promise<BankSummary> {
|
export function getBankSummary(
|
||||||
return apiFetch<BankSummary>(`/bank/summary${year ? `?year=${year}` : ""}`);
|
bankAccountId: string,
|
||||||
|
year?: number,
|
||||||
|
): Promise<BankSummary> {
|
||||||
|
const params = new URLSearchParams({ bankAccountId });
|
||||||
|
if (year) params.set("year", String(year));
|
||||||
|
return apiFetch<BankSummary>(`/bank/summary?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------- Chequera accounts (catalog) */
|
||||||
|
|
||||||
|
/** The account picker's source. Includes closed accounts, which stay readable. */
|
||||||
|
export function listBankAccounts(): Promise<BankAccount[]> {
|
||||||
|
return apiFetch<BankAccount[]>("/bank/accounts");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBankInstitutions(): Promise<BankInstitution[]> {
|
||||||
|
return apiFetch<BankInstitution[]>("/bank/banks");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBankInstitution(
|
||||||
|
input: CreateBankInput,
|
||||||
|
): Promise<BankInstitution> {
|
||||||
|
return apiFetch<BankInstitution>("/bank/banks", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBankInstitution(
|
||||||
|
id: string,
|
||||||
|
input: Partial<CreateBankInput>,
|
||||||
|
): Promise<BankInstitution> {
|
||||||
|
return apiFetch<BankInstitution>(`/bank/banks/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBankAccount(
|
||||||
|
input: CreateBankAccountInput,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch("/bank/accounts", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No `currency` — an account's booked movements are denominated in it. */
|
||||||
|
export function updateBankAccount(
|
||||||
|
id: string,
|
||||||
|
input: UpdateBankAccountInput,
|
||||||
|
): Promise<unknown> {
|
||||||
|
return apiFetch(`/bank/accounts/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
|
/** Append a new chequera movement. Booked rows are never edited — fix mistakes
|
||||||
@@ -806,3 +902,99 @@ export function reportDownloadUrl(
|
|||||||
const tail = qs.toString();
|
const tail = qs.toString();
|
||||||
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
|
return `${API_ORIGIN}/reports/${slug}/${format}${tail ? `?${tail}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------- Statement OCR intake (recibos) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this deployment can ingest scans — automatic capture is hidden
|
||||||
|
* without it. OCR reads the page, object storage keeps it; both are required.
|
||||||
|
*/
|
||||||
|
export function getStatementStatus(): Promise<{
|
||||||
|
ocrAvailable: boolean;
|
||||||
|
storageAvailable: boolean;
|
||||||
|
}> {
|
||||||
|
return apiFetch("/statements/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStatementBatches(
|
||||||
|
page = 1,
|
||||||
|
pageSize = 25,
|
||||||
|
): Promise<{
|
||||||
|
items: StatementBatch[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
}> {
|
||||||
|
return apiFetch(`/statements/batches?page=${page}&pageSize=${pageSize}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatementBatch(id: string): Promise<StatementBatchDetail> {
|
||||||
|
return apiFetch(`/statements/batches/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStatementDocuments(
|
||||||
|
batchId: string,
|
||||||
|
status?: StatementDocumentStatus,
|
||||||
|
): Promise<StatementDocument[]> {
|
||||||
|
const q = status ? `?status=${status}` : "";
|
||||||
|
return apiFetch(`/statements/batches/${batchId}/documents${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multi-file upload — one batch is usually several multi-page scans. */
|
||||||
|
export async function uploadStatementBatch(
|
||||||
|
files: File[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
label?: string,
|
||||||
|
): Promise<StatementBatch> {
|
||||||
|
const body = new FormData();
|
||||||
|
for (const f of files) body.append("files", f, f.name);
|
||||||
|
const qs = new URLSearchParams({ serviceKind });
|
||||||
|
if (label) qs.set("label", label);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `Error ${res.status}`;
|
||||||
|
try {
|
||||||
|
const b = await res.json();
|
||||||
|
if (b?.message) message = b.message;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error body */
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reviewStatementDocument(
|
||||||
|
id: string,
|
||||||
|
input: ReviewDocumentInput,
|
||||||
|
): Promise<StatementDocument> {
|
||||||
|
return apiFetch(`/statements/documents/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
|
||||||
|
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmStatementBatch(
|
||||||
|
batchId: string,
|
||||||
|
input: ConfirmBatchInput,
|
||||||
|
): Promise<ConfirmBatchResult> {
|
||||||
|
return apiFetch(`/statements/batches/${batchId}/confirm`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rendered page image. A plain <img src> — the cookie rides along. */
|
||||||
|
export function statementPageUrl(documentId: string): string {
|
||||||
|
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* The web image's own build identity.
|
||||||
|
*
|
||||||
|
* Same runtime-injection trick as API_ORIGIN (lib/api.ts): docker/web.Dockerfile
|
||||||
|
* bakes APP_VERSION / GIT_SHA / BUILD_DATE as ENV, layout.tsx reads them on the
|
||||||
|
* server per request and paints them into window.__APP_BUILD__. Reading
|
||||||
|
* process.env directly from a client component would return undefined — Next
|
||||||
|
* only inlines NEXT_PUBLIC_* into the browser bundle, and baking the version in
|
||||||
|
* at build time is exactly what we are avoiding elsewhere.
|
||||||
|
*/
|
||||||
|
export interface BuildInfo {
|
||||||
|
version: string;
|
||||||
|
gitSha: string;
|
||||||
|
buildDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNKNOWN_BUILD: BuildInfo = {
|
||||||
|
version: "dev",
|
||||||
|
gitSha: "unknown",
|
||||||
|
buildDate: "unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Server-side read, used by layout.tsx to produce the injected payload. */
|
||||||
|
export function readBuildInfoFromEnv(): BuildInfo {
|
||||||
|
return {
|
||||||
|
version: process.env.APP_VERSION ?? UNKNOWN_BUILD.version,
|
||||||
|
gitSha: process.env.GIT_SHA ?? UNKNOWN_BUILD.gitSha,
|
||||||
|
buildDate: process.env.BUILD_DATE ?? UNKNOWN_BUILD.buildDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Browser-side read of what layout.tsx injected. */
|
||||||
|
export function webBuildInfo(): BuildInfo {
|
||||||
|
if (typeof window === "undefined") return readBuildInfoFromEnv();
|
||||||
|
const injected = (window as { __APP_BUILD__?: BuildInfo }).__APP_BUILD__;
|
||||||
|
return injected ?? UNKNOWN_BUILD;
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ export const SERVICE_KIND_LABELS: Record<string, string> = {
|
|||||||
PROPERTY_TAX: "Predial",
|
PROPERTY_TAX: "Predial",
|
||||||
FEDERAL_ZONE: "Zona Federal",
|
FEDERAL_ZONE: "Zona Federal",
|
||||||
ALARM: "Alarma",
|
ALARM: "Alarma",
|
||||||
|
TELEPHONE: "Teléfono",
|
||||||
OTHER: "Otro",
|
OTHER: "Otro",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export const SERVICE_KIND_GLYPH: Record<string, string> = {
|
|||||||
WATER: "≈",
|
WATER: "≈",
|
||||||
ELECTRIC: "⚡",
|
ELECTRIC: "⚡",
|
||||||
GAS: "◐",
|
GAS: "◐",
|
||||||
|
TELEPHONE: "☎",
|
||||||
CABLE: "▤",
|
CABLE: "▤",
|
||||||
PROPERTY_TAX: "⌂",
|
PROPERTY_TAX: "⌂",
|
||||||
FEDERAL_ZONE: "⇲",
|
FEDERAL_ZONE: "⇲",
|
||||||
|
|||||||
+145
-4
@@ -19,6 +19,9 @@ export type Ability =
|
|||||||
| "ledger:void"
|
| "ledger:void"
|
||||||
| "bank:create"
|
| "bank:create"
|
||||||
| "bank:void"
|
| "bank:void"
|
||||||
|
| "bank:manage-accounts"
|
||||||
|
| "statement:ingest"
|
||||||
|
| "statement:review"
|
||||||
| "lookup:manage"
|
| "lookup:manage"
|
||||||
| "user:manage"
|
| "user:manage"
|
||||||
| "db:manage";
|
| "db:manage";
|
||||||
@@ -29,6 +32,10 @@ export interface AuthUser {
|
|||||||
email: string;
|
email: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
// Text-size preference, stored per account so it follows the person across
|
||||||
|
// machines. localStorage still holds a copy, but only as a pre-paint cache —
|
||||||
|
// this value is the source of truth. See lib/ui-scale.ts.
|
||||||
|
uiScale: number;
|
||||||
// Resolved server-side from role (abilitiesFor in the API); the UI only ever
|
// Resolved server-side from role (abilitiesFor in the API); the UI only ever
|
||||||
// reads this map, never re-derives the rules. Server still enforces.
|
// reads this map, never re-derives the rules. Server still enforces.
|
||||||
abilities: Record<Ability, boolean>;
|
abilities: Record<Ability, boolean>;
|
||||||
@@ -129,6 +136,7 @@ export type ServiceKind =
|
|||||||
| "PROPERTY_TAX"
|
| "PROPERTY_TAX"
|
||||||
| "FEDERAL_ZONE"
|
| "FEDERAL_ZONE"
|
||||||
| "ALARM"
|
| "ALARM"
|
||||||
|
| "TELEPHONE"
|
||||||
| "OTHER"
|
| "OTHER"
|
||||||
| string;
|
| string;
|
||||||
|
|
||||||
@@ -997,12 +1005,57 @@ export interface CustomerInput {
|
|||||||
/* ------------------------------------------------- Bank register (chequera) */
|
/* ------------------------------------------------- Bank register (chequera) */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The office's own checking account. Single-currency (MXN) and with no customer
|
* The office's own checking accounts — one register per chequera, no customer
|
||||||
* link — see `bank.service.ts`. Positive is a deposit, negative a payment, and
|
* link. See `bank.service.ts`. Positive is a deposit, negative a payment, and
|
||||||
* exactly zero a cancelled cheque.
|
* exactly zero a cancelled cheque. Every figure below belongs to exactly one
|
||||||
|
* `BankAccount` and is denominated in that account's currency; two accounts'
|
||||||
|
* figures are never combined.
|
||||||
*/
|
*/
|
||||||
export type BankDirection = "income" | "expense" | "void";
|
export type BankDirection = "income" | "expense" | "void";
|
||||||
|
|
||||||
|
/** A bank the office holds chequeras at. */
|
||||||
|
export interface BankInstitution {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** "MX" | "US" — informational. */
|
||||||
|
country: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One chequera. Its `currency` is what every figure on the page is read in. */
|
||||||
|
export interface BankAccount {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
currency: Currency;
|
||||||
|
/** Soft hint about which line of business it serves; never enforced. */
|
||||||
|
businessLine: TransactionDomain | null;
|
||||||
|
/** Closed accounts stay readable but take no new movements. */
|
||||||
|
active: boolean;
|
||||||
|
bankId: string;
|
||||||
|
bankName: string;
|
||||||
|
bankCountry: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBankInput {
|
||||||
|
name: string;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBankAccountInput {
|
||||||
|
bankId: string;
|
||||||
|
label: string;
|
||||||
|
/** Fixed at creation — an account's booked history is denominated in it. */
|
||||||
|
currency: Currency;
|
||||||
|
businessLine?: TransactionDomain;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateBankAccountInput {
|
||||||
|
bankId?: string;
|
||||||
|
label?: string;
|
||||||
|
businessLine?: TransactionDomain;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type BankCleared = "cleared" | "pending";
|
export type BankCleared = "cleared" | "pending";
|
||||||
|
|
||||||
export type BankSort =
|
export type BankSort =
|
||||||
@@ -1034,8 +1087,10 @@ export interface BankListItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for POST /bank — a new chequera movement. Sign convention: positive
|
/** Payload for POST /bank — a new chequera movement. Sign convention: positive
|
||||||
* = ingreso, negative = egreso. MXN only. */
|
* = ingreso, negative = egreso. The currency comes from the account. */
|
||||||
export interface CreateBankMovementInput {
|
export interface CreateBankMovementInput {
|
||||||
|
/** Which chequera it lands in. Required. */
|
||||||
|
bankAccountId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
transactionDate: string;
|
transactionDate: string;
|
||||||
concept?: string;
|
concept?: string;
|
||||||
@@ -1152,3 +1207,89 @@ export interface ReportRunResult {
|
|||||||
export interface ReportCatalog {
|
export interface ReportCatalog {
|
||||||
items: ReportDef[];
|
items: ReportDef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------- Statement OCR intake (recibos) */
|
||||||
|
|
||||||
|
export type StatementBatchStatus =
|
||||||
|
| "UPLOADED"
|
||||||
|
| "PROCESSING"
|
||||||
|
| "READY_FOR_REVIEW"
|
||||||
|
| "COMPLETED"
|
||||||
|
| "FAILED";
|
||||||
|
|
||||||
|
export type StatementDocumentStatus =
|
||||||
|
| "PENDING_OCR"
|
||||||
|
| "OCR_FAILED"
|
||||||
|
| "NEEDS_REVIEW"
|
||||||
|
| "MATCHED"
|
||||||
|
| "CONFIRMED"
|
||||||
|
| "POSTED"
|
||||||
|
| "REJECTED";
|
||||||
|
|
||||||
|
export interface StatementBatch {
|
||||||
|
id: string;
|
||||||
|
serviceKind: ServiceKind;
|
||||||
|
status: StatementBatchStatus;
|
||||||
|
label: string | null;
|
||||||
|
fileCount: number;
|
||||||
|
error: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
uploadedBy?: { name: string };
|
||||||
|
_count?: { documents: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementBatchDetail extends StatementBatch {
|
||||||
|
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
||||||
|
/** Sum of the amounts still awaiting posting. */
|
||||||
|
pendingTotal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementDocument {
|
||||||
|
id: string;
|
||||||
|
pageNumber: number;
|
||||||
|
status: StatementDocumentStatus;
|
||||||
|
provider: string | null;
|
||||||
|
ocrConfidence: string | null;
|
||||||
|
extractedAccountRef: string | null;
|
||||||
|
extractedAmount: string | null;
|
||||||
|
extractedPeriod: string | null;
|
||||||
|
extractedDueDate: string | null;
|
||||||
|
extractedCadastralKey: string | null;
|
||||||
|
matchNote: string | null;
|
||||||
|
matchedCustomer: { id: string; name: string } | null;
|
||||||
|
matchedPropertyService: {
|
||||||
|
id: string;
|
||||||
|
kind: ServiceKind;
|
||||||
|
accountNumber: string | null;
|
||||||
|
meterNumber: string | null;
|
||||||
|
property: { id: string; addressLine1: string | null };
|
||||||
|
} | null;
|
||||||
|
postedTransactionId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewDocumentInput {
|
||||||
|
accountRef?: string;
|
||||||
|
amount?: number;
|
||||||
|
period?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
matchedPropertyServiceId?: string;
|
||||||
|
matchedCustomerId?: string;
|
||||||
|
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check-level fields shared by every line posted from a batch. */
|
||||||
|
export interface ConfirmBatchInput {
|
||||||
|
checkNumber: string;
|
||||||
|
transactionDate: string;
|
||||||
|
currency?: Currency;
|
||||||
|
typeId?: string;
|
||||||
|
outstanding?: boolean;
|
||||||
|
includeReviewed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfirmBatchResult {
|
||||||
|
posted: number;
|
||||||
|
total: string;
|
||||||
|
checkNumber: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// App-wide text size. Every font-size *and* every spacing value in globals.css
|
||||||
|
// is in rem and the root size is `calc(100% * var(--ui-scale))`, so writing one
|
||||||
|
// variable on <html> rescales the entire UI — no per-component work, and the
|
||||||
|
// browser's own base font size still applies underneath.
|
||||||
|
//
|
||||||
|
// The account is the source of truth (User.uiScale, served on /auth/me).
|
||||||
|
// localStorage holds a copy purely so the pre-hydration script in
|
||||||
|
// app/layout.tsx can paint at the right size before the session is known;
|
||||||
|
// AppShell reconciles the two once /auth/me answers. Keep UI_SCALE_KEY and the
|
||||||
|
// bounds in sync with that script and with the API's UpdatePreferencesDto.
|
||||||
|
|
||||||
|
export const UI_SCALE_KEY = "jc.ui-scale";
|
||||||
|
export const DEFAULT_UI_SCALE = 1;
|
||||||
|
export const MIN_UI_SCALE = 0.9;
|
||||||
|
export const MAX_UI_SCALE = 1.5;
|
||||||
|
|
||||||
|
export const UI_SCALES: { value: number; label: string; short: string }[] = [
|
||||||
|
{ value: 0.9, label: "Compacto", short: "A" },
|
||||||
|
{ value: 1, label: "Normal", short: "A" },
|
||||||
|
{ value: 1.15, label: "Grande", short: "A" },
|
||||||
|
{ value: 1.3, label: "Muy grande", short: "A" },
|
||||||
|
{ value: 1.5, label: "Máximo", short: "A" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Clamp to the supported range; anything unparseable falls back to default. */
|
||||||
|
export function normalizeUiScale(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number.parseFloat(String(value));
|
||||||
|
if (!Number.isFinite(n)) return DEFAULT_UI_SCALE;
|
||||||
|
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUiScale(): number {
|
||||||
|
if (typeof window === "undefined") return DEFAULT_UI_SCALE;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(UI_SCALE_KEY);
|
||||||
|
return raw === null ? DEFAULT_UI_SCALE : normalizeUiScale(raw);
|
||||||
|
} catch {
|
||||||
|
// Private mode / storage disabled — the default is still usable.
|
||||||
|
return DEFAULT_UI_SCALE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUiScale(scale: number): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.style.setProperty("--ui-scale", String(scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveUiScale(scale: number): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(UI_SCALE_KEY, String(scale));
|
||||||
|
} catch {
|
||||||
|
/* ignore — the setting just won't survive a reload */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# NestJS API + Next.js web on galactus (standalone Docker, Portainer endpoint 3).
|
||||||
|
#
|
||||||
|
# Standalone port of deploy/jorgecuadros-app.stack.yml — see the header of
|
||||||
|
# deploy/galactus/jorgecuadros-db.compose.yml for the Swarm keys plain compose
|
||||||
|
# silently ignores. The one that matters most here: without
|
||||||
|
# `restart: unless-stopped` neither service returns after a host reboot.
|
||||||
|
#
|
||||||
|
# Cross-stack traffic still goes over the HOST, not service DNS. db and minio
|
||||||
|
# are separate Portainer stacks, so they are on separate compose networks and
|
||||||
|
# their service names do not resolve from here. DATABASE_URL / S3_ENDPOINT must
|
||||||
|
# name galactus's own address and the published port — exactly as on cubex
|
||||||
|
# today. Do not "simplify" them to `mysql:3306`.
|
||||||
|
#
|
||||||
|
# ...which means these containers have to resolve galactus's MagicDNS name, and
|
||||||
|
# by default they CANNOT. The host runs systemd-resolved, whose 127.0.0.53 stub
|
||||||
|
# is unreachable from a container, so Docker falls back to the upstream resolver
|
||||||
|
# in /run/systemd/resolve/resolv.conf — the LAN router, which knows nothing
|
||||||
|
# about the tailnet. Routing to 100.x works fine; only the lookup fails, and the
|
||||||
|
# API dies with Prisma P1001 "can't reach database server". Pointing the
|
||||||
|
# containers at Tailscale's own resolver fixes it. 100.100.100.100 is Tailscale's
|
||||||
|
# fixed anycast MagicDNS address (identical on every tailnet); the search domain
|
||||||
|
# is this tailnet's suffix.
|
||||||
|
#
|
||||||
|
# The web image is NOT URL-baked: the browser's API origin is injected at
|
||||||
|
# runtime from API_ORIGIN (apps/web/src/app/layout.tsx), so the same image works
|
||||||
|
# for any deployment. APP_VERSION / GIT_SHA / BUILD_DATE come baked in from
|
||||||
|
# build.yml and are surfaced at GET /version (api) and in the web footer.
|
||||||
|
#
|
||||||
|
# Keep in sync with deploy/jorgecuadros-app.stack.yml when either changes.
|
||||||
|
|
||||||
|
services:
|
||||||
|
api:
|
||||||
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
# Stable handle for deploy/scripts/pre-migrate-backup.sh, which finds this
|
||||||
|
# container by label to run mysqldump into the backup volume. A label
|
||||||
|
# survives stack renames; the compose service name does not.
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "api"
|
||||||
|
dns:
|
||||||
|
# MagicDNS first, then a public resolver. Listing ONLY 100.100.100.100
|
||||||
|
# costs the container public name resolution — apk/npm/any outbound
|
||||||
|
# hostname stops resolving — because MagicDNS does not forward to an
|
||||||
|
# upstream unless the tailnet is configured with global nameservers.
|
||||||
|
- ${TAILSCALE_DNS:-100.100.100.100}
|
||||||
|
- ${FALLBACK_DNS:-1.1.1.1}
|
||||||
|
dns_search:
|
||||||
|
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
|
# This deployment is HTTP, so a Secure session cookie would never be sent
|
||||||
|
# and login would silently never establish a session (express-session
|
||||||
|
# declines to emit a Secure cookie over a plain connection). Acceptable
|
||||||
|
# here ONLY because galactus is reachable exclusively over Tailscale, so
|
||||||
|
# WireGuard already encrypts the wire. Set this back to "true" the moment
|
||||||
|
# the app is served over TLS or exposed off-tailnet.
|
||||||
|
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||||
|
WEB_ORIGIN: ${WEB_ORIGIN:?WEB_ORIGIN must be set}
|
||||||
|
PORT: "3001"
|
||||||
|
INGEST_DIR: /data/ingest
|
||||||
|
BACKUP_DIR: /data/backups
|
||||||
|
MIGRATION_ENV: prod
|
||||||
|
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
||||||
|
# application user: --single-transaction needs the global RELOAD privilege
|
||||||
|
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
||||||
|
# and re-import fails without this. Host/port/database still come from
|
||||||
|
# DATABASE_URL — this only changes who logs in. See opsConn() in
|
||||||
|
# apps/api/src/ops/ops.service.ts.
|
||||||
|
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
|
||||||
|
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
|
||||||
|
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
||||||
|
S3_BUCKET: ${S3_BUCKET:-jorgecuadros-documents}
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
- "${API_PORT:-3001}:3001"
|
||||||
|
volumes:
|
||||||
|
# Uploaded Access files and DB backups. Named, so they survive every
|
||||||
|
# redeploy — and so the pre-migrate dump the deploy takes is the same
|
||||||
|
# file the "Operaciones" restore screen lists.
|
||||||
|
- ingest_data:/data/ingest
|
||||||
|
- backup_data:/data/backups
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "web"
|
||||||
|
# Next server-side rendering can call the API by API_ORIGIN, which is the
|
||||||
|
# same MagicDNS name — so the web container needs the resolver too.
|
||||||
|
dns:
|
||||||
|
# MagicDNS first, then a public resolver. Listing ONLY 100.100.100.100
|
||||||
|
# costs the container public name resolution — apk/npm/any outbound
|
||||||
|
# hostname stops resolving — because MagicDNS does not forward to an
|
||||||
|
# upstream unless the tailnet is configured with global nameservers.
|
||||||
|
- ${TAILSCALE_DNS:-100.100.100.100}
|
||||||
|
- ${FALLBACK_DNS:-1.1.1.1}
|
||||||
|
dns_search:
|
||||||
|
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
|
||||||
|
environment:
|
||||||
|
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||||
|
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3000}:3000"
|
||||||
|
depends_on:
|
||||||
|
# Unlike Swarm — which ignores depends_on entirely — plain compose honours
|
||||||
|
# this, so web waits for the API to pass its healthcheck.
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3000/ >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ingest_data:
|
||||||
|
backup_data:
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# MySQL for the Jorge Cuadros platform on galactus — the PROD source of truth.
|
||||||
|
#
|
||||||
|
# galactus is STANDALONE Docker (Portainer endpoint 3, `swarm: inactive`), not
|
||||||
|
# the 3-node Swarm on cubex. deploy/jorgecuadros-db.stack.yml is the Swarm
|
||||||
|
# version of this file; the deltas are called out below because plain compose
|
||||||
|
# SILENTLY IGNORES the Swarm keys rather than erroring on them:
|
||||||
|
#
|
||||||
|
# 1. `deploy.restart_policy` is ignored -> `restart: unless-stopped` instead.
|
||||||
|
# Without this MySQL does not come back after a host reboot. This is the
|
||||||
|
# single highest-risk difference.
|
||||||
|
# 2. `deploy.placement.constraints` is meaningless on one host — dropped,
|
||||||
|
# along with its `docker node update --label-add jorgecuadros_db=true`
|
||||||
|
# prerequisite.
|
||||||
|
# 3. `deploy.replicas` / `update_config` are ignored — dropped.
|
||||||
|
# 4. `ports: {mode: ingress}` long syntax is Swarm-only -> short syntax.
|
||||||
|
# 5. Named volumes stay exactly as they were: the node-pinning hazard that
|
||||||
|
# motivated them was purely a Swarm problem, and Portainer still namespaces
|
||||||
|
# the volume by stack name.
|
||||||
|
#
|
||||||
|
# This node is the REPLICATION MASTER for the whole topology. Every other MySQL
|
||||||
|
# is a replica of it. server-id must be unique across the topology (prod=1,
|
||||||
|
# cubex dev=11); a duplicate silently breaks replication. binlog + GTID are on
|
||||||
|
# from first boot so a replica can attach with SOURCE_AUTO_POSITION=1 and no
|
||||||
|
# file/position bookkeeping.
|
||||||
|
#
|
||||||
|
# Keep in sync with deploy/jorgecuadros-db.stack.yml when either changes.
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
# (caching_sha2_password is already the default in 8.4; the old
|
||||||
|
# --default-authentication-plugin flag was REMOVED in 8.4 and aborts boot.)
|
||||||
|
- --server-id=${MYSQL_SERVER_ID:-1}
|
||||||
|
- --log-bin=mysql-bin
|
||||||
|
- --binlog-format=ROW
|
||||||
|
- --gtid-mode=ON
|
||||||
|
- --enforce-gtid-consistency=ON
|
||||||
|
# A replica offline longer than this needs a full re-seed, because the
|
||||||
|
# binlogs it still needs are gone. The 8.4 default is 30 days; raise it
|
||||||
|
# here rather than discovering the gap during an outage.
|
||||||
|
- --binlog-expire-logs-seconds=${MYSQL_BINLOG_EXPIRE_SECONDS:-5184000}
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-jorgecuadros}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-jorgecuadros}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
# Standalone: binds directly on the host. Reachable at
|
||||||
|
# <galactus>:${MYSQL_PORT}. Replicas connect here — see
|
||||||
|
# docs/DEPLOY_AND_MIGRATIONS.md on NOT exposing raw 3306 to the internet.
|
||||||
|
- "${MYSQL_PORT:-3306}:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# MinIO object storage on galactus (standalone Docker, Portainer endpoint 3).
|
||||||
|
#
|
||||||
|
# Holds the document blobs extracted from the Access LONGBINARY columns; MySQL
|
||||||
|
# keeps only the storageKey pointer. Standalone port of
|
||||||
|
# deploy/jorgecuadros-minio.stack.yml — see the header of
|
||||||
|
# deploy/galactus/jorgecuadros-db.compose.yml for the full list of Swarm keys
|
||||||
|
# that plain compose silently ignores.
|
||||||
|
#
|
||||||
|
# Keep in sync with deploy/jorgecuadros-minio.stack.yml when either changes.
|
||||||
|
|
||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
|
||||||
|
restart: unless-stopped
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?MINIO_ROOT_USER must be set}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD must be set}
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "mc ready local || curl -f http://localhost:9000/minio/health/live || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio_data:
|
||||||
@@ -27,6 +27,11 @@ version: "3.8"
|
|||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
image: git.mancinas.io/rmancinas/jorgecuadros-api:${APP_TAG:-latest}
|
||||||
|
# Container label (not `deploy.labels`, which labels the swarm SERVICE).
|
||||||
|
# deploy/scripts/pre-migrate-backup.mjs finds the container by this label to
|
||||||
|
# run its pre-migrate mysqldump into the backup volume.
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "api"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
DATABASE_URL: ${DATABASE_URL:?DATABASE_URL must be set}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
@@ -36,6 +41,14 @@ services:
|
|||||||
INGEST_DIR: /data/ingest
|
INGEST_DIR: /data/ingest
|
||||||
BACKUP_DIR: /data/backups
|
BACKUP_DIR: /data/backups
|
||||||
MIGRATION_ENV: prod
|
MIGRATION_ENV: prod
|
||||||
|
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
||||||
|
# application user: --single-transaction needs the global RELOAD privilege
|
||||||
|
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
||||||
|
# and re-import fails without this. Host/port/database still come from
|
||||||
|
# DATABASE_URL — this only changes who logs in. See opsConn() in
|
||||||
|
# apps/api/src/ops/ops.service.ts.
|
||||||
|
OPS_DB_ADMIN_USER: ${OPS_DB_ADMIN_USER:-root}
|
||||||
|
OPS_DB_ADMIN_PASSWORD: ${OPS_DB_ADMIN_PASSWORD:?OPS_DB_ADMIN_PASSWORD must be set}
|
||||||
# Object storage — internal endpoint the API (server-side) uses to reach
|
# Object storage — internal endpoint the API (server-side) uses to reach
|
||||||
# the minio stack. Not browser-facing (downloads proxy through the API).
|
# the minio stack. Not browser-facing (downloads proxy through the API).
|
||||||
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
S3_ENDPOINT: ${S3_ENDPOINT:?S3_ENDPOINT must be set}
|
||||||
@@ -68,6 +81,8 @@ services:
|
|||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
image: git.mancinas.io/rmancinas/jorgecuadros-web:${APP_TAG:-latest}
|
||||||
|
labels:
|
||||||
|
io.jorgecuadros.role: "web"
|
||||||
environment:
|
environment:
|
||||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
||||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Take a mysqldump immediately before a deploy runs `prisma migrate deploy`.
|
||||||
|
*
|
||||||
|
* The dump runs in a DEDICATED, throwaway container built from the MySQL image,
|
||||||
|
* with the API's backup volume mounted — not inside the API container. Three
|
||||||
|
* reasons, each learned the hard way:
|
||||||
|
*
|
||||||
|
* 1. Deadlock. Dumping inside the API container makes the backup depend on
|
||||||
|
* whatever toolchain that image happens to carry. When the image shipped a
|
||||||
|
* MySQL client that could not authenticate, the backup failed, which blocked
|
||||||
|
* the very deploy that would have replaced the broken image. The backup must
|
||||||
|
* not depend on the thing being deployed.
|
||||||
|
* 2. The right client. Alpine's `mysql-client` is MariaDB's and cannot perform
|
||||||
|
* caching_sha2_password (MySQL 8.4's default auth). The official MySQL image
|
||||||
|
* obviously can.
|
||||||
|
* 3. Diagnosability. A container's logs can simply be read, whereas a detached
|
||||||
|
* exec reports nothing but an exit code.
|
||||||
|
*
|
||||||
|
* The file still lands in the API's BACKUP_DIR volume, because the only restore
|
||||||
|
* path this platform has is the "Operaciones" admin screen, which lists whatever
|
||||||
|
* `*.sql.gz` sits there (apps/api/src/ops/ops.service.ts).
|
||||||
|
*
|
||||||
|
* It must run BEFORE the app stack is re-applied, while the old container is up
|
||||||
|
* — that container is how the backup volume's name is discovered.
|
||||||
|
*
|
||||||
|
* Required env:
|
||||||
|
* PORTAINER_URL https://<host>:9443
|
||||||
|
* PORTAINER_API_KEY Portainer access token
|
||||||
|
* PORTAINER_ENDPOINT_ID numeric endpoint id (galactus = 3)
|
||||||
|
* DATABASE_URL mysql://user:pass@host:port/db — host/port/db only
|
||||||
|
* MYSQL_ROOT_PASSWORD the dump runs as root, see below
|
||||||
|
* BACKUP_TAG label for the filename, e.g. the deployed tag
|
||||||
|
* Optional env:
|
||||||
|
* ALLOW_MISSING_CONTAINER=true exit 0 when no API container exists yet
|
||||||
|
* BACKUP_VOLUME override the auto-discovered volume name
|
||||||
|
* DUMP_IMAGE default mysql:8.4
|
||||||
|
* API_CONTAINER_LABEL default io.jorgecuadros.role=api
|
||||||
|
* TAILSCALE_DNS / FALLBACK_DNS / TAILNET_SUFFIX
|
||||||
|
* EXEC_TIMEOUT_SECONDS default 1800
|
||||||
|
*
|
||||||
|
* Why root: mysqldump --single-transaction issues FLUSH TABLES, which needs the
|
||||||
|
* global RELOAD (or FLUSH_TABLES) privilege. The application user is granted
|
||||||
|
* only ALL ON `<db>`.* by the MySQL image and deliberately has no global rights,
|
||||||
|
* so it cannot take a consistent dump. Backups are an administrative operation;
|
||||||
|
* elevating the app's own runtime user instead would be the worse trade.
|
||||||
|
*
|
||||||
|
* TLS: Portainer here is self-signed; the caller sets
|
||||||
|
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) {
|
||||||
|
console.error(`missing required env: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
|
||||||
|
const API_KEY = required("PORTAINER_API_KEY");
|
||||||
|
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
|
||||||
|
const DATABASE_URL = required("DATABASE_URL");
|
||||||
|
const ROOT_PASSWORD = required("MYSQL_ROOT_PASSWORD");
|
||||||
|
const BACKUP_TAG = required("BACKUP_TAG");
|
||||||
|
|
||||||
|
const CONTAINER_LABEL =
|
||||||
|
process.env.API_CONTAINER_LABEL ?? "io.jorgecuadros.role=api";
|
||||||
|
const ALLOW_MISSING = process.env.ALLOW_MISSING_CONTAINER === "true";
|
||||||
|
const DUMP_IMAGE = process.env.DUMP_IMAGE ?? "mysql:8.4";
|
||||||
|
const DNS = [
|
||||||
|
process.env.TAILSCALE_DNS ?? "100.100.100.100",
|
||||||
|
process.env.FALLBACK_DNS ?? "1.1.1.1",
|
||||||
|
];
|
||||||
|
const DNS_SEARCH = [process.env.TAILNET_SUFFIX ?? "tail01aa2.ts.net"];
|
||||||
|
const TIMEOUT_MS = Number(process.env.EXEC_TIMEOUT_SECONDS ?? 1800) * 1000;
|
||||||
|
|
||||||
|
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||||
|
|
||||||
|
async function docker(path, init = {}) {
|
||||||
|
const res = await fetch(`${DOCKER}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"X-API-Key": API_KEY,
|
||||||
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`docker ${path} -> ${res.status} ${text.slice(0, 400)}`);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-quote for `sh -c`, the same discipline ops.service.ts uses. */
|
||||||
|
function shq(value) {
|
||||||
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDbUrl(raw) {
|
||||||
|
const u = new URL(raw);
|
||||||
|
return {
|
||||||
|
host: u.hostname,
|
||||||
|
port: u.port || "3306",
|
||||||
|
database: u.pathname.replace(/^\//, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches ops.service.ts's own naming: ISO, colons and dots flattened. */
|
||||||
|
function timestamp() {
|
||||||
|
return new Date()
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[:.]/g, "-")
|
||||||
|
.replace("T", "_")
|
||||||
|
.slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ops.service.ts refuses to restore any name outside this character set, so a
|
||||||
|
* file written with, say, a `/` in the tag would be permanently unrestorable
|
||||||
|
* through the UI. Sanitise before writing, not after.
|
||||||
|
*/
|
||||||
|
function safeTag(tag) {
|
||||||
|
return tag.replace(/[^A-Za-z0-9._-]/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findApiContainer() {
|
||||||
|
const [key, value] = CONTAINER_LABEL.split("=");
|
||||||
|
const filters = encodeURIComponent(
|
||||||
|
JSON.stringify({ label: [`${key}=${value}`], status: ["running"] }),
|
||||||
|
);
|
||||||
|
const list = await docker(`/containers/json?filters=${filters}`);
|
||||||
|
return list.length ? list[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The named volume the API mounts at /data/backups — where restores look. */
|
||||||
|
function backupVolumeOf(container) {
|
||||||
|
const mount = (container.Mounts ?? []).find(
|
||||||
|
(m) => m.Destination === "/data/backups",
|
||||||
|
);
|
||||||
|
return mount?.Name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the dump image is present. A `scope: app` deploy never touches the db
|
||||||
|
* stack, so a host can legitimately be missing it — and container/create fails
|
||||||
|
* with a bare 404 that reads like a Portainer problem rather than a missing
|
||||||
|
* image. The image is public, so no registry auth is involved.
|
||||||
|
*/
|
||||||
|
async function ensureDumpImage() {
|
||||||
|
const [repo, tag = "latest"] = DUMP_IMAGE.split(":");
|
||||||
|
const existing = await docker(`/images/${encodeURIComponent(DUMP_IMAGE)}/json`)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (existing) return;
|
||||||
|
console.log(`pulling ${DUMP_IMAGE} (not present on the host)...`);
|
||||||
|
const res = await fetch(
|
||||||
|
`${DOCKER}/images/create?fromImage=${encodeURIComponent(repo)}&tag=${encodeURIComponent(tag)}`,
|
||||||
|
{ method: "POST", headers: { "X-API-Key": API_KEY } },
|
||||||
|
);
|
||||||
|
const body = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`pull ${DUMP_IMAGE} -> HTTP ${res.status} ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
for (const line of body.split("\n").filter((l) => l.trim())) {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(line);
|
||||||
|
if (obj.error) throw new Error(`pull ${DUMP_IMAGE} failed: ${obj.error}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.startsWith("pull ")) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDumpContainer(cmd, env) {
|
||||||
|
await ensureDumpImage();
|
||||||
|
const created = await docker(`/containers/create`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
Image: DUMP_IMAGE,
|
||||||
|
Entrypoint: ["sh", "-c"],
|
||||||
|
Cmd: [cmd],
|
||||||
|
Env: env,
|
||||||
|
HostConfig: {
|
||||||
|
AutoRemove: false, // we read the logs before removing it ourselves
|
||||||
|
Binds: [`${BACKUP_VOLUME}:/data/backups`],
|
||||||
|
Dns: DNS,
|
||||||
|
DnsSearch: DNS_SEARCH,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const id = created.Id;
|
||||||
|
try {
|
||||||
|
await docker(`/containers/${id}/start`, { method: "POST" });
|
||||||
|
|
||||||
|
const deadline = Date.now() + TIMEOUT_MS;
|
||||||
|
for (;;) {
|
||||||
|
const info = await docker(`/containers/${id}/json`);
|
||||||
|
if (!info.State.Running) {
|
||||||
|
const logs = await fetch(
|
||||||
|
`${DOCKER}/containers/${id}/logs?stdout=true&stderr=true&tail=40`,
|
||||||
|
{ headers: { "X-API-Key": API_KEY } },
|
||||||
|
).then((r) => r.text());
|
||||||
|
// Strip Docker's 8-byte stream framing and any stray control bytes.
|
||||||
|
const clean = logs
|
||||||
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "")
|
||||||
|
.trim();
|
||||||
|
return { code: info.State.ExitCode ?? 1, logs: clean };
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(`dump timed out after ${TIMEOUT_MS / 1000}s`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await docker(`/containers/${id}?force=true`, { method: "DELETE" }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let BACKUP_VOLUME = process.env.BACKUP_VOLUME ?? null;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const container = await findApiContainer();
|
||||||
|
if (!container) {
|
||||||
|
const message = `no running container matching label ${CONTAINER_LABEL}`;
|
||||||
|
if (ALLOW_MISSING) {
|
||||||
|
console.warn(`skipping pre-migrate backup: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${message} — pass bootstrap=true only if this is the first deploy and ` +
|
||||||
|
`there is genuinely no data to lose`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BACKUP_VOLUME = BACKUP_VOLUME ?? backupVolumeOf(container);
|
||||||
|
if (!BACKUP_VOLUME) {
|
||||||
|
throw new Error(
|
||||||
|
"could not determine the backup volume from the API container's mounts; " +
|
||||||
|
"set BACKUP_VOLUME explicitly",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn = parseDbUrl(DATABASE_URL);
|
||||||
|
const file = `pre-migrate-${safeTag(BACKUP_TAG)}-${timestamp()}.sql.gz`;
|
||||||
|
const out = `/data/backups/${file}`;
|
||||||
|
|
||||||
|
console.log(`database : ${conn.host}:${conn.port}/${conn.database}`);
|
||||||
|
console.log(`volume : ${BACKUP_VOLUME}`);
|
||||||
|
console.log(`image : ${DUMP_IMAGE}`);
|
||||||
|
console.log(`writing : ${out}`);
|
||||||
|
|
||||||
|
// --set-gtid-purged=OFF because this server is the replication SOURCE with
|
||||||
|
// GTID on. Without it the dump embeds SET @@GLOBAL.GTID_PURGED, which makes
|
||||||
|
// the file unrestorable onto the very server it came from.
|
||||||
|
//
|
||||||
|
// pipefail is essential: without it the exit status is gzip's, so a dump that
|
||||||
|
// failed on the first statement still produces a small, perfectly valid .gz —
|
||||||
|
// a "successful" backup containing nothing.
|
||||||
|
//
|
||||||
|
// The table count is asserted for the same reason: valid gzip is not evidence
|
||||||
|
// of a usable dump. It is echoed so the log records how much was captured.
|
||||||
|
//
|
||||||
|
// A failed attempt deletes its own output. Otherwise every failure leaves a
|
||||||
|
// truncated .sql.gz sitting in the volume, and the Operaciones restore screen
|
||||||
|
// lists it as a perfectly ordinary restore point.
|
||||||
|
const dump =
|
||||||
|
`set -o pipefail; ` +
|
||||||
|
`( mysqldump --host=${conn.host} --port=${conn.port} --user=root ` +
|
||||||
|
`--single-transaction --routines --triggers --no-tablespaces ` +
|
||||||
|
`--set-gtid-purged=OFF ${shq(conn.database)} | gzip -c > ${shq(out)} && ` +
|
||||||
|
`gzip -t ${shq(out)} && ` +
|
||||||
|
`TABLES=$(gunzip -c ${shq(out)} | grep -c 'CREATE TABLE') && ` +
|
||||||
|
`echo "tables captured: $TABLES" && ` +
|
||||||
|
`[ "$TABLES" -ge 1 ] ); ` +
|
||||||
|
`rc=$?; ` +
|
||||||
|
`if [ $rc -ne 0 ]; then rm -f ${shq(out)}; ` +
|
||||||
|
`echo "removed incomplete backup ${file}"; fi; ` +
|
||||||
|
`exit $rc`;
|
||||||
|
|
||||||
|
const { code, logs } = await runDumpContainer(dump, [
|
||||||
|
// Password via MYSQL_PWD, never argv — argv is readable through `ps`.
|
||||||
|
`MYSQL_PWD=${ROOT_PASSWORD}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (logs) console.log(logs);
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`dump failed (exit ${code}) — refusing to migrate. See the output above.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`ok: ${file} written and verified in ${BACKUP_VOLUME}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`pre-migrate backup FAILED: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Pull the api + web images onto the target host before the stack is applied.
|
||||||
|
*
|
||||||
|
* This exists because the deploy action's `pull: true` does NOT reliably
|
||||||
|
* refresh an already-cached tag on a standalone endpoint. Observed on galactus
|
||||||
|
* 2026-07-30: the registry held web:latest built from 3ff56e6, the host still
|
||||||
|
* had a web:latest cached from an earlier commit, the deploy reported success,
|
||||||
|
* and the running container served the OLD build. A moving tag like `latest`
|
||||||
|
* makes this silent — the stack file names the same string either way, so
|
||||||
|
* nothing downstream notices.
|
||||||
|
*
|
||||||
|
* Pulling explicitly, and failing the deploy if a pull fails, makes "the image
|
||||||
|
* the host runs" a thing the workflow controls rather than hopes for.
|
||||||
|
*
|
||||||
|
* Required env:
|
||||||
|
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
|
||||||
|
* REGISTRY, REGISTRY_USERNAME, REGISTRY_PASSWORD
|
||||||
|
* IMAGES comma-separated repositories, e.g. "owner/api,owner/web"
|
||||||
|
* TAG the tag to pull
|
||||||
|
*
|
||||||
|
* TLS: Portainer here is self-signed; the caller sets
|
||||||
|
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function required(name) {
|
||||||
|
const v = process.env[name];
|
||||||
|
if (!v) {
|
||||||
|
console.error(`missing required env: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
|
||||||
|
const API_KEY = required("PORTAINER_API_KEY");
|
||||||
|
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
|
||||||
|
const REGISTRY = required("REGISTRY");
|
||||||
|
const USERNAME = required("REGISTRY_USERNAME");
|
||||||
|
const PASSWORD = required("REGISTRY_PASSWORD");
|
||||||
|
const IMAGES = required("IMAGES").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const TAG = required("TAG");
|
||||||
|
|
||||||
|
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||||
|
|
||||||
|
// Docker wants the credentials as base64url'd JSON in a header. Node's
|
||||||
|
// "base64url" encoding omits the `=` padding, which Portainer's Go decoder
|
||||||
|
// rejects outright ("Illegal base64 data at input byte N"), so build the
|
||||||
|
// URL-safe alphabet by hand and KEEP the padding.
|
||||||
|
const REGISTRY_AUTH = Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
username: USERNAME,
|
||||||
|
password: PASSWORD,
|
||||||
|
serveraddress: REGISTRY,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_");
|
||||||
|
|
||||||
|
async function pull(repository) {
|
||||||
|
const image = `${REGISTRY}/${repository}`;
|
||||||
|
const url =
|
||||||
|
`${DOCKER}/images/create` +
|
||||||
|
`?fromImage=${encodeURIComponent(image)}&tag=${encodeURIComponent(TAG)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-API-Key": API_KEY, "X-Registry-Auth": REGISTRY_AUTH },
|
||||||
|
});
|
||||||
|
const body = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`pull ${image}:${TAG} -> HTTP ${res.status} ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
// The endpoint streams newline-delimited JSON and answers 200 even when the
|
||||||
|
// pull itself failed — the failure only shows up as an {"error": ...} object
|
||||||
|
// in the stream, so the status code alone proves nothing.
|
||||||
|
const lines = body.split("\n").filter((l) => l.trim());
|
||||||
|
for (const line of lines) {
|
||||||
|
let obj;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (obj.error) {
|
||||||
|
throw new Error(`pull ${image}:${TAG} failed: ${obj.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const last = lines.length ? JSON.parse(lines[lines.length - 1]) : {};
|
||||||
|
console.log(`${image}:${TAG} — ${last.status ?? "pulled"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const repository of IMAGES) {
|
||||||
|
await pull(repository);
|
||||||
|
}
|
||||||
|
console.log("all images pulled");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(`image pull FAILED: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
+43
-2
@@ -8,7 +8,9 @@ RUN corepack enable && corepack prepare pnpm@9.15.9 --activate
|
|||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
# argon2's native addon has no musl prebuild -> compiles from source here.
|
# argon2's native addon has no musl prebuild -> compiles from source here.
|
||||||
RUN apk add --no-cache python3 make g++
|
# openssl so `prisma generate` in the build stage sees the same platform the
|
||||||
|
# runtime stage does (see the binaryTargets note in schema.prisma).
|
||||||
|
RUN apk add --no-cache python3 make g++ openssl
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY apps/api/package.json apps/api/package.json
|
COPY apps/api/package.json apps/api/package.json
|
||||||
COPY apps/web/package.json apps/web/package.json
|
COPY apps/web/package.json apps/web/package.json
|
||||||
@@ -32,7 +34,29 @@ ENV NODE_ENV=production
|
|||||||
# (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools)
|
# (mysqldump), restores (mysql), and the re-import pipeline (python + mdbtools)
|
||||||
# from inside the API container. Build deps are installed in a throwaway virtual
|
# from inside the API container. Build deps are installed in a throwaway virtual
|
||||||
# package so pandas/pyarrow build on musl, then dropped from the final layer.
|
# package so pandas/pyarrow build on musl, then dropped from the final layer.
|
||||||
RUN apk add --no-cache python3 mdbtools mysql-client \
|
# openssl is NOT optional: Prisma's query engine resolves its binary target at
|
||||||
|
# runtime (linux-musl-openssl-3.0.x) and aborts with "Please manually install
|
||||||
|
# OpenSSL" without it. Node bundles its own OpenSSL, so nothing else in this
|
||||||
|
# image pulls the system package in.
|
||||||
|
# mariadb-connector-c is REQUIRED, not incidental. Alpine's `mysql-client` is
|
||||||
|
# MariaDB's client, and it ships with an EMPTY /usr/lib/mariadb/plugin — so it
|
||||||
|
# cannot perform caching_sha2_password, which is MySQL 8.4's default and
|
||||||
|
# effectively only auth method. Without this package every mysqldump/mysql call
|
||||||
|
# from the container dies with:
|
||||||
|
# ERROR 1045: Plugin caching_sha2_password could not be loaded
|
||||||
|
# That breaks the pre-migrate deploy backup AND the whole "Operaciones" admin
|
||||||
|
# panel (backup, restore, sync, re-import all shell out to these binaries).
|
||||||
|
#
|
||||||
|
# tesseract-ocr + tesseract-ocr-data-spa + poppler-utils drive the statement
|
||||||
|
# OCR intake (RECEIPT_CAPTURE_SPEC §2): poppler's `pdftoppm` rasterises each
|
||||||
|
# scanned page and tesseract reads it, with the Spanish traineddata for the
|
||||||
|
# accented labels on CFE/CESPT/Telnor bills. These are external binaries rather
|
||||||
|
# than a native npm addon so the pnpm workspace stays free of a compiled
|
||||||
|
# dependency. If they are absent the API still boots — the statements module
|
||||||
|
# reports itself unavailable and only that feature is disabled — but statement
|
||||||
|
# ingest is the point of shipping them.
|
||||||
|
RUN apk add --no-cache python3 mdbtools mysql-client mariadb-connector-c openssl \
|
||||||
|
tesseract-ocr tesseract-ocr-data-spa poppler-utils \
|
||||||
&& apk add --no-cache --virtual .pybuild python3-dev build-base \
|
&& apk add --no-cache --virtual .pybuild python3-dev build-base \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
@@ -40,6 +64,23 @@ COPY --from=build /repo/node_modules node_modules
|
|||||||
COPY --from=build /repo/packages/database packages/database
|
COPY --from=build /repo/packages/database packages/database
|
||||||
COPY --from=build /repo/apps/api/dist apps/api/dist
|
COPY --from=build /repo/apps/api/dist apps/api/dist
|
||||||
COPY --from=build /repo/apps/api/package.json apps/api/package.json
|
COPY --from=build /repo/apps/api/package.json apps/api/package.json
|
||||||
|
# Operational scripts, run on demand — never automatically. seed-user.mjs is the
|
||||||
|
# only way to create the first sign-in account on a fresh database, and without
|
||||||
|
# it in the image that had to be done from a developer's machine against a
|
||||||
|
# production DATABASE_URL. Run it with:
|
||||||
|
# docker exec <api> node apps/api/scripts/seed-user.mjs
|
||||||
|
# honouring SEED_EMAIL / SEED_PASSWORD / SEED_NAME. It upserts, so re-running is
|
||||||
|
# safe — but note it RESETS the password of an existing account.
|
||||||
|
COPY --from=build /repo/apps/api/scripts apps/api/scripts
|
||||||
|
# node-linker=hoisted flattens EXTERNAL deps into /repo/node_modules, but the
|
||||||
|
# workspace dependency is still linked per-package:
|
||||||
|
# apps/api/node_modules/@jorgecuadros/database -> ../../../../packages/database
|
||||||
|
# Copying only /repo/node_modules therefore drops it and the API dies at boot
|
||||||
|
# with "Cannot find module '@jorgecuadros/database'". Copy just the scope dir —
|
||||||
|
# the rest of apps/api/node_modules is devDependencies (typescript) we don't
|
||||||
|
# want in the runtime layer. The relative link resolves because packages/database
|
||||||
|
# is copied to the same place above.
|
||||||
|
COPY --from=build /repo/apps/api/node_modules/@jorgecuadros apps/api/node_modules/@jorgecuadros
|
||||||
|
|
||||||
# Migration scripts + their own Python venv (ops.service.ts prefers this venv).
|
# Migration scripts + their own Python venv (ops.service.ts prefers this venv).
|
||||||
COPY migration migration
|
COPY migration migration
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# Releasing, deploying, and changing the schema
|
||||||
|
|
||||||
|
How a version gets from this repo onto a server, and the one rule that keeps
|
||||||
|
rollbacks possible.
|
||||||
|
|
||||||
|
## The short version
|
||||||
|
|
||||||
|
Dispatch **Cut release** from the Actions tab and pick `patch`, `minor` or
|
||||||
|
`major` (or `explicit` plus a number). It stamps every `package.json`, commits
|
||||||
|
`chore(release): vX.Y.Z`, tags, and pushes both refs in one go. It refuses a
|
||||||
|
version that already exists as a tag, and refuses a no-op bump.
|
||||||
|
|
||||||
|
The equivalent by hand, if you would rather cut it locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm version:set 1.2.0 # stamp every package.json
|
||||||
|
git commit -am "chore(release): v1.2.0"
|
||||||
|
git tag v1.2.0 && git push origin master v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way that push triggers `.gitea/workflows/build.yml`, which builds **both** images in
|
||||||
|
one matrix run and publishes:
|
||||||
|
|
||||||
|
| tag pushed | image tags produced |
|
||||||
|
| --- | --- |
|
||||||
|
| `v1.2.0` | `1.2.0`, `1.2`, `sha-<short>` |
|
||||||
|
| push to `master` | `master`, `sha-<short>`, `latest` |
|
||||||
|
|
||||||
|
Then dispatch a deploy from the Actions tab:
|
||||||
|
|
||||||
|
- **galactus** (office server, standalone Docker) — *Deploy to galactus*
|
||||||
|
- **cubex** (3-node Swarm) — *Deploy to Portainer*
|
||||||
|
|
||||||
|
> **The `v` is not part of the image tag.** `docker/metadata-action`'s
|
||||||
|
> `{{version}}` strips it. Git tag `v1.2.0`, dispatch `1.2.0`. Dispatching
|
||||||
|
> `v1.2.0` deploys nothing that exists.
|
||||||
|
|
||||||
|
**Cut release needs a `RELEASE_TOKEN` secret** — a Gitea PAT with
|
||||||
|
`write:repository`. It does not use the built-in Actions token on purpose:
|
||||||
|
whether a push made with that token re-triggers `build.yml` depends on the Gitea
|
||||||
|
version, and a release that quietly publishes no images is worse than one that
|
||||||
|
fails outright. If the build somehow does not start, `build.yml` has
|
||||||
|
`workflow_dispatch` — run it against the new tag by hand.
|
||||||
|
|
||||||
|
Because api and web are built from one matrix run, they cannot drift at build
|
||||||
|
time. They *can* drift at deploy time if a stack is applied with only one image
|
||||||
|
moved — the web footer shows both versions and flags a mismatch, and the deploy
|
||||||
|
workflow's last step fails if the API does not report the tag you dispatched.
|
||||||
|
|
||||||
|
## What a deploy actually does
|
||||||
|
|
||||||
|
1. **db + minio** — `scope: full` only. Idempotent; data lives on named volumes.
|
||||||
|
2. **Pre-migrate backup** — `deploy/scripts/pre-migrate-backup.mjs` runs
|
||||||
|
`mysqldump` *inside the still-running old API container*, via Portainer's
|
||||||
|
Docker API. The file lands in that container's `BACKUP_DIR` volume as
|
||||||
|
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
|
||||||
|
**Operaciones** admin screen lists and can restore. A dump taken on the CI
|
||||||
|
runner would be unreachable by the only restore path the platform has.
|
||||||
|
3. **`prisma migrate deploy`** — as a workflow *step*, never the container
|
||||||
|
`CMD`. If it were the CMD, N replicas would race each other applying the
|
||||||
|
same migration.
|
||||||
|
4. **app** — the new api + web images.
|
||||||
|
5. **Verify** — `GET /version` on the running API must report the dispatched
|
||||||
|
tag.
|
||||||
|
|
||||||
|
Rollback is `tag: 1.1.9` re-dispatched. **That rolls back code only.** The
|
||||||
|
schema stays where it is. Which brings us to the rule.
|
||||||
|
|
||||||
|
## The rule: expand / contract
|
||||||
|
|
||||||
|
Prisma has no down-migrations. There is no `prisma migrate down`, and there
|
||||||
|
never will be. So a schema change that the *previous* release cannot tolerate
|
||||||
|
turns a 30-second rollback into a restore-from-backup outage.
|
||||||
|
|
||||||
|
**Every schema change must leave the previous release working.** Split anything
|
||||||
|
destructive across two releases:
|
||||||
|
|
||||||
|
| | Release N (expand) | Release N+1 (contract) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Rename a column | add the new column, write to both, read the old | drop the old column |
|
||||||
|
| Drop a column | stop reading and writing it in code | drop it |
|
||||||
|
| Add a required column | add it nullable (or with a default), backfill | make it `NOT NULL` |
|
||||||
|
| Split a table | create the new table, dual-write | stop writing the old, drop it |
|
||||||
|
| Add an enum value | add the value; old code must not choke on unknowns | start emitting it |
|
||||||
|
|
||||||
|
Ship N, let it soak, *then* ship N+1. If N has to be rolled back you just
|
||||||
|
re-dispatch the old tag — the expanded schema still satisfies it.
|
||||||
|
|
||||||
|
Restoring from the pre-migrate dump is the **emergency lever, not the routine
|
||||||
|
path**, and on galactus it is worse than it sounds: galactus is the replication
|
||||||
|
master, DDL replicates through the binlog, and restoring the master from a dump
|
||||||
|
diverges every replica. GTIDs will not line up and each replica needs a full
|
||||||
|
re-seed. Assume a restore is a multi-hour, whole-topology event.
|
||||||
|
|
||||||
|
## Migration history
|
||||||
|
|
||||||
|
`packages/database/prisma/migrations/0000_init/` is a **baseline**. It is the
|
||||||
|
full schema as it stood on 2026-07-30, generated with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma migrate diff --from-empty \
|
||||||
|
--to-schema-datamodel packages/database/prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
Until then the schema had only ever been applied with `prisma db push`, so no
|
||||||
|
history existed and the schema state was disconnected from the app version.
|
||||||
|
|
||||||
|
### One-time, on every database that already exists
|
||||||
|
|
||||||
|
`0000_init` describes tables those databases already have, so `migrate deploy`
|
||||||
|
would fail with **P3005 "the database schema is not empty"**. Mark it applied
|
||||||
|
instead of applying it — this writes a `_prisma_migrations` row and changes no
|
||||||
|
data:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL=<the database> npx prisma@5 migrate resolve \
|
||||||
|
--applied 0000_init --schema packages/database/prisma/schema.prisma
|
||||||
|
```
|
||||||
|
|
||||||
|
Do this once per database (prod, dev, any local copy). Verify first that the
|
||||||
|
live schema really does match the baseline — this should print an empty
|
||||||
|
migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma migrate diff --from-url "$DATABASE_URL" \
|
||||||
|
--to-schema-datamodel packages/database/prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
If it prints actual statements, the live database has drifted from
|
||||||
|
`schema.prisma`. Reconcile *before* baselining, or the first real migration
|
||||||
|
will fail against a schema Prisma believes it already knows.
|
||||||
|
|
||||||
|
### From here on
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# edit schema.prisma, then:
|
||||||
|
pnpm --filter @jorgecuadros/database exec prisma migrate dev --name add_foo
|
||||||
|
```
|
||||||
|
|
||||||
|
Commit the generated `migrations/<timestamp>_add_foo/` directory. `db push` is
|
||||||
|
now a local-scratch tool only — using it against a database with history
|
||||||
|
desynchronises it from `_prisma_migrations`.
|
||||||
|
|
||||||
|
## galactus vs cubex
|
||||||
|
|
||||||
|
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
|
||||||
|
Swarm (endpoint **2**). They need different compose files because **plain
|
||||||
|
compose silently ignores Swarm's `deploy:` keys** rather than erroring:
|
||||||
|
|
||||||
|
| | Swarm (`deploy/*.stack.yml`) | standalone (`deploy/galactus/*.compose.yml`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| restart | `deploy.restart_policy` | `restart: unless-stopped` — **without this nothing comes back after a host reboot** |
|
||||||
|
| placement | `node.labels.jorgecuadros_db == true` | dropped, one host |
|
||||||
|
| ports | `{mode: ingress}` long syntax | `"3306:3306"` |
|
||||||
|
| `depends_on` | ignored by Swarm | honoured, with `condition: service_healthy` |
|
||||||
|
| volumes | named | named (unchanged — the pinning hazard was a Swarm problem) |
|
||||||
|
|
||||||
|
Keep the two sets in sync when either changes.
|
||||||
|
|
||||||
|
On both hosts, cross-stack traffic goes over the **host address**, not compose
|
||||||
|
service DNS: db, minio and app are three separate stacks, so three separate
|
||||||
|
networks. `DATABASE_URL` and `S3_ENDPOINT` name the host and its published
|
||||||
|
port. Do not "simplify" them to `mysql:3306`.
|
||||||
|
|
||||||
|
### galactus is addressed by MagicDNS, and containers need help resolving it
|
||||||
|
|
||||||
|
galactus is Tailscale-only once it is installed in the office, so every URL
|
||||||
|
names `galactus.tail01aa2.ts.net`. Its LAN IP is a DHCP lease and has already
|
||||||
|
drifted once — never put a `192.168.4.x` address in a secret.
|
||||||
|
|
||||||
|
Containers on galactus cannot resolve that name by default. The host runs
|
||||||
|
systemd-resolved, whose `127.0.0.53` stub is unreachable from inside a
|
||||||
|
container, so Docker falls back to the upstream resolver listed in
|
||||||
|
`/run/systemd/resolve/resolv.conf` — the LAN router, which knows nothing about
|
||||||
|
the tailnet. Routing to `100.x` works fine; only the *lookup* fails, and the
|
||||||
|
symptom is Prisma **P1001 "can't reach database server"** on a container that
|
||||||
|
otherwise started cleanly.
|
||||||
|
|
||||||
|
`deploy/galactus/jorgecuadros-app.compose.yml` therefore pins the resolver:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dns: [100.100.100.100] # Tailscale's fixed anycast MagicDNS address
|
||||||
|
dns_search: [tail01aa2.ts.net] # this tailnet's suffix
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are overridable (`TAILSCALE_DNS`, `TAILNET_SUFFIX`) if the tailnet changes.
|
||||||
|
Browser-facing origins need none of this — those names resolve on the client.
|
||||||
|
|
||||||
|
## Replication
|
||||||
|
|
||||||
|
galactus's MySQL is the **master**; every other MySQL in the estate is a
|
||||||
|
replica. Consequences that bite:
|
||||||
|
|
||||||
|
- `server-id` must be unique across the whole topology (prod `1`, cubex dev
|
||||||
|
`11`). A duplicate breaks replication silently.
|
||||||
|
- GTID is on from first boot, so replicas attach with `SOURCE_AUTO_POSITION=1`.
|
||||||
|
- `binlog_expire_logs_seconds` is raised to 60 days in the galactus compose file
|
||||||
|
(`MYSQL_BINLOG_EXPIRE_SECONDS`). MySQL 8.4 defaults to 30 days; a replica
|
||||||
|
offline longer than the retention needs a full re-seed.
|
||||||
|
|
||||||
|
Still open, and **not** handled by anything in this repo:
|
||||||
|
|
||||||
|
- No replication user with `REPLICATION SLAVE` granted exists yet.
|
||||||
|
- Nothing sets `read_only` / `super_read_only` on the replicas, so a stray write
|
||||||
|
to a replica will diverge it.
|
||||||
|
- The channel to the VPS crosses the public internet. It needs a tunnel or TLS —
|
||||||
|
do not publish raw 3306.
|
||||||
|
|
||||||
|
## Seeding the first sign-in account
|
||||||
|
|
||||||
|
A freshly migrated database has a schema and **no users**, so nobody can log in.
|
||||||
|
`prisma migrate deploy` creates tables, never rows; nothing in the deploy path
|
||||||
|
seeds an account, by design — creating an administrator should be a deliberate
|
||||||
|
act, not a side effect of shipping code.
|
||||||
|
|
||||||
|
`apps/api/scripts/seed-user.mjs` ships inside the API image. On the target host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -e SEED_PASSWORD='<a strong password>' \
|
||||||
|
<api-container> node apps/api/scripts/seed-user.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Defaults are `admin@jorgecuadros.local` / `ChangeMe!2026` / role `ADMIN`,
|
||||||
|
overridable with `SEED_EMAIL`, `SEED_PASSWORD`, `SEED_NAME`. **Do not accept the
|
||||||
|
default password on anything but a dev database** — it is published in this
|
||||||
|
repo's README. The script upserts by email, so re-running is safe, but it also
|
||||||
|
**resets the password of an existing account**.
|
||||||
|
|
||||||
|
## The session cookie and TLS
|
||||||
|
|
||||||
|
`SESSION_COOKIE_SECURE` controls the `Secure` flag on the session cookie. It
|
||||||
|
defaults to on in production, and it must be explicitly `"false"` for a
|
||||||
|
deployment served over plain HTTP.
|
||||||
|
|
||||||
|
This is not cosmetic. express-session silently declines to emit a `Secure`
|
||||||
|
cookie over an unencrypted connection: no `Set-Cookie` header is sent at all,
|
||||||
|
`POST /auth/login` still answers `200` with the user object, no session is
|
||||||
|
established, every subsequent request gets `403`, and the UI bounces back to
|
||||||
|
`/login` in a loop. It looks like an auth bug and is really a transport
|
||||||
|
mismatch.
|
||||||
|
|
||||||
|
galactus runs with `SESSION_COOKIE_SECURE=false`, which is acceptable **only**
|
||||||
|
because it is reachable exclusively over Tailscale — WireGuard already encrypts
|
||||||
|
the wire, so the cookie never crosses an untrusted network. Turn it back on the
|
||||||
|
moment the app is served over TLS or reachable off-tailnet. Behind a
|
||||||
|
TLS-terminating reverse proxy, set `trust proxy` on the Nest app instead of
|
||||||
|
disabling the flag.
|
||||||
|
|
||||||
|
## The MySQL client inside the API image
|
||||||
|
|
||||||
|
Alpine's `mysql-client` package is **MariaDB's** client, and it installs an
|
||||||
|
empty `/usr/lib/mariadb/plugin`. It therefore cannot speak
|
||||||
|
`caching_sha2_password`, which is MySQL 8.4's default and effectively only auth
|
||||||
|
method, and every `mysqldump`/`mysql` call from the container fails with:
|
||||||
|
|
||||||
|
```
|
||||||
|
ERROR 1045: Plugin caching_sha2_password could not be loaded:
|
||||||
|
... /usr/lib/mariadb/plugin/caching_sha2_password.so: No such file or directory
|
||||||
|
```
|
||||||
|
|
||||||
|
`mariadb-connector-c` supplies that plugin and is installed in
|
||||||
|
`docker/api.Dockerfile` for exactly this reason — do not drop it as an unused
|
||||||
|
dependency. It affects far more than the deploy backup: the entire
|
||||||
|
**Operaciones** panel (backup, restore, sync, re-import) shells out to these
|
||||||
|
binaries, so without it none of those work in a container either. The feature
|
||||||
|
had only ever been exercised with the API running on a developer machine, where
|
||||||
|
the Oracle client is installed, which is why this went unnoticed until the
|
||||||
|
first containerised deploy.
|
||||||
|
|
||||||
|
## The Operaciones panel needs its own database login
|
||||||
|
|
||||||
|
The panel's four jobs all shell out to `mysqldump`/`mysql`, and they cannot do
|
||||||
|
so as the application user. `mysqldump --single-transaction` issues
|
||||||
|
`FLUSH TABLES`, which requires the **global** `RELOAD` privilege; the MySQL
|
||||||
|
image grants the app user only `ALL PRIVILEGES ON jorgecuadros.*` plus
|
||||||
|
`USAGE ON *.*`. `--skip-lock-tables` does not avoid it. BACKUP therefore failed
|
||||||
|
outright, and SYNC and RE-IMPORT with it, because both take a safety backup
|
||||||
|
first.
|
||||||
|
|
||||||
|
The API is given an admin login out of band rather than permanently elevating
|
||||||
|
the user it serves requests as:
|
||||||
|
|
||||||
|
```
|
||||||
|
OPS_DB_ADMIN_USER=root
|
||||||
|
OPS_DB_ADMIN_PASSWORD=<MYSQL_ROOT_PASSWORD>
|
||||||
|
```
|
||||||
|
|
||||||
|
Both deploy workflows pass these into the app stack from the existing
|
||||||
|
`MYSQL_ROOT_PASSWORD` secret. Host, port and database still come from
|
||||||
|
`DATABASE_URL` — the override changes *who logs in*, never *which server*. With
|
||||||
|
the pair unset the service falls back to the `DATABASE_URL` credentials and logs
|
||||||
|
a warning, which is what local development wants.
|
||||||
|
|
||||||
|
Two more things the panel's dumps now do, for the same reasons the pre-migrate
|
||||||
|
backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
|
||||||
|
|
||||||
|
- **`--set-gtid-purged=OFF`.** galactus is the replication *source* with GTID
|
||||||
|
on, so without this every dump embeds `SET @@GLOBAL.GTID_PURGED` and cannot be
|
||||||
|
restored onto the server it came from — which is precisely what the restore
|
||||||
|
screen exists to do.
|
||||||
|
- **`set -o pipefail` and a `CREATE TABLE` count.** `mysqldump | gzip` reports
|
||||||
|
gzip's exit status, and a `mysqldump` that dies on its first statement still
|
||||||
|
produces a ~372-byte perfectly valid archive that passes `gzip -t`. Without
|
||||||
|
both checks a failed backup was recorded as a successful one and listed as an
|
||||||
|
ordinary restore point. A dump that fails now deletes its own output.
|
||||||
|
|
||||||
|
## Known caveats in the deploy path
|
||||||
|
|
||||||
|
- The pre-migrate backup step sets `NODE_TLS_REJECT_UNAUTHORIZED=0` because
|
||||||
|
Portainer serves a self-signed certificate. It is scoped to that one step,
|
||||||
|
which talks to nothing but Portainer. Replacing the certificate and dropping
|
||||||
|
the flag is the real fix.
|
||||||
|
- The runner lives on cubex and must reach the target host's Portainer (9443)
|
||||||
|
**and** MySQL (3306). If it cannot reach 3306, run the migration by hand from
|
||||||
|
a host that can and dispatch with `skip_migrate: true`.
|
||||||
|
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container
|
||||||
|
exists yet. Use it for a first-ever deploy only — it is the one switch that
|
||||||
|
lets a migration run with no restore point.
|
||||||
+127
-10
@@ -131,6 +131,82 @@ single-movement form.
|
|||||||
|
|
||||||
## 2. PDF / OCR auto-capture
|
## 2. PDF / OCR auto-capture
|
||||||
|
|
||||||
|
> **BUILT — 2026-08-01.** Implemented and verified end to end against real
|
||||||
|
> scanned statements. `apps/api/src/statements/` holds the module: a swappable
|
||||||
|
> `OcrProvider` seam with a self-hosted Tesseract implementation, per-provider
|
||||||
|
> parsers for CFE / CESPT / Telnor, a scoped matcher, and a review queue that
|
||||||
|
> posts through `BillingService.createBatch` with `source: "OCR"`. Web:
|
||||||
|
> the "Captura automática (OCR)" tab of the Captura screen (upload + batch
|
||||||
|
> list) and `/recibos/:id` (review queue with the page image beside the
|
||||||
|
> extracted fields). New abilities `statement:ingest` / `statement:review`, both
|
||||||
|
> STAFF.
|
||||||
|
>
|
||||||
|
> Auto-capture is a *mode of* §1.2's capture screen, not a separate menu entry:
|
||||||
|
> it is the same daily job with a scanner instead of a keyboard, and both modes
|
||||||
|
> post through the same ledger path. `/estado-cuenta/lote` opens the manual tab,
|
||||||
|
> `/recibos` the automatic one; both render `components/Captura.tsx`.
|
||||||
|
>
|
||||||
|
> Requires object storage (`S3_ENDPOINT` + credentials): the scans are kept as
|
||||||
|
> blobs. `GET /statements/status` reports `ocrAvailable` and `storageAvailable`,
|
||||||
|
> and the upload card hides itself unless both hold.
|
||||||
|
>
|
||||||
|
> **Measured, not assumed.** Ten real scans (46 pages of CFE, CESPT and Telnor
|
||||||
|
> bills) drove every decision below. Against them the shipped parser identifies
|
||||||
|
> the provider on **46/46**, reads an account reference on **43/46**, an amount
|
||||||
|
> on **42/46**, and a due date on **44/46**. Matched against the dev database
|
||||||
|
> that is **39/46 (85%) exact auto-match, 40/46 (87%) identified**. The
|
||||||
|
> remainder are legitimate review cases: one account number shared by two
|
||||||
|
> services, three phone numbers not yet on file, one clave not in the book, and
|
||||||
|
> one page too poorly scanned to read.
|
||||||
|
>
|
||||||
|
> **The OCR-provider question is closed: self-hosted Tesseract.** It clears the
|
||||||
|
> bar for a queue where a human confirms every row, and at 300+ pages/month/
|
||||||
|
> company a per-page API would carry real recurring cost for accuracy that is
|
||||||
|
> not the bottleneck. `OcrProvider` keeps a managed API (Textract, Document
|
||||||
|
> Intelligence, Document AI) a one-line swap in `statements.module.ts` with no
|
||||||
|
> schema change.
|
||||||
|
>
|
||||||
|
> **Four things the samples proved that this spec had wrong or unknown:**
|
||||||
|
>
|
||||||
|
> 1. **Clave catastral ≠ predial — gap 2 below is resolved.** `DATMEX.clave` is
|
||||||
|
> 934 rows of `[A-Z]{2}[0-9]{6}` (`MM000012`, `KH220204`), the exact format
|
||||||
|
> printed as `Cve. Cat.` / `CLAVE CATASTRAL` on real CESPT bills
|
||||||
|
> (`KB078025`, `KA903009`). `DATMEX.predial` — what
|
||||||
|
> `PROPERTY_TAX.accountNumber` actually holds — is 1135 numeric rows with
|
||||||
|
> only **663 distinct values**, so it is not a per-property key at all and
|
||||||
|
> appears on no statement. The clave was never migrated; it now lives on
|
||||||
|
> `Property.cadastralKey` (property-level, because two different services
|
||||||
|
> both print it) and is the matcher's secondary key. Predial is left
|
||||||
|
> untouched. Predial statements match on the clave alone.
|
||||||
|
> 2. **Gas is not a dead end — gap 3 below was wrong.** `DATMEX.gas` has 334
|
||||||
|
> filled rows, of which **160 are real numeric account numbers**
|
||||||
|
> (`900004807`); the other 174 are tank descriptors (`ESTACIONARIO`,
|
||||||
|
> `CILINDRO`). All 334 went to `notes`. The 160 are recovered into
|
||||||
|
> `GAS.meterNumber`; only the descriptor rows start cold.
|
||||||
|
> 3. **Phone is one line per property, not three.** Of 1518 properties, 534
|
||||||
|
> have `phone1`, 18 have `phone2` and exactly **1** has `phone3`. The
|
||||||
|
> secondaries are alternate contacts, so `TELEPHONE` backfills from `phone1`
|
||||||
|
> only rather than fanning out. This answers the open question below.
|
||||||
|
> 4. **Statements arrive bundled, and their printed names are stale.** One PDF
|
||||||
|
> holds many customers, one per page (Telnor's own `Pág 3 de 6` refers to
|
||||||
|
> its internal pagination, not the office's scan). And the name on a utility
|
||||||
|
> bill is the account registrant, not the current owner: a CESPT receipt for
|
||||||
|
> account `5365218` prints `ARNAIZ ROSAS ELSA AURORA` where the office's
|
||||||
|
> book — corroborated by the clave — has `CATT, RANDY`. **The matcher never
|
||||||
|
> reads the name.**
|
||||||
|
>
|
||||||
|
> **Two OCR traps worth keeping in mind if the parsers are ever extended:**
|
||||||
|
> scanned logos read badly (a CESPT header came back as `E BAJA ES PAGO /
|
||||||
|
> EALIFORNIA`), so provider detection falls back to layout anchors — but only
|
||||||
|
> after *every* brand check has run, since a Telnor page contains words a CFE
|
||||||
|
> structural rule would otherwise claim. And amounts must be parsed by
|
||||||
|
> separator position: a real Telnor bill OCR'd as `$ 649,00`, which naive
|
||||||
|
> comma-stripping turns into $64,900.
|
||||||
|
>
|
||||||
|
> **Not covered:** handwritten folder numbers. Staff pencil a customer number on
|
||||||
|
> each bill (`9`, `405`, `406`); Tesseract read `405` as `205`. Handwriting is
|
||||||
|
> a review hint at best and is deliberately not an input to matching.
|
||||||
|
|
||||||
### Motivation (from the meeting)
|
### Motivation (from the meeting)
|
||||||
|
|
||||||
Each utility company (CFE, water, phone, gas...) sends 300+ individual
|
Each utility company (CFE, water, phone, gas...) sends 300+ individual
|
||||||
@@ -401,6 +477,40 @@ document-understanding problem. Recommend:
|
|||||||
|
|
||||||
## 3. Multi-bank chequera
|
## 3. Multi-bank chequera
|
||||||
|
|
||||||
|
> **BUILT — 2026-07-27.** Everything below is implemented and verified against
|
||||||
|
> the dev database and browser. `Bank` / `BankAccount` exist, every
|
||||||
|
> `BankTransaction` carries a required `bankAccountId`, and all 22,669 migrated
|
||||||
|
> rows were backfilled onto the Utilities/Scotiabank MXN account by
|
||||||
|
> `migration/backfill_bank_accounts.py` (now wired into `run_all.py`, both
|
||||||
|
> modes, ahead of `transform_bank.py`). Every read path in `bank.service.ts` is
|
||||||
|
> account-scoped — including both raw-SQL rollups in `summary()` and the
|
||||||
|
> previously-unfiltered `facets()`. `/banco` gained an account picker,
|
||||||
|
> `/banco/cuentas` manages banks and accounts under the new MANAGER
|
||||||
|
> `bank:manage-accounts` ability, and `/inicio`'s chequera card now names the
|
||||||
|
> account it is reading rather than implying a single register.
|
||||||
|
>
|
||||||
|
> **Verified end to end:** a second account (USD) was created through the API,
|
||||||
|
> a movement captured into it, and the MXN register's totals confirmed
|
||||||
|
> unchanged (22,669 movements, net 1,014,266.97) with zero cross-account leak
|
||||||
|
> in list/stats/facets/summary. Missing `bankAccountId` returns 400, unknown
|
||||||
|
> returns 404, capture into a closed account returns 400, and an attempt to
|
||||||
|
> PATCH an account's `currency` is rejected by DTO whitelisting. The test
|
||||||
|
> account was then deleted — the real Seguros bank is still the open question
|
||||||
|
> below, so nothing was left behind guessing at it.
|
||||||
|
>
|
||||||
|
> **Two deviations from the design below**, both tightening it:
|
||||||
|
> - `bank_transactions` also gained an `@@index([bankAccountId, transactionDate])`.
|
||||||
|
> Every read is now filtered by account and ordered/grouped by date; without
|
||||||
|
> it each of them is a full scan of the 22k-row table.
|
||||||
|
> - `UpdateBankAccountDto` deliberately has **no `currency` field**. The
|
||||||
|
> movements already booked in an account are denominated in it, so editing it
|
||||||
|
> would silently re-denominate history instead of converting it. Currency is
|
||||||
|
> set once, at creation.
|
||||||
|
>
|
||||||
|
> Still open: which bank the Seguros USD account is actually at (see Open
|
||||||
|
> questions). Until that answer arrives the office has exactly one chequera and
|
||||||
|
> the UI behaves as it always did, just scoped explicitly.
|
||||||
|
|
||||||
### Motivation
|
### Motivation
|
||||||
|
|
||||||
Seguros uses a US bank account; Utilities uses a Mexican bank account. The
|
Seguros uses a US bank account; Utilities uses a Mexican bank account. The
|
||||||
@@ -776,16 +886,23 @@ action (`customer:purge`) taken well after release — not bundled into
|
|||||||
|
|
||||||
## Open questions to take back to Jorge (collected)
|
## Open questions to take back to Jorge (collected)
|
||||||
|
|
||||||
- OCR provider/budget for §2 (self-hosted vs. managed API, given 300+
|
- ~~OCR provider/budget for §2~~ — **CLOSED**: self-hosted Tesseract, chosen on
|
||||||
pages/month/company).
|
measured accuracy against real scans (see §2's BUILT note). No per-page cost.
|
||||||
- Whether source PDFs arrive pre-split per customer or as one bundled file
|
- ~~Whether source PDFs arrive pre-split per customer or bundled~~ —
|
||||||
needing page-range detection (§2).
|
**CLOSED**: bundled, one customer per page. Split per page.
|
||||||
- Whether "Clave Catastral" and the already-migrated `PREDIAL`-sourced
|
- ~~Whether "Clave Catastral" and the `PREDIAL`-sourced
|
||||||
`PROPERTY_TAX.accountNumber` are the same number — blocks OCR matching
|
`PROPERTY_TAX.accountNumber` are the same number~~ — **CLOSED**: they are
|
||||||
for predial statements specifically until confirmed (§2).
|
different. `clave` is the cadastral key and is now on
|
||||||
- Whether phone billing is really one service per phone number on file, or
|
`Property.cadastralKey`; `predial` is not unique and is not printed on
|
||||||
one per property regardless of how many numbers are recorded — decides
|
statements.
|
||||||
how the new `TELEPHONE` service kind gets backfilled (§2).
|
- ~~Whether phone billing is one service per number or one per property~~ —
|
||||||
|
**CLOSED**: effectively one (534 / 18 / 1 across phone1/2/3), backfilled
|
||||||
|
from `phone1`.
|
||||||
|
- **Still open (§2):** whether the CFE amount staff should owe is the rounded
|
||||||
|
headline (`$268`, what the barcode encodes and what is paid at the window) or
|
||||||
|
the exact `Total` in the breakdown (`$268.88`). The parser currently takes
|
||||||
|
the barcode figure, which matches what the office actually pays; worth one
|
||||||
|
confirmation from Jorge.
|
||||||
- The actual bank name/currency/details for the Seguros USD account, and
|
- The actual bank name/currency/details for the Seguros USD account, and
|
||||||
whether any historical Seguros bank data exists to migrate (§3).
|
whether any historical Seguros bank data exists to migrate (§3).
|
||||||
- Whether `BankAccount.businessLine` should be enforced or a soft hint
|
- Whether `BankAccount.businessLine` should be enforced or a soft hint
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
One-off schema+data step for the multi-bank chequera
|
||||||
|
(docs/RECEIPT_CAPTURE_SPEC.md §3).
|
||||||
|
|
||||||
|
`bank_transactions.bankAccountId` is REQUIRED in the Prisma schema, so
|
||||||
|
`prisma db push` cannot introduce it on a table that already holds 22k rows.
|
||||||
|
This script does the ordered dance that push can't:
|
||||||
|
|
||||||
|
1. create `banks` / `bank_accounts` (same DDL Prisma generates)
|
||||||
|
2. seed the one account every existing row belongs to — Scotiabank MXN,
|
||||||
|
the office's Utilities chequera, which is all `SCOTHIA.mdb` ever was
|
||||||
|
3. add `bankAccountId` NULLable, backfill every row to that account,
|
||||||
|
then promote it to NOT NULL and attach the FK + index
|
||||||
|
|
||||||
|
On a database that predates the feature, run it BEFORE `prisma db push`; push
|
||||||
|
then sees no drift. On a fresh environment push creates the tables itself and
|
||||||
|
this only seeds the rows. Either way `transform_bank.py` needs the account to
|
||||||
|
exist, so `run_all.py` runs it first. Idempotent — safe to re-run, and
|
||||||
|
re-running once a second account exists does NOT re-point rows (the backfill
|
||||||
|
only touches NULLs).
|
||||||
|
|
||||||
|
./.venv/bin/python backfill_bank_accounts.py --env dev
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from dbenv import connect
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
|
# The account every migrated SCOTHIA row belongs to. Its id is derived, not
|
||||||
|
# random, so a re-run against a half-applied database finds the same row and
|
||||||
|
# `transform_bank.py` can resolve it by label without a lookup table.
|
||||||
|
SCOTIABANK = "Scotiabank"
|
||||||
|
UTILITIES_ACCOUNT = "Utilities — Scotiabank (MXN)"
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(c, name: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s",
|
||||||
|
(name,),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(c, table: str, column: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s",
|
||||||
|
(table, column),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_exists(c, table: str, name: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.table_constraints "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s AND constraint_name = %s",
|
||||||
|
(table, name),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(c, table: str, name: str) -> bool:
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = %s AND index_name = %s",
|
||||||
|
(table, name),
|
||||||
|
)
|
||||||
|
return c.fetchone()[0] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# `--sync` is accepted and ignored: this step is idempotent by nature, so
|
||||||
|
# it behaves identically in both modes and can sit in run_all's two lists.
|
||||||
|
env, _sync_mode = parse_mode()
|
||||||
|
conn = connect(env)
|
||||||
|
c = conn.cursor()
|
||||||
|
print(f"[bank-accounts] target env: {env}")
|
||||||
|
|
||||||
|
# --- 1. tables ----------------------------------------------------------
|
||||||
|
if not table_exists(c, "banks"):
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `banks` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`country` VARCHAR(191) NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `banks_name_key` (`name`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" created banks")
|
||||||
|
|
||||||
|
if not table_exists(c, "bank_accounts"):
|
||||||
|
c.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE `bank_accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`bankId` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`currency` ENUM('USD','MXN') NOT NULL,
|
||||||
|
`businessLine` ENUM('UTILITY','INSURANCE','TRUST') NULL,
|
||||||
|
`active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `bank_accounts_bankId_fkey` (`bankId`),
|
||||||
|
CONSTRAINT `bank_accounts_bankId_fkey` FOREIGN KEY (`bankId`)
|
||||||
|
REFERENCES `banks` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
print(" created bank_accounts")
|
||||||
|
|
||||||
|
# --- 2. seed the Utilities/Scotiabank chequera --------------------------
|
||||||
|
c.execute("SELECT id FROM banks WHERE name = %s", (SCOTIABANK,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
bank_id = row[0]
|
||||||
|
else:
|
||||||
|
bank_id = str(uuid.uuid4())
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO banks (id, name, country) VALUES (%s, %s, %s)",
|
||||||
|
(bank_id, SCOTIABANK, "MX"),
|
||||||
|
)
|
||||||
|
print(f" seeded bank {SCOTIABANK}")
|
||||||
|
|
||||||
|
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if row:
|
||||||
|
account_id = row[0]
|
||||||
|
else:
|
||||||
|
account_id = str(uuid.uuid4())
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO bank_accounts (id, bankId, label, currency, businessLine, active) "
|
||||||
|
"VALUES (%s, %s, %s, 'MXN', 'UTILITY', 1)",
|
||||||
|
(account_id, bank_id, UTILITIES_ACCOUNT),
|
||||||
|
)
|
||||||
|
print(f" seeded account {UTILITIES_ACCOUNT}")
|
||||||
|
print(f" account id: {account_id}")
|
||||||
|
|
||||||
|
# --- 3. column, backfill, promote to NOT NULL ---------------------------
|
||||||
|
if not column_exists(c, "bank_transactions", "bankAccountId"):
|
||||||
|
c.execute("ALTER TABLE `bank_transactions` ADD COLUMN `bankAccountId` VARCHAR(191) NULL")
|
||||||
|
print(" added bank_transactions.bankAccountId (nullable)")
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"UPDATE bank_transactions SET bankAccountId = %s WHERE bankAccountId IS NULL",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
print(f" backfilled {c.rowcount} movement(s) to {UTILITIES_ACCOUNT}")
|
||||||
|
|
||||||
|
c.execute("SELECT COUNT(*) FROM bank_transactions WHERE bankAccountId IS NULL")
|
||||||
|
orphans = c.fetchone()[0]
|
||||||
|
if orphans:
|
||||||
|
raise SystemExit(f"abort: {orphans} bank_transactions still have no account")
|
||||||
|
|
||||||
|
c.execute("ALTER TABLE `bank_transactions` MODIFY `bankAccountId` VARCHAR(191) NOT NULL")
|
||||||
|
|
||||||
|
if not index_exists(c, "bank_transactions", "bank_transactions_bankAccountId_transactionDate_idx"):
|
||||||
|
c.execute(
|
||||||
|
"CREATE INDEX `bank_transactions_bankAccountId_transactionDate_idx` "
|
||||||
|
"ON `bank_transactions` (`bankAccountId`, `transactionDate`)"
|
||||||
|
)
|
||||||
|
print(" created (bankAccountId, transactionDate) index")
|
||||||
|
|
||||||
|
if not constraint_exists(c, "bank_transactions", "bank_transactions_bankAccountId_fkey"):
|
||||||
|
c.execute(
|
||||||
|
"ALTER TABLE `bank_transactions` "
|
||||||
|
"ADD CONSTRAINT `bank_transactions_bankAccountId_fkey` FOREIGN KEY (`bankAccountId`) "
|
||||||
|
"REFERENCES `bank_accounts` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE"
|
||||||
|
)
|
||||||
|
print(" attached bankAccountId FK")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
c.execute(
|
||||||
|
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
|
||||||
|
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
|
||||||
|
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
|
||||||
|
)
|
||||||
|
print("=== Multi-bank chequera ready ===")
|
||||||
|
for label, currency, n, total in c.fetchall():
|
||||||
|
print(f" {label:36} {currency} {n:6} movimientos neto {total}")
|
||||||
|
print(" validation: OK")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
Closes the three data gaps the statement-OCR matcher depends on
|
||||||
|
(docs/RECEIPT_CAPTURE_SPEC.md §2, "Matching logic").
|
||||||
|
|
||||||
|
OCR matching is only as good as the field it matches against, and a
|
||||||
|
field-by-field check of real scanned statements against what
|
||||||
|
`transform_properties.py` actually loaded turned up three mismatches. This
|
||||||
|
script fixes them on an existing database; `transform_properties.py` has been
|
||||||
|
updated in the same commit so a full re-migration produces them directly.
|
||||||
|
|
||||||
|
1. CLAVE CATASTRAL — printed on both the CESPT water bill ("Cve. Cat.:
|
||||||
|
KB078025") and the predial statement, and held in `DATMEX.clave` (934
|
||||||
|
rows, format `[A-Z]{2}[0-9]{6}`). It was never migrated. What
|
||||||
|
`PROPERTY_TAX.accountNumber` carries instead is `DATMEX.predial`, a
|
||||||
|
different, purely numeric column that is *not* unique — 663 distinct
|
||||||
|
values across 1135 filled rows — and appears on no statement. So predial
|
||||||
|
is left exactly where it is, and the clave lands on `Property` (it is a
|
||||||
|
property-level key, which is why two different services both print it).
|
||||||
|
|
||||||
|
2. GAS — `GAS.meterNumber` is empty for all 334 rows because the transform
|
||||||
|
put `DATMEX.gas` into `notes`. That column is mixed: 160 rows hold a real
|
||||||
|
numeric account/meter number, the remaining 174 hold a tank descriptor
|
||||||
|
("ESTACIONARIO", "CILINDRO"). The numeric ones are recoverable now; the
|
||||||
|
descriptors legitimately have no number, so those statements still start
|
||||||
|
cold and get their number from the first human confirmation.
|
||||||
|
|
||||||
|
3. TELEPHONE — no such `ServiceKind` existed, so a Telnor bill had nothing to
|
||||||
|
match against. One service row is created per property with a `phone1`.
|
||||||
|
Only phone1: 534 properties have one, 18 have a phone2 and exactly 1 has a
|
||||||
|
phone3, so the secondaries are alternate contacts rather than separately
|
||||||
|
billed lines.
|
||||||
|
|
||||||
|
Idempotent — re-running updates nothing it has already done, and it never
|
||||||
|
overwrites a value a human has since corrected.
|
||||||
|
|
||||||
|
./.venv/bin/python backfill_statement_match_fields.py --env dev
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from dbenv import connect
|
||||||
|
from sync import parse_mode
|
||||||
|
|
||||||
|
STG = Path(__file__).parent / "output" / "stg_utilities"
|
||||||
|
NULL = "∅"
|
||||||
|
|
||||||
|
|
||||||
|
def s(v):
|
||||||
|
if v is None or pd.isna(v):
|
||||||
|
return None
|
||||||
|
v = str(v).strip()
|
||||||
|
return None if v in ("", NULL, "0") else v
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# `--sync` is accepted and ignored — the script is idempotent either way.
|
||||||
|
env, _sync_mode = parse_mode()
|
||||||
|
conn = connect(env)
|
||||||
|
c = conn.cursor()
|
||||||
|
print(f"[statement-match-fields] target env: {env}")
|
||||||
|
|
||||||
|
# --- 1. clave catastral -> properties.cadastralKey ----------------------
|
||||||
|
# Joined on provenance, the same key transform_properties.py writes, so a
|
||||||
|
# property that was re-created by a later sync still lines up.
|
||||||
|
dm = pd.read_parquet(STG / "datmex.parquet")
|
||||||
|
claves = []
|
||||||
|
for _, row in dm.iterrows():
|
||||||
|
clave = s(row["clave"])
|
||||||
|
if clave:
|
||||||
|
claves.append((clave, str(int(row["_row_num"]))))
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for clave, legacy_id in claves:
|
||||||
|
c.execute(
|
||||||
|
"UPDATE properties SET cadastralKey = %s "
|
||||||
|
"WHERE legacySourceTable = 'DATMEX' AND legacyId = %s AND cadastralKey IS NULL",
|
||||||
|
(clave, legacy_id),
|
||||||
|
)
|
||||||
|
updated += c.rowcount
|
||||||
|
print(f" cadastralKey: set on {updated} propert(ies) ({len(claves)} in source)")
|
||||||
|
|
||||||
|
# --- 2. gas account numbers out of notes -> GAS.meterNumber -------------
|
||||||
|
# REGEXP rather than a Python loop: the value is already sitting in the
|
||||||
|
# notes column, so this is one pass over 334 rows inside the database.
|
||||||
|
c.execute(
|
||||||
|
"UPDATE property_services SET meterNumber = notes "
|
||||||
|
"WHERE kind = 'GAS' AND meterNumber IS NULL "
|
||||||
|
"AND notes REGEXP '^[0-9]{5,}$'"
|
||||||
|
)
|
||||||
|
print(f" GAS.meterNumber: recovered {c.rowcount} account number(s) from notes")
|
||||||
|
|
||||||
|
# --- 3. TELEPHONE service rows ------------------------------------------
|
||||||
|
# Digits only, matching how the transform now writes them: a scanned Telnor
|
||||||
|
# bill prints "664 609 3444" and reduces to the stored local 6093444 once
|
||||||
|
# the LADA is stripped, which is the matcher's job, not this script's.
|
||||||
|
c.execute(
|
||||||
|
"SELECT p.id, p.phone1 FROM properties p "
|
||||||
|
"WHERE p.phone1 IS NOT NULL AND p.phone1 <> '' "
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM property_services ps "
|
||||||
|
" WHERE ps.propertyId = p.id AND ps.kind = 'TELEPHONE')"
|
||||||
|
)
|
||||||
|
rows = c.fetchall()
|
||||||
|
made = []
|
||||||
|
for pid, phone in rows:
|
||||||
|
digits = "".join(ch for ch in str(phone) if ch.isdigit())
|
||||||
|
if digits:
|
||||||
|
made.append((str(uuid.uuid4()), pid, digits))
|
||||||
|
if made:
|
||||||
|
c.executemany(
|
||||||
|
"INSERT INTO property_services "
|
||||||
|
"(id, propertyId, kind, accountNumber, active, notes) "
|
||||||
|
"VALUES (%s, %s, 'TELEPHONE', %s, 1, 'from DATMEX.telefono')",
|
||||||
|
made,
|
||||||
|
)
|
||||||
|
print(f" TELEPHONE: created {len(made)} service row(s)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# --- validation ---------------------------------------------------------
|
||||||
|
c.execute("SELECT COUNT(*) FROM properties WHERE cadastralKey IS NOT NULL")
|
||||||
|
n_clave = c.fetchone()[0]
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM property_services WHERE kind='GAS' AND meterNumber IS NOT NULL"
|
||||||
|
)
|
||||||
|
n_gas = c.fetchone()[0]
|
||||||
|
c.execute("SELECT COUNT(*) FROM property_services WHERE kind='TELEPHONE'")
|
||||||
|
n_tel = c.fetchone()[0]
|
||||||
|
|
||||||
|
# A clave that is not unique would silently make the secondary match key
|
||||||
|
# ambiguous, which is worse than not having one — surface it rather than
|
||||||
|
# letting the matcher discover it a statement at a time.
|
||||||
|
c.execute(
|
||||||
|
"SELECT COUNT(*) FROM (SELECT cadastralKey FROM properties "
|
||||||
|
"WHERE cadastralKey IS NOT NULL GROUP BY cadastralKey HAVING COUNT(*) > 1) d"
|
||||||
|
)
|
||||||
|
dupe_claves = c.fetchone()[0]
|
||||||
|
|
||||||
|
print("=== Statement match fields ready ===")
|
||||||
|
print(f" properties with cadastralKey : {n_clave}")
|
||||||
|
print(f" GAS services with meterNumber: {n_gas}")
|
||||||
|
print(f" TELEPHONE services : {n_tel}")
|
||||||
|
print(f" duplicated cadastralKey values: {dupe_claves}"
|
||||||
|
+ (" (matcher treats these as ambiguous)" if dupe_claves else ""))
|
||||||
|
assert n_clave > 0 and n_tel > 0, "backfill produced nothing — check staging output"
|
||||||
|
print(" validation: OK")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -41,9 +41,18 @@ PY = sys.executable # the venv python running this orchestrator
|
|||||||
STEPS = [
|
STEPS = [
|
||||||
"transform_customers.py",
|
"transform_customers.py",
|
||||||
"transform_properties.py",
|
"transform_properties.py",
|
||||||
|
# Statement-OCR match fields. transform_properties.py now produces these
|
||||||
|
# directly, so on a full rebuild this is a no-op that re-asserts they are
|
||||||
|
# there; on a database predating the OCR module it is what fills them in.
|
||||||
|
# Must follow transform_properties.py, which truncates both tables it
|
||||||
|
# touches.
|
||||||
|
"backfill_statement_match_fields.py",
|
||||||
"transform_policies.py",
|
"transform_policies.py",
|
||||||
"transform_transactions.py",
|
"transform_transactions.py",
|
||||||
"prune_empty_customers.py",
|
"prune_empty_customers.py",
|
||||||
|
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
|
||||||
|
# transform_bank.py fails fast without it.
|
||||||
|
"backfill_bank_accounts.py",
|
||||||
"transform_bank.py",
|
"transform_bank.py",
|
||||||
"blob_extract.py",
|
"blob_extract.py",
|
||||||
]
|
]
|
||||||
@@ -51,11 +60,20 @@ STEPS = [
|
|||||||
SYNC_STEPS = [
|
SYNC_STEPS = [
|
||||||
"transform_customers.py",
|
"transform_customers.py",
|
||||||
"transform_properties.py",
|
"transform_properties.py",
|
||||||
|
# Statement-OCR match fields. transform_properties.py now produces these
|
||||||
|
# directly, so on a full rebuild this is a no-op that re-asserts they are
|
||||||
|
# there; on a database predating the OCR module it is what fills them in.
|
||||||
|
# Must follow transform_properties.py, which truncates both tables it
|
||||||
|
# touches.
|
||||||
|
"backfill_statement_match_fields.py",
|
||||||
"transform_policies.py",
|
"transform_policies.py",
|
||||||
"transform_transactions.py",
|
"transform_transactions.py",
|
||||||
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
# Manual-safe prune: drops legacy-owned empties that the customer upsert
|
||||||
# re-creates from Parquet, but leaves manually-added customers alone.
|
# re-creates from Parquet, but leaves manually-added customers alone.
|
||||||
"prune_empty_customers.py",
|
"prune_empty_customers.py",
|
||||||
|
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
|
||||||
|
# transform_bank.py fails fast without it.
|
||||||
|
"backfill_bank_accounts.py",
|
||||||
"transform_bank.py",
|
"transform_bank.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,20 @@ Sources:
|
|||||||
from the spelled-out "cantidad en letra"
|
from the spelled-out "cantidad en letra"
|
||||||
- TABLA RAMODOS -> business_line_categories (line-of-business lookup)
|
- TABLA RAMODOS -> business_line_categories (line-of-business lookup)
|
||||||
|
|
||||||
|
Bank account: SCOTHIA is the Utilities MXN chequera and nothing else — DATOS
|
||||||
|
E/I carry no bank or currency column — so every row loads against the single
|
||||||
|
account seeded by `backfill_bank_accounts.py`, which must have run first.
|
||||||
|
`banks` / `bank_accounts` are NOT truncated here; only the movements are. (In
|
||||||
|
full-rebuild mode that still clears app-captured rows on every account, the
|
||||||
|
same whole-database truncate every transform in this pipeline does — use
|
||||||
|
`--sync` to upsert instead.)
|
||||||
|
|
||||||
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
|
Category link: DATOS E/I have no explicit FK to TABLA RAMODOS — the ramo is
|
||||||
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
|
inferred from the CONCEPTO text, which is a fuzzy classification, not a stored
|
||||||
key. So the categories are loaded but bank_transactions.categoryId is left
|
key. So the categories are loaded but bank_transactions.categoryId is left
|
||||||
NULL for now; a concept->ramo classifier is a later enhancement.
|
NULL for now; a concept->ramo classifier is a later enhancement.
|
||||||
|
|
||||||
Idempotent (truncate + rebuild). Run:
|
Idempotent (rebuild the legacy rows). Run:
|
||||||
./.venv/bin/python transform_bank.py --env dev
|
./.venv/bin/python transform_bank.py --env dev
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -27,6 +35,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
from backfill_bank_accounts import UTILITIES_ACCOUNT
|
||||||
from dbenv import connect, env_arg
|
from dbenv import connect, env_arg
|
||||||
from sync import parse_mode
|
from sync import parse_mode
|
||||||
|
|
||||||
@@ -78,6 +87,19 @@ def main():
|
|||||||
print(f"[bank] target env: {env}")
|
print(f"[bank] target env: {env}")
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Every SCOTHIA row belongs to the one Utilities MXN chequera. Resolved by
|
||||||
|
# label rather than created here, so this script can't silently open a
|
||||||
|
# second copy of the account if the backfill hasn't run.
|
||||||
|
c.execute("SELECT id FROM bank_accounts WHERE label = %s", (UTILITIES_ACCOUNT,))
|
||||||
|
row = c.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise SystemExit(
|
||||||
|
f"missing bank account {UTILITIES_ACCOUNT!r} — run "
|
||||||
|
f"backfill_bank_accounts.py --env {env} first"
|
||||||
|
)
|
||||||
|
account_id = row[0]
|
||||||
|
print(f"[bank] account: {UTILITIES_ACCOUNT} ({account_id})")
|
||||||
|
|
||||||
# business_line_categories (dedup TABLA RAMODOS)
|
# business_line_categories (dedup TABLA RAMODOS)
|
||||||
cats, seen = [], set()
|
cats, seen = [], set()
|
||||||
for _, r in load("tabla_ramodos").iterrows():
|
for _, r in load("tabla_ramodos").iterrows():
|
||||||
@@ -96,7 +118,8 @@ def main():
|
|||||||
skip_date += 1
|
skip_date += 1
|
||||||
return
|
return
|
||||||
rows.append((
|
rows.append((
|
||||||
str(uuid.uuid4()), td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
str(uuid.uuid4()), account_id,
|
||||||
|
td, s(r["tipo"]), s(r["num"]), s(r["concepto"]),
|
||||||
amount, None, # categoryId left NULL (see header)
|
amount, None, # categoryId left NULL (see header)
|
||||||
1 if truthy(r["operado"]) else 0,
|
1 if truthy(r["operado"]) else 0,
|
||||||
1 if (income and truthy(r["transferido"])) else 0,
|
1 if (income and truthy(r["transferido"])) else 0,
|
||||||
@@ -110,9 +133,17 @@ def main():
|
|||||||
for _, r in load("datos_e").iterrows():
|
for _, r in load("datos_e").iterrows():
|
||||||
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
add(r, -(dec(r["egreso"], Decimal(0))), income=False)
|
||||||
|
|
||||||
|
COLS = (
|
||||||
|
"id,bankAccountId,transactionDate,transactionType,reference,concept,amount,"
|
||||||
|
"categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId"
|
||||||
|
)
|
||||||
|
PLACEHOLDERS = ",".join(["%s"] * 14)
|
||||||
|
|
||||||
if sync_mode:
|
if sync_mode:
|
||||||
|
# bankAccountId is deliberately absent from the UPDATE clause: an
|
||||||
|
# account moved by hand in the app must not be dragged back.
|
||||||
for row in rows:
|
for row in rows:
|
||||||
c.execute("INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
c.execute(f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS}) ON DUPLICATE KEY UPDATE transactionDate=VALUES(transactionDate),transactionType=VALUES(transactionType),reference=VALUES(reference),concept=VALUES(concept),amount=VALUES(amount),cleared=VALUES(cleared),transferred=VALUES(transferred),notes=VALUES(notes),amountInWords=VALUES(amountInWords),voidedAt=NULL", row)
|
||||||
else:
|
else:
|
||||||
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
c.execute("SET FOREIGN_KEY_CHECKS=0")
|
||||||
for t in ("bank_transactions", "business_line_categories"):
|
for t in ("bank_transactions", "business_line_categories"):
|
||||||
@@ -120,7 +151,7 @@ def main():
|
|||||||
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
c.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
c.executemany("INSERT INTO business_line_categories (id,name) VALUES (%s,%s)", cats)
|
||||||
c.executemany(
|
c.executemany(
|
||||||
"INSERT INTO bank_transactions (id,transactionDate,transactionType,reference,concept,amount,categoryId,cleared,transferred,notes,amountInWords,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", rows)
|
f"INSERT INTO bank_transactions ({COLS}) VALUES ({PLACEHOLDERS})", rows)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
def count(t):
|
def count(t):
|
||||||
@@ -136,6 +167,15 @@ def main():
|
|||||||
for src, n, tot in by_src:
|
for src, n, tot in by_src:
|
||||||
print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
|
print(f" {(src or '(manual)'):10} {n:6} sum {tot}")
|
||||||
print(f" net balance movement : {net}")
|
print(f" net balance movement : {net}")
|
||||||
|
# Per account, never a cross-account total: the registers are in different
|
||||||
|
# currencies and summing them produces a figure that never existed.
|
||||||
|
c.execute(
|
||||||
|
"SELECT a.label, a.currency, COUNT(t.id), COALESCE(SUM(t.amount), 0) "
|
||||||
|
"FROM bank_accounts a LEFT JOIN bank_transactions t ON t.bankAccountId = a.id "
|
||||||
|
"GROUP BY a.id, a.label, a.currency ORDER BY a.label"
|
||||||
|
)
|
||||||
|
for label, currency, n, total in c.fetchall():
|
||||||
|
print(f" {label:34} {currency} {n:6} neto {total}")
|
||||||
print(f" -> business_line_categories: {count('business_line_categories')}")
|
print(f" -> business_line_categories: {count('business_line_categories')}")
|
||||||
print(" validation: OK")
|
print(" validation: OK")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ def main():
|
|||||||
props.append((
|
props.append((
|
||||||
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
pid, cust_id, s(row["direccion"]), ", ".join(addr2_parts) or None,
|
||||||
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
|
s(row["telefono"]), s(row["telefono2"]), s(row["telefono3"]),
|
||||||
s(row["zona"]), "DATMEX", legacy_id,
|
s(row["zona"]), s(row["clave"]), "DATMEX", legacy_id,
|
||||||
|
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -178,9 +178,16 @@ def main():
|
|||||||
svc("ELECTRIC", account=s(row[rc]),
|
svc("ELECTRIC", account=s(row[rc]),
|
||||||
notes=s(row["luz_tipo"]) if rc == "rpu" else None,
|
notes=s(row["luz_tipo"]) if rc == "rpu" else None,
|
||||||
active=flag("electric"))
|
active=flag("electric"))
|
||||||
# GAS
|
# GAS — the column mixes two things: an account/meter number for 160 of
|
||||||
|
# the 334 filled rows, and a tank descriptor ("ESTACIONARIO",
|
||||||
|
# "CILINDRO") for the rest. Only the numeric form can be matched
|
||||||
|
# against a scanned gas statement, so it is promoted to meterNumber;
|
||||||
|
# the descriptor stays a note, as before.
|
||||||
if s(row["gas"]) or flag("gas1", False):
|
if s(row["gas"]) or flag("gas1", False):
|
||||||
svc("GAS", due=s(row["gas_vence"]), notes=s(row["gas"]), active=flag("gas1"))
|
gas_val = s(row["gas"])
|
||||||
|
gas_meter = gas_val if gas_val and gas_val.isdigit() and len(gas_val) >= 5 else None
|
||||||
|
svc("GAS", meter=gas_meter, due=s(row["gas_vence"]),
|
||||||
|
notes=s(row["gas"]), active=flag("gas1"))
|
||||||
# CABLE
|
# CABLE
|
||||||
if s(row["cable_num"]) or (s_keep0(row["cable_sky"]) or "0") in _TRUE:
|
if s(row["cable_num"]) or (s_keep0(row["cable_sky"]) or "0") in _TRUE:
|
||||||
svc("CABLE", account=s(row["cable_num"]), route=s(row["cia_cable"]),
|
svc("CABLE", account=s(row["cable_num"]), route=s(row["cia_cable"]),
|
||||||
@@ -194,6 +201,19 @@ def main():
|
|||||||
if s(row["zfed"]) or flag("federalzone", False):
|
if s(row["zfed"]) or flag("federalzone", False):
|
||||||
svc("FEDERAL_ZONE", account=s(row["zfed"]), notes=s(row["zfed_t"]),
|
svc("FEDERAL_ZONE", account=s(row["zfed"]), notes=s(row["zfed_t"]),
|
||||||
active=flag("federalzone"))
|
active=flag("federalzone"))
|
||||||
|
# TELEPHONE — DATMEX never had a phone *service*, only the contact
|
||||||
|
# numbers unpivoted into Property.phone1/2/3 above, even though the
|
||||||
|
# legacy ledger billed phone as its own transaction type. A Telnor bill
|
||||||
|
# can only be matched against a service row, so the primary number
|
||||||
|
# becomes one. Only phone1: of 1518 properties, 534 have phone1, 18
|
||||||
|
# phone2 and exactly 1 phone3 — the secondaries are alternate contacts,
|
||||||
|
# not additional billed lines. Stored as the bare local number, which is
|
||||||
|
# how DATMEX holds it and what a printed bill reduces to once the 664
|
||||||
|
# Tijuana LADA is stripped.
|
||||||
|
tel = s(row["telefono"])
|
||||||
|
if tel:
|
||||||
|
svc("TELEPHONE", account="".join(ch for ch in tel if ch.isdigit()) or None,
|
||||||
|
notes="from DATMEX.telefono")
|
||||||
# ALARM
|
# ALARM
|
||||||
if s(row["alarm_system"]):
|
if s(row["alarm_system"]):
|
||||||
svc("ALARM", notes=s(row["alarm_system"]))
|
svc("ALARM", notes=s(row["alarm_system"]))
|
||||||
@@ -218,7 +238,7 @@ def main():
|
|||||||
cur.execute("DELETE ps FROM property_services ps JOIN properties p ON p.id=ps.propertyId WHERE p.legacyId IS NOT NULL")
|
cur.execute("DELETE ps FROM property_services ps JOIN properties p ON p.id=ps.propertyId WHERE p.legacyId IS NOT NULL")
|
||||||
cur.execute("DELETE ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL")
|
cur.execute("DELETE ta FROM trust_accounts ta JOIN properties p ON p.id=ta.propertyId WHERE p.legacyId IS NOT NULL")
|
||||||
cur.executemany(
|
cur.executemany(
|
||||||
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),phone1=VALUES(phone1),phone2=VALUES(phone2),phone3=VALUES(phone3),zone=VALUES(zone),archivedAt=NULL", props)
|
"INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,cadastralKey,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),phone1=VALUES(phone1),phone2=VALUES(phone2),phone3=VALUES(phone3),zone=VALUES(zone),cadastralKey=VALUES(cadastralKey),archivedAt=NULL", props)
|
||||||
delete_missing(cur, "properties", ("legacySourceTable", "legacyId"), prop_keys, "WHERE legacyId IS NOT NULL")
|
delete_missing(cur, "properties", ("legacySourceTable", "legacyId"), prop_keys, "WHERE legacyId IS NOT NULL")
|
||||||
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
||||||
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||||
@@ -227,7 +247,7 @@ def main():
|
|||||||
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
for t in ("property_services", "service_documents", "trust_accounts", "properties"):
|
||||||
cur.execute(f"TRUNCATE TABLE {t}")
|
cur.execute(f"TRUNCATE TABLE {t}")
|
||||||
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
cur.execute("SET FOREIGN_KEY_CHECKS=1")
|
||||||
cur.executemany("INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
|
cur.executemany("INSERT INTO properties (id,customerId,addressLine1,addressLine2,phone1,phone2,phone3,zone,cadastralKey,legacySourceTable,legacyId) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", props)
|
||||||
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
cur.executemany("INSERT INTO property_services (id,propertyId,kind,accountNumber,meterNumber,route,dueDay,active,notes) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)", services)
|
||||||
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
cur.executemany("INSERT INTO trust_accounts (id,propertyId,bankName,trustNumber,bankFee,dueDate1,dueDate2) VALUES (%s,%s,%s,%s,%s,%s,%s)", trusts)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jorgecuadros-platform",
|
"name": "jorgecuadros-platform",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
@@ -12,7 +12,9 @@
|
|||||||
"build": "npm run build -ws --if-present",
|
"build": "npm run build -ws --if-present",
|
||||||
"prisma:generate": "npm run generate -w packages/database",
|
"prisma:generate": "npm run generate -w packages/database",
|
||||||
"prisma:migrate": "npm run migrate:dev -w packages/database",
|
"prisma:migrate": "npm run migrate:dev -w packages/database",
|
||||||
"prisma:studio": "npm run studio -w packages/database"
|
"prisma:deploy": "npm run migrate:deploy -w packages/database",
|
||||||
|
"prisma:studio": "npm run studio -w packages/database",
|
||||||
|
"version:set": "node scripts/set-version.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/database",
|
"name": "@jorgecuadros/database",
|
||||||
"version": "0.1.0",
|
"version": "1.0.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "generated/client/index.js",
|
"main": "generated/client/index.js",
|
||||||
"types": "generated/client/index.d.ts",
|
"types": "generated/client/index.d.ts",
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `customers` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`nameSource` VARCHAR(191) NULL,
|
||||||
|
`nameMissing` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`addressLine1` VARCHAR(191) NULL,
|
||||||
|
`addressLine2` VARCHAR(191) NULL,
|
||||||
|
`city` VARCHAR(191) NULL,
|
||||||
|
`state` VARCHAR(191) NULL,
|
||||||
|
`zipCode` VARCHAR(191) NULL,
|
||||||
|
`country` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`mobile` VARCHAR(191) NULL,
|
||||||
|
`fax` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`identificationType` VARCHAR(191) NULL,
|
||||||
|
`identificationNumber` VARCHAR(191) NULL,
|
||||||
|
`identificationExpiration` DATETIME(3) NULL,
|
||||||
|
`customerSince` DATETIME(3) NULL,
|
||||||
|
`status` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`minimumBalance` DECIMAL(12, 2) NULL,
|
||||||
|
`feeAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`preferredCurrency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'USD',
|
||||||
|
`archivedAt` DATETIME(3) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `customer_legacy_refs` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`sourceSystem` VARCHAR(191) NOT NULL,
|
||||||
|
`sourceTable` VARCHAR(191) NOT NULL,
|
||||||
|
`legacyId` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `customer_legacy_refs_sourceSystem_sourceTable_legacyId_key`(`sourceSystem`, `sourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `insurance_providers` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `insurance_providers_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_types` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`shortDescription` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `policy_types_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policies` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyNumber` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`policyTypeId` VARCHAR(191) NULL,
|
||||||
|
`insuranceProviderId` VARCHAR(191) NULL,
|
||||||
|
`agentName` VARCHAR(191) NULL,
|
||||||
|
`policyDate` DATETIME(3) NULL,
|
||||||
|
`policyFrom` DATETIME(3) NULL,
|
||||||
|
`policyTo` DATETIME(3) NULL,
|
||||||
|
`coveragePeriodDays` INTEGER NULL DEFAULT 365,
|
||||||
|
`netPremium` DECIMAL(12, 2) NULL,
|
||||||
|
`policyFee` DECIMAL(12, 2) NULL,
|
||||||
|
`brokerFee` DECIMAL(12, 2) NULL,
|
||||||
|
`commission` DECIMAL(12, 2) NULL,
|
||||||
|
`total` DECIMAL(12, 2) NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
|
||||||
|
`observations` TEXT NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`coveragesJson` JSON NULL,
|
||||||
|
`endorsement` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`liquidated` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`liquidationNumber` VARCHAR(191) NULL,
|
||||||
|
`liquidationDate` DATETIME(3) NULL,
|
||||||
|
`archivedAt` DATETIME(3) NULL,
|
||||||
|
`legacySourceDb` VARCHAR(191) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
INDEX `policies_policyNumber_idx`(`policyNumber`),
|
||||||
|
UNIQUE INDEX `policies_legacySourceDb_legacySourceTable_legacyId_key`(`legacySourceDb`, `legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `renewal_notices` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`generation` INTEGER NOT NULL,
|
||||||
|
`channel` ENUM('MAIL', 'EMAIL') NOT NULL DEFAULT 'MAIL',
|
||||||
|
`sentAt` DATETIME(3) NULL,
|
||||||
|
`sentById` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `renewal_notices_policyId_generation_key`(`policyId`, `generation`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_payment_installments` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`sequence` INTEGER NOT NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
|
||||||
|
`dueDate` DATETIME(3) NULL,
|
||||||
|
`paidDate` DATETIME(3) NULL,
|
||||||
|
`checkNumber` VARCHAR(191) NULL,
|
||||||
|
`isCash` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `vehicles` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NULL,
|
||||||
|
`policyId` VARCHAR(191) NULL,
|
||||||
|
`make` VARCHAR(191) NULL,
|
||||||
|
`model` VARCHAR(191) NULL,
|
||||||
|
`modelYear` VARCHAR(191) NULL,
|
||||||
|
`bodyType` VARCHAR(191) NULL,
|
||||||
|
`engineNumber` VARCHAR(191) NULL,
|
||||||
|
`licensePlate` VARCHAR(191) NULL,
|
||||||
|
`vinNumber` VARCHAR(191) NULL,
|
||||||
|
`stateCode` VARCHAR(191) NULL,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `insured_drivers` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`fullName` VARCHAR(191) NULL,
|
||||||
|
`birthDate` DATETIME(3) NULL,
|
||||||
|
`sex` VARCHAR(191) NULL,
|
||||||
|
`occupation` VARCHAR(191) NULL,
|
||||||
|
`licenseNumber` VARCHAR(191) NULL,
|
||||||
|
`licenseState` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_beneficiaries` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NULL,
|
||||||
|
`address` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `claims` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`claimType` VARCHAR(191) NULL,
|
||||||
|
`incidentDate` DATETIME(3) NULL,
|
||||||
|
`reportedDate` DATETIME(3) NULL,
|
||||||
|
`description` TEXT NULL,
|
||||||
|
`adjusterId` VARCHAR(191) NULL,
|
||||||
|
`claimedAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`settledAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`settlementDate` DATETIME(3) NULL,
|
||||||
|
`checkNumber` VARCHAR(191) NULL,
|
||||||
|
`resolved` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`resolutionNotes` TEXT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `adjusters` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`company` VARCHAR(191) NULL,
|
||||||
|
`city` VARCHAR(191) NULL,
|
||||||
|
`name` VARCHAR(191) NULL,
|
||||||
|
`phone` VARCHAR(191) NULL,
|
||||||
|
`beeper` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `policy_documents` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NOT NULL,
|
||||||
|
`documentType` VARCHAR(191) NOT NULL,
|
||||||
|
`storageKey` VARCHAR(191) NOT NULL,
|
||||||
|
`originalColumn` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `properties` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`policyId` VARCHAR(191) NULL,
|
||||||
|
`addressLine1` VARCHAR(191) NULL,
|
||||||
|
`addressLine2` VARCHAR(191) NULL,
|
||||||
|
`phone1` VARCHAR(191) NULL,
|
||||||
|
`phone2` VARCHAR(191) NULL,
|
||||||
|
`phone3` VARCHAR(191) NULL,
|
||||||
|
`zone` VARCHAR(191) NULL,
|
||||||
|
`archivedAt` DATETIME(3) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `properties_legacySourceTable_legacyId_key`(`legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `property_services` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`propertyId` VARCHAR(191) NOT NULL,
|
||||||
|
`kind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'OTHER') NOT NULL,
|
||||||
|
`accountNumber` VARCHAR(191) NULL,
|
||||||
|
`meterNumber` VARCHAR(191) NULL,
|
||||||
|
`route` VARCHAR(191) NULL,
|
||||||
|
`dueDay` VARCHAR(191) NULL,
|
||||||
|
`active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `service_documents` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`propertyId` VARCHAR(191) NOT NULL,
|
||||||
|
`documentType` VARCHAR(191) NOT NULL,
|
||||||
|
`storageKey` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `trust_accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`propertyId` VARCHAR(191) NOT NULL,
|
||||||
|
`bankName` VARCHAR(191) NULL,
|
||||||
|
`trustNumber` VARCHAR(191) NULL,
|
||||||
|
`bankFee` DECIMAL(12, 2) NULL,
|
||||||
|
`dueDate1` DATETIME(3) NULL,
|
||||||
|
`dueDate2` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `trust_accounts_propertyId_key`(`propertyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `type_transactions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`nameEn` VARCHAR(191) NOT NULL,
|
||||||
|
`nameEs` VARCHAR(191) NULL,
|
||||||
|
`isService` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `transactions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NOT NULL,
|
||||||
|
`domain` ENUM('UTILITY', 'INSURANCE', 'TRUST') NOT NULL,
|
||||||
|
`typeId` VARCHAR(191) NULL,
|
||||||
|
`transactionDate` DATETIME(3) NOT NULL,
|
||||||
|
`period` VARCHAR(191) NULL,
|
||||||
|
`reference` VARCHAR(191) NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL DEFAULT 'MXN',
|
||||||
|
`exchangeRate` DECIMAL(10, 4) NULL,
|
||||||
|
`checkNumber` VARCHAR(191) NULL,
|
||||||
|
`message` TEXT NULL,
|
||||||
|
`outstanding` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`captureSource` ENUM('MANUAL', 'BATCH', 'OCR') NULL,
|
||||||
|
`captureRef` VARCHAR(191) NULL,
|
||||||
|
`voidedAt` DATETIME(3) NULL,
|
||||||
|
`voidedById` VARCHAR(191) NULL,
|
||||||
|
`legacySourceDb` VARCHAR(191) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
INDEX `transactions_customerId_transactionDate_idx`(`customerId`, `transactionDate`),
|
||||||
|
INDEX `transactions_checkNumber_idx`(`checkNumber`),
|
||||||
|
INDEX `transactions_captureRef_idx`(`captureRef`),
|
||||||
|
UNIQUE INDEX `transactions_legacySourceDb_legacySourceTable_legacyId_key`(`legacySourceDb`, `legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `exchange_rates` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`rate` DECIMAL(10, 4) NOT NULL,
|
||||||
|
`effectiveDate` DATETIME(3) NOT NULL,
|
||||||
|
`effectiveHour` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `business_line_categories` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `business_line_categories_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `banks` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`country` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `banks_name_key`(`name`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `bank_accounts` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`bankId` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NOT NULL,
|
||||||
|
`currency` ENUM('USD', 'MXN') NOT NULL,
|
||||||
|
`businessLine` ENUM('UTILITY', 'INSURANCE', 'TRUST') NULL,
|
||||||
|
`active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `bank_transactions` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`bankAccountId` VARCHAR(191) NOT NULL,
|
||||||
|
`transactionDate` DATETIME(3) NOT NULL,
|
||||||
|
`transactionType` VARCHAR(191) NULL,
|
||||||
|
`reference` VARCHAR(191) NULL,
|
||||||
|
`concept` VARCHAR(191) NULL,
|
||||||
|
`amount` DECIMAL(12, 2) NOT NULL,
|
||||||
|
`categoryId` VARCHAR(191) NULL,
|
||||||
|
`cleared` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`transferred` BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
`notes` TEXT NULL,
|
||||||
|
`amountInWords` VARCHAR(191) NULL,
|
||||||
|
`voidedAt` DATETIME(3) NULL,
|
||||||
|
`voidedById` VARCHAR(191) NULL,
|
||||||
|
`legacySourceTable` VARCHAR(191) NULL,
|
||||||
|
`legacyId` VARCHAR(191) NULL,
|
||||||
|
|
||||||
|
INDEX `bank_transactions_bankAccountId_transactionDate_idx`(`bankAccountId`, `transactionDate`),
|
||||||
|
UNIQUE INDEX `bank_transactions_legacySourceTable_legacyId_key`(`legacySourceTable`, `legacyId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`passwordHash` VARCHAR(191) NOT NULL,
|
||||||
|
`role` ENUM('ADMIN', 'MANAGER', 'STAFF', 'VIEWER') NOT NULL DEFAULT 'STAFF',
|
||||||
|
`active` BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
`uiScale` DOUBLE NOT NULL DEFAULT 1,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `users_email_key`(`email`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `activity_logs` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`userId` VARCHAR(191) NULL,
|
||||||
|
`event` VARCHAR(191) NOT NULL,
|
||||||
|
`level` VARCHAR(191) NOT NULL,
|
||||||
|
`message` JSON NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `email_templates` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NOT NULL,
|
||||||
|
`templateSource` TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `email_campaigns` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`campaignName` VARCHAR(191) NOT NULL,
|
||||||
|
`subject` VARCHAR(191) NULL,
|
||||||
|
`body` TEXT NULL,
|
||||||
|
`status` VARCHAR(191) NOT NULL DEFAULT 'in_progress',
|
||||||
|
`emailSentCount` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `email_log` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`customerId` VARCHAR(191) NULL,
|
||||||
|
`emailAddress` VARCHAR(191) NULL,
|
||||||
|
`emailType` VARCHAR(191) NULL,
|
||||||
|
`requestBody` TEXT NULL,
|
||||||
|
`responseBody` TEXT NULL,
|
||||||
|
`sentAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `ops_jobs` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`kind` ENUM('BACKUP', 'RESTORE', 'REIMPORT', 'SYNC') NOT NULL,
|
||||||
|
`status` ENUM('RUNNING', 'SUCCESS', 'FAILED') NOT NULL DEFAULT 'RUNNING',
|
||||||
|
`log` LONGTEXT NOT NULL,
|
||||||
|
`params` JSON NULL,
|
||||||
|
`createdById` VARCHAR(191) NULL,
|
||||||
|
`startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`finishedAt` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `ops_jobs_status_idx`(`status`),
|
||||||
|
INDEX `ops_jobs_startedAt_idx`(`startedAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `customer_legacy_refs` ADD CONSTRAINT `customer_legacy_refs_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policies` ADD CONSTRAINT `policies_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policies` ADD CONSTRAINT `policies_policyTypeId_fkey` FOREIGN KEY (`policyTypeId`) REFERENCES `policy_types`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policies` ADD CONSTRAINT `policies_insuranceProviderId_fkey` FOREIGN KEY (`insuranceProviderId`) REFERENCES `insurance_providers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `renewal_notices` ADD CONSTRAINT `renewal_notices_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policy_payment_installments` ADD CONSTRAINT `policy_payment_installments_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `vehicles` ADD CONSTRAINT `vehicles_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `vehicles` ADD CONSTRAINT `vehicles_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `insured_drivers` ADD CONSTRAINT `insured_drivers_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policy_beneficiaries` ADD CONSTRAINT `policy_beneficiaries_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `claims` ADD CONSTRAINT `claims_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `claims` ADD CONSTRAINT `claims_adjusterId_fkey` FOREIGN KEY (`adjusterId`) REFERENCES `adjusters`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `policy_documents` ADD CONSTRAINT `policy_documents_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `properties` ADD CONSTRAINT `properties_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `properties` ADD CONSTRAINT `properties_policyId_fkey` FOREIGN KEY (`policyId`) REFERENCES `policies`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `property_services` ADD CONSTRAINT `property_services_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `service_documents` ADD CONSTRAINT `service_documents_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `trust_accounts` ADD CONSTRAINT `trust_accounts_propertyId_fkey` FOREIGN KEY (`propertyId`) REFERENCES `properties`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `transactions` ADD CONSTRAINT `transactions_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `transactions` ADD CONSTRAINT `transactions_typeId_fkey` FOREIGN KEY (`typeId`) REFERENCES `type_transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `bank_accounts` ADD CONSTRAINT `bank_accounts_bankId_fkey` FOREIGN KEY (`bankId`) REFERENCES `banks`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `bank_transactions` ADD CONSTRAINT `bank_transactions_bankAccountId_fkey` FOREIGN KEY (`bankAccountId`) REFERENCES `bank_accounts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `bank_transactions` ADD CONSTRAINT `bank_transactions_categoryId_fkey` FOREIGN KEY (`categoryId`) REFERENCES `business_line_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `activity_logs` ADD CONSTRAINT `activity_logs_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `properties` ADD COLUMN `cadastralKey` VARCHAR(191) NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `property_services` MODIFY `kind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'TELEPHONE', 'OTHER') NOT NULL;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `statement_batches` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`serviceKind` ENUM('WATER', 'ELECTRIC', 'GAS', 'CABLE', 'PROPERTY_TAX', 'FEDERAL_ZONE', 'ALARM', 'TELEPHONE', 'OTHER') NOT NULL,
|
||||||
|
`status` ENUM('UPLOADED', 'PROCESSING', 'READY_FOR_REVIEW', 'COMPLETED', 'FAILED') NOT NULL DEFAULT 'UPLOADED',
|
||||||
|
`uploadedById` VARCHAR(191) NOT NULL,
|
||||||
|
`label` VARCHAR(191) NULL,
|
||||||
|
`fileCount` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`error` TEXT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`completedAt` DATETIME(3) NULL,
|
||||||
|
|
||||||
|
INDEX `statement_batches_status_createdAt_idx`(`status`, `createdAt`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `statement_documents` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`batchId` VARCHAR(191) NOT NULL,
|
||||||
|
`pageNumber` INTEGER NOT NULL,
|
||||||
|
`storageKey` VARCHAR(191) NOT NULL,
|
||||||
|
`status` ENUM('PENDING_OCR', 'OCR_FAILED', 'NEEDS_REVIEW', 'MATCHED', 'CONFIRMED', 'POSTED', 'REJECTED') NOT NULL DEFAULT 'PENDING_OCR',
|
||||||
|
`ocrRawText` TEXT NULL,
|
||||||
|
`ocrConfidence` DECIMAL(4, 3) NULL,
|
||||||
|
`provider` VARCHAR(191) NULL,
|
||||||
|
`extractedAccountRef` VARCHAR(191) NULL,
|
||||||
|
`extractedAmount` DECIMAL(12, 2) NULL,
|
||||||
|
`extractedPeriod` VARCHAR(191) NULL,
|
||||||
|
`extractedDueDate` DATETIME(3) NULL,
|
||||||
|
`extractedCadastralKey` VARCHAR(191) NULL,
|
||||||
|
`matchedPropertyServiceId` VARCHAR(191) NULL,
|
||||||
|
`matchedCustomerId` VARCHAR(191) NULL,
|
||||||
|
`matchNote` VARCHAR(191) NULL,
|
||||||
|
`reviewedById` VARCHAR(191) NULL,
|
||||||
|
`reviewedAt` DATETIME(3) NULL,
|
||||||
|
`postedTransactionId` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `statement_documents_postedTransactionId_key`(`postedTransactionId`),
|
||||||
|
INDEX `statement_documents_status_idx`(`status`),
|
||||||
|
INDEX `statement_documents_matchedCustomerId_idx`(`matchedCustomerId`),
|
||||||
|
UNIQUE INDEX `statement_documents_batchId_pageNumber_key`(`batchId`, `pageNumber`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `properties_cadastralKey_idx` ON `properties`(`cadastralKey`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `property_services_kind_accountNumber_idx` ON `property_services`(`kind`, `accountNumber`);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX `property_services_kind_meterNumber_idx` ON `property_services`(`kind`, `meterNumber`);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `statement_batches` ADD CONSTRAINT `statement_batches_uploadedById_fkey` FOREIGN KEY (`uploadedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_batchId_fkey` FOREIGN KEY (`batchId`) REFERENCES `statement_batches`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_matchedPropertyServiceId_fkey` FOREIGN KEY (`matchedPropertyServiceId`) REFERENCES `property_services`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_matchedCustomerId_fkey` FOREIGN KEY (`matchedCustomerId`) REFERENCES `customers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_reviewedById_fkey` FOREIGN KEY (`reviewedById`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `statement_documents` ADD CONSTRAINT `statement_documents_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "mysql"
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
output = "../generated/client"
|
output = "../generated/client"
|
||||||
|
// "native" covers local dev. The musl target is declared EXPLICITLY because
|
||||||
|
// Prisma picks the engine by sniffing the build environment: the Docker build
|
||||||
|
// stage has no openssl, so it detected plain "linux-musl", while the runtime
|
||||||
|
// stage (which needs openssl for other reasons) then demanded
|
||||||
|
// "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
|
||||||
|
// engine that ships independent of what happens to be installed at build time.
|
||||||
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@@ -46,6 +53,12 @@ enum ServiceKind {
|
|||||||
PROPERTY_TAX
|
PROPERTY_TAX
|
||||||
FEDERAL_ZONE
|
FEDERAL_ZONE
|
||||||
ALARM
|
ALARM
|
||||||
|
/// Telephone was never unpivoted out of DATMEX — the numbers sat on
|
||||||
|
/// `Property.phone1/2/3` as contact fields even though the legacy ledger
|
||||||
|
/// billed phone as its own transaction type. OCR matching needs a real
|
||||||
|
/// service row to match a Telnor bill against, so it becomes one; see
|
||||||
|
/// `migration/backfill_statement_match_fields.py`.
|
||||||
|
TELEPHONE
|
||||||
OTHER
|
OTHER
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +121,8 @@ model Customer {
|
|||||||
vehicles Vehicle[]
|
vehicles Vehicle[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
|
||||||
|
statementDocuments StatementDocument[]
|
||||||
|
|
||||||
@@map("customers")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +381,14 @@ model Property {
|
|||||||
phone2 String?
|
phone2 String?
|
||||||
phone3 String?
|
phone3 String?
|
||||||
zone String?
|
zone String?
|
||||||
|
/// Clave catastral (DATMEX.clave) — the cadastral key, format `KA903009`.
|
||||||
|
/// Property-level, not per-service: it is printed on both the CESPT water
|
||||||
|
/// bill and the predial statement, which is exactly why it is a useful
|
||||||
|
/// secondary match key when a bill's account number does not OCR cleanly.
|
||||||
|
/// Distinct from the numeric DATMEX.predial that `PROPERTY_TAX.accountNumber`
|
||||||
|
/// carries — that column is not unique (663 distinct across 1135 rows) and
|
||||||
|
/// is not what any statement prints.
|
||||||
|
cadastralKey String?
|
||||||
// Soft-delete marker (see Customer.archivedAt).
|
// Soft-delete marker (see Customer.archivedAt).
|
||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
@@ -376,6 +399,8 @@ model Property {
|
|||||||
documents ServiceDocument[]
|
documents ServiceDocument[]
|
||||||
trustAccount TrustAccount?
|
trustAccount TrustAccount?
|
||||||
|
|
||||||
|
@@index([cadastralKey])
|
||||||
|
|
||||||
@@unique([legacySourceTable, legacyId])
|
@@unique([legacySourceTable, legacyId])
|
||||||
@@map("properties")
|
@@map("properties")
|
||||||
}
|
}
|
||||||
@@ -394,6 +419,13 @@ model PropertyService {
|
|||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
notes String? @db.Text
|
notes String? @db.Text
|
||||||
|
|
||||||
|
statementDocuments StatementDocument[]
|
||||||
|
|
||||||
|
// The OCR matcher looks a service up by (kind, accountNumber) — always
|
||||||
|
// scoped to one kind, never fuzzily across every identifier column, so a
|
||||||
|
// water account number cannot collide with an unrelated phone number.
|
||||||
|
@@index([kind, accountNumber])
|
||||||
|
@@index([kind, meterNumber])
|
||||||
@@map("property_services")
|
@@map("property_services")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +440,120 @@ model ServiceDocument {
|
|||||||
@@map("service_documents")
|
@@map("service_documents")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statement OCR intake (RECEIPT_CAPTURE_SPEC §2)
|
||||||
|
//
|
||||||
|
// Each utility company mails 300+ paper statements a month, one per customer,
|
||||||
|
// which staff key in by hand. These two tables are the intake side of removing
|
||||||
|
// that: a batch of scanned PDFs is split per page, OCR'd, matched to a
|
||||||
|
// PropertyService by its scoped account number, and queued for review. Nothing
|
||||||
|
// here writes to the ledger — confirming a document posts it through
|
||||||
|
// `BillingService.createBatch`, the same path hand-keyed batches take.
|
||||||
|
//
|
||||||
|
// Everything ingested is a CHARGE (a bill awaiting payment), never a proof of
|
||||||
|
// payment: the office scans what it must pay, and settles it by check through
|
||||||
|
// the existing capture flow.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
enum StatementBatchStatus {
|
||||||
|
UPLOADED
|
||||||
|
PROCESSING
|
||||||
|
READY_FOR_REVIEW
|
||||||
|
COMPLETED
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StatementDocumentStatus {
|
||||||
|
PENDING_OCR
|
||||||
|
OCR_FAILED
|
||||||
|
/// No confident match, or the extraction itself was low-confidence.
|
||||||
|
NEEDS_REVIEW
|
||||||
|
/// Confident auto-match, awaiting a human confirm.
|
||||||
|
MATCHED
|
||||||
|
/// Staff confirmed; not yet posted.
|
||||||
|
CONFIRMED
|
||||||
|
POSTED
|
||||||
|
/// Duplicate, unreadable, or wrong batch.
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One upload session — e.g. "October CFE statements".
|
||||||
|
model StatementBatch {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
/// What kind of service every statement in this batch bills. The parser
|
||||||
|
/// still detects the provider per page and flags any page that disagrees,
|
||||||
|
/// rather than trusting the uploader's label.
|
||||||
|
serviceKind ServiceKind
|
||||||
|
status StatementBatchStatus @default(UPLOADED)
|
||||||
|
uploadedById String
|
||||||
|
uploadedBy User @relation("StatementBatchUploader", fields: [uploadedById], references: [id])
|
||||||
|
label String?
|
||||||
|
fileCount Int @default(0)
|
||||||
|
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
|
||||||
|
error String? @db.Text
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
completedAt DateTime?
|
||||||
|
|
||||||
|
documents StatementDocument[]
|
||||||
|
|
||||||
|
@@index([status, createdAt])
|
||||||
|
@@map("statement_batches")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One statement — one customer, one period — after splitting the batch.
|
||||||
|
model StatementDocument {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
batchId String
|
||||||
|
batch StatementBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||||
|
/// 1-based page of the source PDF this was split from.
|
||||||
|
pageNumber Int
|
||||||
|
/// The rendered page image in object storage. The source PDF is kept too, so
|
||||||
|
/// a reviewer can always see exactly what the parser read.
|
||||||
|
storageKey String
|
||||||
|
status StatementDocumentStatus @default(PENDING_OCR)
|
||||||
|
|
||||||
|
/// Raw OCR text, kept even after a manual correction so a mismatch between
|
||||||
|
/// what the machine read and what staff entered stays auditable.
|
||||||
|
ocrRawText String? @db.Text
|
||||||
|
/// Mean per-word confidence reported by the OCR engine, 0..1.
|
||||||
|
ocrConfidence Decimal? @db.Decimal(4, 3)
|
||||||
|
/// Which parser claimed the page ("CFE", "CESPT", "TELNOR").
|
||||||
|
provider String?
|
||||||
|
|
||||||
|
// Extracted, then staff-corrected in place. `extractedAccountRef` is already
|
||||||
|
// normalised for matching (CFE leading zeros stripped, Telnor LADA removed).
|
||||||
|
extractedAccountRef String?
|
||||||
|
extractedAmount Decimal? @db.Decimal(12, 2)
|
||||||
|
extractedPeriod String?
|
||||||
|
extractedDueDate DateTime?
|
||||||
|
/// Clave catastral when the statement prints one — a second key to match on
|
||||||
|
/// when the account number is unreadable.
|
||||||
|
extractedCadastralKey String?
|
||||||
|
|
||||||
|
matchedPropertyServiceId String?
|
||||||
|
matchedPropertyService PropertyService? @relation(fields: [matchedPropertyServiceId], references: [id])
|
||||||
|
matchedCustomerId String?
|
||||||
|
matchedCustomer Customer? @relation(fields: [matchedCustomerId], references: [id])
|
||||||
|
/// Why this landed where it did — "exact account match", "no candidate",
|
||||||
|
/// "2 candidates". Shown in the review queue so staff can trust or distrust
|
||||||
|
/// the suggestion without opening the image.
|
||||||
|
matchNote String?
|
||||||
|
|
||||||
|
reviewedById String?
|
||||||
|
reviewedBy User? @relation("StatementDocumentReviewer", fields: [reviewedById], references: [id])
|
||||||
|
reviewedAt DateTime?
|
||||||
|
|
||||||
|
postedTransactionId String? @unique
|
||||||
|
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([batchId, pageNumber])
|
||||||
|
@@index([status])
|
||||||
|
@@index([matchedCustomerId])
|
||||||
|
@@map("statement_documents")
|
||||||
|
}
|
||||||
|
|
||||||
/// From TRUSTVENCE.
|
/// From TRUSTVENCE.
|
||||||
model TrustAccount {
|
model TrustAccount {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
@@ -476,6 +622,9 @@ model Transaction {
|
|||||||
legacyId String?
|
legacyId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
/// Set only on OCR-posted rows — the statement page this came from.
|
||||||
|
statementDocument StatementDocument?
|
||||||
|
|
||||||
@@index([customerId, transactionDate])
|
@@index([customerId, transactionDate])
|
||||||
// By-check reconciliation (billing.byCheck / the cheque-count report) looks
|
// By-check reconciliation (billing.byCheck / the cheque-count report) looks
|
||||||
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
|
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
|
||||||
@@ -510,10 +659,46 @@ model BusinessLineCategory {
|
|||||||
@@map("business_line_categories")
|
@@map("business_line_categories")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The institution a chequera is held at. Purely a grouping label for the
|
||||||
|
/// accounts under it — no money hangs off a Bank directly.
|
||||||
|
model Bank {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
/// "MX" | "US" — informational, used only to label the account picker.
|
||||||
|
country String?
|
||||||
|
accounts BankAccount[]
|
||||||
|
|
||||||
|
@@map("banks")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One physical chequera. Currency is fixed per account, because a real bank
|
||||||
|
/// account is: there is deliberately NO currency column on BankTransaction, a
|
||||||
|
/// movement inherits its account's. This is what keeps the MXN (Utilities /
|
||||||
|
/// Scotiabank) and USD (Seguros) registers from ever being summed together,
|
||||||
|
/// the same rule the customer ledger follows per currency.
|
||||||
|
model BankAccount {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bankId String
|
||||||
|
bank Bank @relation(fields: [bankId], references: [id])
|
||||||
|
/// Staff-facing name, e.g. "Utilities — Scotiabank (MXN)".
|
||||||
|
label String
|
||||||
|
currency Currency
|
||||||
|
/// Hint only, never enforced — one chequera can pay for more than one line.
|
||||||
|
businessLine TransactionDomain?
|
||||||
|
active Boolean @default(true)
|
||||||
|
movements BankTransaction[]
|
||||||
|
|
||||||
|
@@map("bank_accounts")
|
||||||
|
}
|
||||||
|
|
||||||
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
|
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
|
||||||
/// signed-amount table: income positive, expense negative.
|
/// signed-amount table: income positive, expense negative.
|
||||||
model BankTransaction {
|
model BankTransaction {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
|
// Required: a movement with no known account isn't reconcilable against a
|
||||||
|
// statement. Every migrated row is SCOTHIA = the Utilities MXN account.
|
||||||
|
bankAccountId String
|
||||||
|
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
|
||||||
transactionDate DateTime
|
transactionDate DateTime
|
||||||
transactionType String?
|
transactionType String?
|
||||||
reference String?
|
reference String?
|
||||||
@@ -531,7 +716,10 @@ model BankTransaction {
|
|||||||
legacySourceTable String?
|
legacySourceTable String?
|
||||||
legacyId String?
|
legacyId String?
|
||||||
|
|
||||||
|
// Provenance stays globally unique: every legacy row belongs to the one
|
||||||
|
// Scotiabank account, so adding accounts never collides here.
|
||||||
@@unique([legacySourceTable, legacyId])
|
@@unique([legacySourceTable, legacyId])
|
||||||
|
@@index([bankAccountId, transactionDate])
|
||||||
@@map("bank_transactions")
|
@@map("bank_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,10 +734,17 @@ model User {
|
|||||||
passwordHash String
|
passwordHash String
|
||||||
role UserRole @default(STAFF)
|
role UserRole @default(STAFF)
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
|
// UI text-size preference, so it follows the person between machines
|
||||||
|
// instead of living only in one browser's localStorage. Range is clamped
|
||||||
|
// API-side (see UpdatePreferencesDto) to match the web's presets.
|
||||||
|
uiScale Float @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
activityLogs ActivityLog[]
|
activityLogs ActivityLog[]
|
||||||
|
|
||||||
|
statementBatches StatementBatch[] @relation("StatementBatchUploader")
|
||||||
|
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Stamp one version across every package.json in the workspace.
|
||||||
|
*
|
||||||
|
* The git tag is what actually drives the image tags (docker/metadata-action in
|
||||||
|
* .gitea/workflows/build.yml reads the tag, not any package.json). This script
|
||||||
|
* exists so the checked-in manifests stop lying: they all sat at 0.1.0 while
|
||||||
|
* real releases went out as v1.x, which makes a checkout impossible to place
|
||||||
|
* against a running container.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/set-version.mjs 1.2.0
|
||||||
|
* pnpm version:set 1.2.0
|
||||||
|
*
|
||||||
|
* Then, as one release commit:
|
||||||
|
* git commit -am "chore(release): v1.2.0"
|
||||||
|
* git tag v1.2.0 && git push origin master v1.2.0
|
||||||
|
*
|
||||||
|
* Note the tag carries the leading `v` but the deploy workflow's `tag` input
|
||||||
|
* does NOT — metadata-action's {{version}} strips it, so the published image is
|
||||||
|
* `1.2.0`. Dispatch `1.2.0`, tag `v1.2.0`.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
|
||||||
|
const MANIFESTS = [
|
||||||
|
"package.json",
|
||||||
|
"apps/api/package.json",
|
||||||
|
"apps/web/package.json",
|
||||||
|
"packages/database/package.json",
|
||||||
|
];
|
||||||
|
|
||||||
|
const version = process.argv[2];
|
||||||
|
if (!version) {
|
||||||
|
console.error("usage: node scripts/set-version.mjs <x.y.z>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
// Plain semver only — a leading `v` here would end up in the image tag and in
|
||||||
|
// every manifest, which is not what any consumer expects.
|
||||||
|
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) {
|
||||||
|
console.error(`invalid version: ${version} (expected x.y.z, no leading "v")`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rel of MANIFESTS) {
|
||||||
|
const file = join(REPO, rel);
|
||||||
|
const raw = readFileSync(file, "utf8");
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
const previous = pkg.version;
|
||||||
|
pkg.version = version;
|
||||||
|
// Match the 2-space + trailing-newline shape the files already have so the
|
||||||
|
// release commit is a one-line diff per manifest.
|
||||||
|
writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
|
||||||
|
console.log(`${rel}: ${previous} -> ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nnext: git commit -am "chore(release): v${version}" && git tag v${version}`);
|
||||||
Reference in New Issue
Block a user