Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af5261c644 | ||
|
|
c239c1a866 | ||
|
|
6468811955 | ||
|
|
ca76f4d57a | ||
|
|
fd5df240bc | ||
|
|
f655fcbb82 | ||
|
|
b016e53893 | ||
|
|
031b13f8c8 | ||
|
|
2cf07ed5ee | ||
|
|
36158ae761 | ||
|
|
fa38ff581e | ||
|
|
c4213aa697 | ||
|
|
f7507f2370 | ||
|
|
2f9a9afc0d | ||
|
|
7e71a993d0 | ||
|
|
aa5867c8ea | ||
|
|
5549a1e0cf | ||
|
|
c6feae9522 | ||
|
|
93f817158e | ||
|
|
29ae9fa5bc | ||
|
|
9973488330 | ||
|
|
75dcbc11b8 | ||
|
|
9929a9a3ac | ||
|
|
8b8de0fdca | ||
|
|
bc749055e7 | ||
|
|
75e9f582b4 | ||
|
|
48e01ddd21 | ||
|
|
8c144fe8c4 | ||
|
|
4f2f064955 | ||
|
|
2f99bd5f98 | ||
|
|
458b2b272d | ||
|
|
81938877ed | ||
|
|
d854dff091 | ||
|
|
19864f16f2 | ||
|
|
ca6432efc8 | ||
|
|
022d1935ad | ||
|
|
5a277f4885 | ||
|
|
d645ba51d3 | ||
|
|
14c4d44acb | ||
|
|
45be0ad77d | ||
|
|
7be897ef2b | ||
|
|
cf40cd22ef | ||
|
|
3b02c6944f | ||
|
|
683fd37b08 | ||
|
|
14c6183aa2 | ||
|
|
5352d49ecf | ||
|
|
2169ffa78d | ||
|
|
17d83291c3 | ||
|
|
6a97242fc3 | ||
|
|
7981c715ce | ||
|
|
d173c9e9a0 | ||
|
|
e9a5ee9e90 |
No files matched your search
+2
-1
@@ -22,12 +22,13 @@ OPS_DB_ADMIN_USER=
|
|||||||
OPS_DB_ADMIN_PASSWORD=
|
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 defaults in company.ts. 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.
|
||||||
COMPANY_NAME=Jorge Cuadros & Asociados
|
COMPANY_NAME=Jorge Cuadros & Asociados
|
||||||
COMPANY_ADDRESS_LINE1=
|
COMPANY_ADDRESS_LINE1=
|
||||||
COMPANY_ADDRESS_LINE2=
|
COMPANY_ADDRESS_LINE2=
|
||||||
COMPANY_CITY_STATE=
|
COMPANY_CITY_STATE=
|
||||||
|
COMPANY_MAILING_ADDRESS=
|
||||||
COMPANY_PHONE=
|
COMPANY_PHONE=
|
||||||
COMPANY_EMAIL=
|
COMPANY_EMAIL=
|
||||||
COMPANY_TAX_ID=
|
COMPANY_TAX_ID=
|
||||||
|
|||||||
@@ -17,8 +17,18 @@
|
|||||||
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
# 3. prisma migrate deploy forward-only. Prisma has no down-migrations; see
|
||||||
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
# docs/DEPLOY_AND_MIGRATIONS.md — expand/contract is
|
||||||
# the rule, the backup is the emergency lever.
|
# the rule, the backup is the emergency lever.
|
||||||
|
# Done HERE so the schema moves while the OLD code is
|
||||||
|
# still serving. The api container ALSO migrates at
|
||||||
|
# start (docker/api-entrypoint.sh); `migrate deploy`
|
||||||
|
# is idempotent, so the second run is a no-op and the
|
||||||
|
# container is what covers a restart that never goes
|
||||||
|
# through this workflow at all.
|
||||||
# 4. app (api + web) the new images.
|
# 4. app (api + web) the new images.
|
||||||
# 5. verify ask the running API what it actually is.
|
# 5. verify ask the running API what it actually is.
|
||||||
|
# 6. prune images reclaim the superseded api/web images. LAST, and
|
||||||
|
# after verify: Docker will not prune an image a
|
||||||
|
# container references, so the running stack is what
|
||||||
|
# protects the release we just shipped.
|
||||||
#
|
#
|
||||||
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
|
# 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
|
# schema stays forward. This is exactly why every schema change must be
|
||||||
@@ -55,10 +65,10 @@
|
|||||||
# uses until somebody saves them there
|
# uses until somebody saves them there
|
||||||
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
|
# These are NOT galactus-specific (no _GALACTUS suffix) — one SES identity
|
||||||
# serves every deployment.
|
# serves every deployment.
|
||||||
# - The runner (which lives on cubex) must be able to reach BOTH
|
# - The runner (which lives on cubex) must be able to reach galactus:9443
|
||||||
# galactus:9443 (Portainer) and galactus:3306 (MySQL, for migrate deploy).
|
# (Portainer). It should also reach galactus:3306 for step 3, but that is
|
||||||
# If it cannot reach 3306, run the migration by hand from a host that can
|
# no longer load-bearing: dispatch with skip_migrate=true and the api
|
||||||
# and dispatch with skip_migrate=true.
|
# container applies the migrations itself at start.
|
||||||
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
# - ONE-TIME, on a database that predates migration history (i.e. one built
|
||||||
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
# with `prisma db push`): baseline it before the first run, or step 3 fails
|
||||||
# with P3005 "database schema is not empty":
|
# with P3005 "database schema is not empty":
|
||||||
@@ -88,7 +98,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
skip_migrate:
|
skip_migrate:
|
||||||
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
description: "Skip the runner-side migrate step (safe: the api container migrates at start)"
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -237,6 +247,10 @@ jobs:
|
|||||||
run: node deploy/scripts/pre-migrate-backup.mjs
|
run: node deploy/scripts/pre-migrate-backup.mjs
|
||||||
|
|
||||||
# --- schema, forward-only ---------------------------------------------
|
# --- schema, forward-only ---------------------------------------------
|
||||||
|
# Belt to the container's braces: this runs while the OLD code is still
|
||||||
|
# serving, which is the order expand/contract is designed around. The
|
||||||
|
# api container repeats it at start for the paths this step cannot
|
||||||
|
# reach (skip_migrate, a host reboot, a stack re-applied by hand).
|
||||||
- name: Apply database migrations
|
- name: Apply database migrations
|
||||||
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
if: ${{ github.event.inputs.skip_migrate != 'true' }}
|
||||||
env:
|
env:
|
||||||
@@ -281,13 +295,20 @@ jobs:
|
|||||||
standalone: true
|
standalone: true
|
||||||
pull: true
|
pull: true
|
||||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
# NOTE: the block below is parsed as JSON — no comments inside it.
|
||||||
|
#
|
||||||
|
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
||||||
|
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
||||||
|
# survives the box moving between the tailnet, the office LAN and a
|
||||||
|
# demo domain. Setting it here would pin it again and re-break an https
|
||||||
|
# front door with mixed active content. APP_API_ORIGIN_GALACTUS lives
|
||||||
|
# on only as the URL the verify step probes.
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
"API_PORT": "3001",
|
"API_PORT": "3001",
|
||||||
"WEB_PORT": "3000",
|
"WEB_PORT": "3000",
|
||||||
"S3_BUCKET": "jorgecuadros-documents",
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN_GALACTUS }}",
|
|
||||||
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN_GALACTUS }}",
|
||||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT_GALACTUS }}",
|
||||||
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
"DATABASE_URL": "${{ secrets.DATABASE_URL_GALACTUS }}",
|
||||||
@@ -322,6 +343,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
apk add --no-cache curl >/dev/null
|
apk add --no-cache curl >/dev/null
|
||||||
|
# These secrets are CORS origin LISTS as far as the app is concerned
|
||||||
|
# (WEB_ORIGIN is comma-separated so one deployment can be reached by
|
||||||
|
# LAN IP, tailnet name and demo domain at once). A list is not a URL,
|
||||||
|
# so probe the FIRST entry — keep the runner-reachable origin first.
|
||||||
|
API_ORIGIN=${API_ORIGIN%%,*}
|
||||||
|
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
||||||
fetch_version() {
|
fetch_version() {
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
@@ -363,3 +390,24 @@ jobs:
|
|||||||
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# --- housekeeping ------------------------------------------------------
|
||||||
|
# Runs LAST, and only after the verify step proved the new containers are
|
||||||
|
# up. See deploy/scripts/prune-images.mjs: Docker refuses to prune an
|
||||||
|
# image a container references, so "the stack is running" is what makes
|
||||||
|
# the current images safe. Pruning earlier would have nothing holding
|
||||||
|
# them.
|
||||||
|
#
|
||||||
|
# continue-on-error: reclaiming disk is not what the deploy is for. A
|
||||||
|
# prune that fails leaves a fat host, not a broken release.
|
||||||
|
- name: Prune unused images
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
|
||||||
|
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
|
||||||
|
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
|
||||||
|
# Grace window. Keeps the previous few releases on disk so a rollback
|
||||||
|
# dispatch is a stack swap instead of a re-pull.
|
||||||
|
KEEP_HOURS: "168"
|
||||||
|
NODE_TLS_REJECT_UNAUTHORIZED: "0"
|
||||||
|
run: node deploy/scripts/prune-images.mjs
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
# git tag v1.2.3 into image tag 1.2.3. Tag v1.2.3, dispatch 1.2.3.
|
||||||
#
|
#
|
||||||
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
# Order: db+minio (full only) -> pre-migrate backup -> prisma migrate deploy ->
|
||||||
|
# (the api container also migrates at start; see docker/api-entrypoint.sh)
|
||||||
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
# app -> verify the API reports the version you asked for. Rollback = dispatch
|
||||||
# an older tag; that rolls back CODE only, never the schema, which is why every
|
# an older tag; that rolls back CODE only, never the schema, which is why every
|
||||||
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
# schema change must be expand/contract. See docs/DEPLOY_AND_MIGRATIONS.md.
|
||||||
@@ -47,9 +48,11 @@
|
|||||||
# # Database stack (full only)
|
# # Database stack (full only)
|
||||||
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
# MYSQL_PASSWORD app-user password (matches DATABASE_URL)
|
||||||
# MYSQL_ROOT_PASSWORD mysql root password
|
# MYSQL_ROOT_PASSWORD mysql root password
|
||||||
# - the runner must reach BOTH Portainer (9443) and MySQL (3306) — the
|
# - the runner must reach Portainer (9443). It should also reach MySQL (3306)
|
||||||
# migration step connects to the database directly. If it cannot reach 3306,
|
# for the migrate step, but that is no longer load-bearing: dispatch with
|
||||||
# migrate by hand and dispatch with skip_migrate=true.
|
# skip_migrate=true and the api container applies the migrations itself at
|
||||||
|
# start (docker/api-entrypoint.sh). `migrate deploy` is idempotent, so the
|
||||||
|
# two never conflict.
|
||||||
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
# - ONE-TIME on a database built with `prisma db push` (i.e. every database
|
||||||
# that exists today): baseline it before the first run, or the migrate step
|
# that exists today): baseline it before the first run, or the migrate step
|
||||||
# fails with P3005 "database schema is not empty":
|
# fails with P3005 "database schema is not empty":
|
||||||
@@ -79,7 +82,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
skip_migrate:
|
skip_migrate:
|
||||||
description: "Skip prisma migrate deploy (use when the runner cannot reach MySQL and you migrated by hand)"
|
description: "Skip the runner-side migrate step (safe: the api container migrates at start)"
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -253,13 +256,19 @@ jobs:
|
|||||||
type: file
|
type: file
|
||||||
pull: true
|
pull: true
|
||||||
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }}
|
||||||
|
# NOTE: the block below is parsed as JSON — no comments inside it.
|
||||||
|
#
|
||||||
|
# API_ORIGIN is deliberately absent. The browser derives the API origin
|
||||||
|
# from the page it loaded (apps/web/src/lib/api.ts), so the deployment
|
||||||
|
# survives the host moving. Setting it here would pin it again and
|
||||||
|
# re-break an https front door with mixed active content. APP_API_ORIGIN
|
||||||
|
# lives on only as the URL the verify step probes.
|
||||||
env_data: |
|
env_data: |
|
||||||
{
|
{
|
||||||
"APP_TAG": "${{ github.event.inputs.tag }}",
|
"APP_TAG": "${{ github.event.inputs.tag }}",
|
||||||
"API_PORT": "3001",
|
"API_PORT": "3001",
|
||||||
"WEB_PORT": "3000",
|
"WEB_PORT": "3000",
|
||||||
"S3_BUCKET": "jorgecuadros-documents",
|
"S3_BUCKET": "jorgecuadros-documents",
|
||||||
"API_ORIGIN": "${{ secrets.APP_API_ORIGIN }}",
|
|
||||||
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
"WEB_ORIGIN": "${{ secrets.APP_WEB_ORIGIN }}",
|
||||||
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
"S3_ENDPOINT": "${{ secrets.APP_S3_ENDPOINT }}",
|
||||||
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
"DATABASE_URL": "${{ secrets.DATABASE_URL }}",
|
||||||
@@ -288,6 +297,12 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
apk add --no-cache curl >/dev/null
|
apk add --no-cache curl >/dev/null
|
||||||
|
# These secrets are CORS origin LISTS as far as the app is concerned
|
||||||
|
# (WEB_ORIGIN is comma-separated so one deployment can be reached under
|
||||||
|
# several origins at once). A list is not a URL, so probe the FIRST
|
||||||
|
# entry — keep the runner-reachable origin first.
|
||||||
|
API_ORIGIN=${API_ORIGIN%%,*}
|
||||||
|
WEB_ORIGIN=${WEB_ORIGIN%%,*}
|
||||||
fetch_version() {
|
fetch_version() {
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
if curl -fsS "$1/version" > "$2"; then return 0; fi
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ NEXT_PUBLIC_API_ORIGIN=http://localhost:3001
|
|||||||
```
|
```
|
||||||
|
|
||||||
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
The API loads `DATABASE_URL`, `SESSION_SECRET`, `WEB_ORIGIN`, and optional
|
||||||
`PORT` (default `3001`). The web app only needs `NEXT_PUBLIC_API_ORIGIN`.
|
`PORT` (default `3001`). `WEB_ORIGIN` is comma-separated — list every origin the
|
||||||
|
app is reached under, or credentialed fetches from the missing ones fail CORS.
|
||||||
|
|
||||||
|
The web app needs no API URL of its own: the browser derives it from the page it
|
||||||
|
loaded (same host on port `3001` over plain HTTP, or the same-origin `/api` path
|
||||||
|
behind a TLS proxy). Set `NEXT_PUBLIC_API_ORIGIN` (dev) or `API_ORIGIN` (deploy,
|
||||||
|
read at request time) only to override that — for instance when running the API
|
||||||
|
on a non-default port.
|
||||||
|
|
||||||
### 3. Start MySQL
|
### 3. Start MySQL
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/api",
|
"name": "@jorgecuadros/api",
|
||||||
"version": "1.0.14",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PoliciesModule } from "./policies/policies.module";
|
|||||||
import { PropertiesModule } from "./properties/properties.module";
|
import { PropertiesModule } from "./properties/properties.module";
|
||||||
import { BillingModule } from "./billing/billing.module";
|
import { BillingModule } from "./billing/billing.module";
|
||||||
import { StatementsModule } from "./statements/statements.module";
|
import { StatementsModule } from "./statements/statements.module";
|
||||||
|
import { CashReceiptsModule } from "./cash-receipts/cash-receipts.module";
|
||||||
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
import { PolicyOcrModule } from "./policy-ocr/policy-ocr.module";
|
||||||
import { BankModule } from "./bank/bank.module";
|
import { BankModule } from "./bank/bank.module";
|
||||||
import { OpsModule } from "./ops/ops.module";
|
import { OpsModule } from "./ops/ops.module";
|
||||||
@@ -35,6 +36,7 @@ import { AppController } from "./app.controller";
|
|||||||
PropertiesModule,
|
PropertiesModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
StatementsModule,
|
StatementsModule,
|
||||||
|
CashReceiptsModule,
|
||||||
PolicyOcrModule,
|
PolicyOcrModule,
|
||||||
BankModule,
|
BankModule,
|
||||||
OpsModule,
|
OpsModule,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type Ability =
|
|||||||
| "customer:create"
|
| "customer:create"
|
||||||
| "customer:update"
|
| "customer:update"
|
||||||
| "customer:delete"
|
| "customer:delete"
|
||||||
|
| "customer:portal-access"
|
||||||
| "policy:create"
|
| "policy:create"
|
||||||
| "policy:update"
|
| "policy:update"
|
||||||
| "policy:delete"
|
| "policy:delete"
|
||||||
@@ -48,6 +49,12 @@ export const ABILITY_MIN: Record<Ability, Role> = {
|
|||||||
"customer:create": "STAFF",
|
"customer:create": "STAFF",
|
||||||
"customer:update": "STAFF",
|
"customer:update": "STAFF",
|
||||||
"customer:delete": "ADMIN",
|
"customer:delete": "ADMIN",
|
||||||
|
// Assigning a portal NUMid is granting someone the ability to log in to
|
||||||
|
// my.jorgecuadros.com and read an account, so it sits above customer:update:
|
||||||
|
// editing a phone number is the day job, handing out portal identity is not.
|
||||||
|
// It is also close to irreversible in practice — the id is what the customer
|
||||||
|
// then types at every login.
|
||||||
|
"customer:portal-access": "MANAGER",
|
||||||
"policy:create": "STAFF",
|
"policy:create": "STAFF",
|
||||||
"policy:update": "STAFF",
|
"policy:update": "STAFF",
|
||||||
"policy:delete": "MANAGER",
|
"policy:delete": "MANAGER",
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import {
|
||||||
|
BALANCE_FLOOR_JOIN,
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
BillingService,
|
||||||
|
NOT_SUPERSEDED,
|
||||||
|
} from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The balance floor drops rows a later BALANCE FORWARD already accounts for.
|
||||||
|
*
|
||||||
|
* It is worth testing because it fails silently: nothing throws, the numbers are
|
||||||
|
* just wrong, and they were wrong for years — the whole book read +20.6M MXN in
|
||||||
|
* credit because every customer's pre-cutover history was counted twice, once
|
||||||
|
* inside their opening balance and once as itself.
|
||||||
|
*/
|
||||||
|
describe("balance floor", () => {
|
||||||
|
describe("SQL fragments", () => {
|
||||||
|
it("binds the type name rather than interpolating it", () => {
|
||||||
|
// A literal would be a second place to edit if the label ever changes,
|
||||||
|
// and this string reaches SQL from a module constant.
|
||||||
|
expect(BALANCE_FLOOR_JOIN.values).toEqual([BALANCE_FORWARD_TYPE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keys the floor to the row's own customer", () => {
|
||||||
|
// Without this the derived table cross-joins and every customer inherits
|
||||||
|
// the earliest BALANCE FORWARD in the book.
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain(
|
||||||
|
"bfloor ON bfloor.customerId = t.customerId",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the most recent opening balance, not the first", () => {
|
||||||
|
// A customer accumulates one BALANCE FORWARD per year. MIN would floor at
|
||||||
|
// the oldest and leave every intervening year double-counted.
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain("MAX(bf.transactionDate)");
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).not.toContain("MIN(bf.transactionDate)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores voided opening balances when locating the floor", () => {
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain("bf.voidedAt IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is inclusive of the opening balance row itself", () => {
|
||||||
|
// `>` instead of `>=` would drop the carried balance and understate every
|
||||||
|
// customer by exactly that amount.
|
||||||
|
expect(NOT_SUPERSEDED.sql).toContain("t.transactionDate >= bfloor.floorDate");
|
||||||
|
expect(NOT_SUPERSEDED.sql).not.toMatch(/transactionDate\s*>\s*bfloor/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves customers with no opening balance untouched", () => {
|
||||||
|
// NULL comparisons are never true, so without the explicit IS NULL branch
|
||||||
|
// a customer who has no BALANCE FORWARD row loses their entire ledger.
|
||||||
|
expect(NOT_SUPERSEDED.sql).toContain("bfloor.floorDate IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only ever references the alias the join defines", () => {
|
||||||
|
// The predicate is useless without the join; pairing them wrongly is a
|
||||||
|
// runtime "unknown column", so keep the alias identical in both.
|
||||||
|
const aliases = NOT_SUPERSEDED.sql.match(/bfloor\.\w+/g) ?? [];
|
||||||
|
expect(aliases.length).toBeGreaterThan(0);
|
||||||
|
for (const ref of aliases) {
|
||||||
|
expect(BALANCE_FLOOR_JOIN.sql).toContain(ref.split(".")[1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("statement()", () => {
|
||||||
|
/**
|
||||||
|
* One customer means one floor date, so the statement uses a scalar lookup
|
||||||
|
* instead of the join. Asserting on the `where` Prisma is handed is the only
|
||||||
|
* way to see it without a database.
|
||||||
|
*/
|
||||||
|
function serviceWith(floor: Date | null) {
|
||||||
|
const findMany = jest.fn().mockResolvedValue([]);
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: "c1",
|
||||||
|
name: "CUADROS, JORGE H.",
|
||||||
|
preferredCurrency: "USD",
|
||||||
|
_count: { properties: 0, policies: 0 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||||
|
findMany,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new BillingService(prisma as never),
|
||||||
|
prisma,
|
||||||
|
findMany,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("looks the floor up from the customer's newest opening balance", async () => {
|
||||||
|
const { service, prisma } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: {
|
||||||
|
customerId: "c1",
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* statement() issues two findMany calls: first the period discovery (which
|
||||||
|
* years this customer has an archive for), then the statement rows. Select
|
||||||
|
* the rows query by its shape so adding another lookup later moves nothing
|
||||||
|
* here — the previous version indexed call 0 and broke the moment period
|
||||||
|
* support landed.
|
||||||
|
*/
|
||||||
|
function rowsQuery(findMany: jest.Mock) {
|
||||||
|
const call = findMany.mock.calls.find((c) => c[0]?.orderBy);
|
||||||
|
if (!call) throw new Error("statement() issued no ordered rows query");
|
||||||
|
return call[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
it("bounds the statement at the floor, inclusive", async () => {
|
||||||
|
const floor = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const { service, findMany } = serviceWith(floor);
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
expect(rowsQuery(findMany).where).toMatchObject({
|
||||||
|
customerId: "c1",
|
||||||
|
transactionDate: { gte: floor },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies no date bound when the customer has no opening balance", async () => {
|
||||||
|
const { service, findMany } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
expect(rowsQuery(findMany).where).not.toHaveProperty("transactionDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the source-table exclusion alongside the floor", async () => {
|
||||||
|
// The three guards answer different questions — one windows imported
|
||||||
|
// periods, one drops the cash receipt book the ledger already posts, the
|
||||||
|
// floor drops superseded history — and dropping any one of them changes
|
||||||
|
// the customer's balance.
|
||||||
|
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.statement("c1");
|
||||||
|
|
||||||
|
const where = rowsQuery(findMany).where;
|
||||||
|
// Imported periods are windowed rather than excluded: history below the
|
||||||
|
// year start (the only carry a floored-by-archive customer has), never
|
||||||
|
// at or above it (those rows sit inside the next BALANCE FORWARD).
|
||||||
|
expect(where.AND).toEqual([
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash journal, qualified by the database it came from — the
|
||||||
|
// insurance line has its own EFECTIVO and that one is a real ledger.
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: "UTILITIES" } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
|
legacySourceTable: {
|
||||||
|
notIn: expect.arrayContaining(["EFECTIVO"]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("regression: NUMid 501", () => {
|
||||||
|
/**
|
||||||
|
* The arithmetic that exposed the bug, pinned so it cannot silently return.
|
||||||
|
* Figures measured against the live ledger on 2026-08-05.
|
||||||
|
*/
|
||||||
|
const openingBalance = new Prisma.Decimal("-6732.29");
|
||||||
|
const activitySinceOpening = new Prisma.Decimal("-7333.00");
|
||||||
|
const preCutoverCashAlreadyInOpening = new Prisma.Decimal("3596.00");
|
||||||
|
|
||||||
|
it("matches the legacy portal once superseded rows are dropped", () => {
|
||||||
|
expect(openingBalance.plus(activitySinceOpening).toFixed(2)).toBe(
|
||||||
|
"-14065.29",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reproduces the wrong figure when they are not", () => {
|
||||||
|
expect(
|
||||||
|
openingBalance
|
||||||
|
.plus(activitySinceOpening)
|
||||||
|
.plus(preCutoverCashAlreadyInOpening)
|
||||||
|
.toFixed(2),
|
||||||
|
).toBe("-10469.29");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -119,10 +119,23 @@ export class BillingController {
|
|||||||
return this.billing.byCheck(n);
|
return this.billing.byCheck(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One customer's full statement across both business lines. */
|
/**
|
||||||
|
* One customer's statement across both business lines, for one period.
|
||||||
|
*
|
||||||
|
* `year` omitted means the current one. Any earlier year is served from its
|
||||||
|
* imported archive; the response carries `availableYears` so the caller can
|
||||||
|
* offer only the periods this customer actually has.
|
||||||
|
*/
|
||||||
@Get("customers/:id")
|
@Get("customers/:id")
|
||||||
statement(@Param("id") id: string) {
|
statement(@Param("id") id: string, @Query("year") year?: string) {
|
||||||
return this.billing.statement(id);
|
let parsed: number | undefined;
|
||||||
|
if (year !== undefined && year !== "") {
|
||||||
|
parsed = Number(year);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
throw new BadRequestException("year debe ser un año de cuatro dígitos");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.billing.statement(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cross-customer movement browser. */
|
/** Cross-customer movement browser. */
|
||||||
|
|||||||
@@ -104,24 +104,31 @@ interface BalanceRow {
|
|||||||
nameMissing: number;
|
nameMissing: number;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
movements: bigint | number | string;
|
movements: RawCount;
|
||||||
balanceMxn: Prisma.Decimal | null;
|
balanceMxn: Prisma.Decimal | null;
|
||||||
balanceUsd: Prisma.Decimal | null;
|
balanceUsd: Prisma.Decimal | null;
|
||||||
chargesMxn: Prisma.Decimal | null;
|
chargesMxn: Prisma.Decimal | null;
|
||||||
creditsMxn: Prisma.Decimal | null;
|
creditsMxn: Prisma.Decimal | null;
|
||||||
chargesUsd: Prisma.Decimal | null;
|
chargesUsd: Prisma.Decimal | null;
|
||||||
creditsUsd: Prisma.Decimal | null;
|
creditsUsd: Prisma.Decimal | null;
|
||||||
utilityMovements: bigint | number | string;
|
utilityMovements: RawCount;
|
||||||
insuranceMovements: bigint | number | string;
|
insuranceMovements: RawCount;
|
||||||
lastMovement: Date | null;
|
lastMovement: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw-query counts come back in three shapes depending on the aggregate:
|
* Every shape a raw-query count can arrive in. `COUNT(*)` is a bigint,
|
||||||
* `COUNT(*)` as bigint, `SUM(bool)` as a decimal *string*, and plain numbers.
|
* `SUM(bool)` is a Prisma.Decimal, and plain numbers occur too — none of which
|
||||||
* Normalize all of them before they reach the client as JSON.
|
* survive JSON serialization the way the client expects.
|
||||||
*/
|
*/
|
||||||
function num(v: bigint | number | string | null | undefined): number {
|
type RawCount = bigint | number | string | Prisma.Decimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a raw-query count before it reaches the client as JSON. A bigint
|
||||||
|
* throws on JSON.stringify and a Decimal serializes to a *string*, so counts
|
||||||
|
* must not be passed through untouched.
|
||||||
|
*/
|
||||||
|
function num(v: RawCount | null | undefined): number {
|
||||||
if (v === null || v === undefined) return 0;
|
if (v === null || v === undefined) return 0;
|
||||||
return typeof v === "number" ? v : Number(v);
|
return typeof v === "number" ? v : Number(v);
|
||||||
}
|
}
|
||||||
@@ -153,18 +160,97 @@ const NOT_VOIDED: Prisma.TransactionWhereInput = { voidedAt: null };
|
|||||||
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
const NOT_OUTSTANDING: Prisma.TransactionWhereInput = { outstanding: false };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source tables excluded from the customer-facing statement.
|
* The legacy type name for a carried-forward opening balance.
|
||||||
*
|
*
|
||||||
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
|
* These rows are not movements. Access materialized one per customer per year,
|
||||||
* (`objects.json:1358`), so the customer's "current balance" never saw
|
* dated Jan 1, holding the closing balance of everything before it — that is
|
||||||
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
|
* what let the portal keep each year in its own table (`datosfreak` = current,
|
||||||
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
|
* `2025`, `2024`, ...) and still show a correct running balance from a single
|
||||||
* the statement must drop them to match the legacy number the customer has
|
* year's rows.
|
||||||
* been quoted for years. The staff-facing balances worklist and movement
|
|
||||||
* browser keep them — they're real money, just tracked separately
|
|
||||||
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
|
|
||||||
*/
|
*/
|
||||||
const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
export const BALANCE_FORWARD_TYPE = "BALANCE FORWARD";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-customer date of the most recent BALANCE FORWARD row.
|
||||||
|
*
|
||||||
|
* Joined rather than correlated: one small derived table (1,170 rows) beats a
|
||||||
|
* subquery evaluated per ledger row.
|
||||||
|
*/
|
||||||
|
export const BALANCE_FLOOR_JOIN = Prisma.sql`
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT bf.customerId, MAX(bf.transactionDate) AS floorDate
|
||||||
|
FROM transactions bf
|
||||||
|
JOIN type_transactions bft ON bft.id = bf.typeId
|
||||||
|
WHERE bft.nameEn = ${BALANCE_FORWARD_TYPE} AND bf.voidedAt IS NULL
|
||||||
|
GROUP BY bf.customerId
|
||||||
|
) bfloor ON bfloor.customerId = t.customerId`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excludes rows a later BALANCE FORWARD already accounts for.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS. The platform holds both the synthetic BALANCE FORWARD rows
|
||||||
|
* and the real pre-cutover history they summarize, so summing a customer's
|
||||||
|
* whole ledger counts that history twice — once inside the opening balance,
|
||||||
|
* once as itself. NUMid 501 read -10,469.29 on the worklist against -14,065.29
|
||||||
|
* on the customer's own statement and on the legacy portal, the gap being two
|
||||||
|
* cash receipts from 2009 and 2012 that the 2026 opening balance had already
|
||||||
|
* absorbed.
|
||||||
|
*
|
||||||
|
* The scale is what settles it: summed the old way the entire book came to
|
||||||
|
* +20,605,447.86 MXN — the office owing its customers 20.6 million pesos.
|
||||||
|
* Floored, it is -56,855.90, a modest net receivable. A receivables ledger
|
||||||
|
* cannot be 20M in credit.
|
||||||
|
*
|
||||||
|
* Applies to BALANCES ONLY, in the same spirit as NOT_OUTSTANDING: the movement
|
||||||
|
* browser still totals every captured row, because "how much water did we
|
||||||
|
* capture in April" is a question about what was recorded, not about what is
|
||||||
|
* owed. Customers with no BALANCE FORWARD row (the floor is NULL) are
|
||||||
|
* unaffected.
|
||||||
|
*/
|
||||||
|
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `legacySourceTable` of an imported prior period.
|
||||||
|
*
|
||||||
|
* A closed year arrives as its own Access snapshot and is tagged rather than
|
||||||
|
* dated (see migration/transform_transactions.py). The tag is what a period
|
||||||
|
* view filters on: the archives are not cleanly date-bounded — 2025 carries
|
||||||
|
* rows dated into 2026 — and legacy did not filter by date either, it selected
|
||||||
|
* `FROM \`2025\``. Filtering on provenance reproduces the legacy period exactly.
|
||||||
|
*/
|
||||||
|
export const periodSourceTable = (year: number) => `datos2@${year}`;
|
||||||
|
|
||||||
|
/** Matches any imported period tag, for discovering which years a customer has. */
|
||||||
|
export const PERIOD_TABLE_PREFIX = "datos2@";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy cash receipt book — a journal, not a ledger.
|
||||||
|
*
|
||||||
|
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
|
||||||
|
* counter, a folio is written, and the same receipt is then *posted* to the
|
||||||
|
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
|
||||||
|
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
|
||||||
|
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
|
||||||
|
* both tables into one `transactions` table, so anything summing a customer's
|
||||||
|
* rows counts every cash receipt twice.
|
||||||
|
*
|
||||||
|
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
|
||||||
|
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
|
||||||
|
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
|
||||||
|
* than a code change. Six receipts post converted to pesos under a mistyped
|
||||||
|
* folio, which is why matching on folio and amount found fewer duplicate pairs
|
||||||
|
* than actually exist — a reason to exclude the whole journal rather than to
|
||||||
|
* exclude a list of confirmed pairs.
|
||||||
|
*
|
||||||
|
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
|
||||||
|
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
|
||||||
|
* nothing posts it anywhere else. Excluding by table name alone erases the
|
||||||
|
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
|
||||||
|
* customers, 99 of whom have no other rows at all.
|
||||||
|
*/
|
||||||
|
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
|
||||||
|
|
||||||
|
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
|
||||||
"EFECTIVO",
|
"EFECTIVO",
|
||||||
"EFECTIVO_BACKUP",
|
"EFECTIVO_BACKUP",
|
||||||
"EFECTIVO FM3",
|
"EFECTIVO FM3",
|
||||||
@@ -172,6 +258,62 @@ const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
|
|||||||
"IVA 2015",
|
"IVA 2015",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma form of the cash-journal exclusion.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`,
|
||||||
|
* and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row
|
||||||
|
* (no `legacySourceTable`) would silently vanish. Same for the database test.
|
||||||
|
*/
|
||||||
|
export const notCashJournal = (): Prisma.TransactionWhereInput => ({
|
||||||
|
OR: [
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */
|
||||||
|
export const NOT_CASH_JOURNAL = Prisma.sql`(
|
||||||
|
t.legacySourceDb IS NULL
|
||||||
|
OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB}
|
||||||
|
OR t.legacySourceTable IS NULL
|
||||||
|
OR t.legacySourceTable NOT IN (${Prisma.join([
|
||||||
|
...CASH_JOURNAL_SOURCE_TABLES,
|
||||||
|
])}))`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
|
*
|
||||||
|
* The balance floor does not settle the archives on its own, in both
|
||||||
|
* directions. A customer whose newest BALANCE FORWARD lives *inside* an archive
|
||||||
|
* floors at that archive's own January 1st, so every row of it clears the floor
|
||||||
|
* — and that is correct, because below the year start the archive is the only
|
||||||
|
* carry there is. At or above the year start it must go: the archives spill a
|
||||||
|
* couple of rows into the following January and those already sit inside the
|
||||||
|
* next year's BALANCE FORWARD, which is the sum of the whole archive.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR for the same NULL reason as above.
|
||||||
|
*/
|
||||||
|
export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
|
||||||
|
t.legacySourceTable IS NULL
|
||||||
|
OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`}
|
||||||
|
OR t.transactionDate < ${yearStart})`;
|
||||||
|
|
||||||
|
/** January 1st of the running year, UTC — the current period's lower bound. */
|
||||||
|
export const currentYearStart = () =>
|
||||||
|
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a balance sums over beyond "not voided": the cash journal out, the
|
||||||
|
* archives kept to history. Shared by `balances()`, `stats()` and the reports,
|
||||||
|
* so a customer's balance reads the same on every screen that quotes one.
|
||||||
|
* Pair it with BALANCE_FLOOR_JOIN + NOT_SUPERSEDED.
|
||||||
|
*/
|
||||||
|
export const balanceScopeSql = () =>
|
||||||
|
Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(currentYearStart())}`;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@@ -341,6 +483,14 @@ export class BillingService {
|
|||||||
async balances(params: BalanceParams) {
|
async balances(params: BalanceParams) {
|
||||||
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
const { query, page, pageSize, currency, balance, domain, sort } = params;
|
||||||
|
|
||||||
|
// A balance is what the customer owes, so it takes the same rules the
|
||||||
|
// statement takes: the floor, the cash journal, and the archive window.
|
||||||
|
// Without the last two the worklist quoted a different number than the
|
||||||
|
// customer's own statement — NUMid 295 read 14,377.46 against a statement
|
||||||
|
// of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in
|
||||||
|
// each case being a cash receipt already posted to the ledger.
|
||||||
|
const scope = balanceScopeSql();
|
||||||
|
|
||||||
const filters: Prisma.Sql[] = [];
|
const filters: Prisma.Sql[] = [];
|
||||||
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
|
||||||
const txFilter = filters.length
|
const txFilter = filters.length
|
||||||
@@ -402,22 +552,24 @@ export class BillingService {
|
|||||||
MAX(t.transactionDate) AS lastMovement
|
MAX(t.transactionDate) AS lastMovement
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
|
||||||
${having}
|
${having}
|
||||||
${orderBy}
|
${orderBy}
|
||||||
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const counted = await this.prisma.$queryRaw<{ total: bigint | number | string }[]>`
|
const counted = await this.prisma.$queryRaw<{ total: RawCount }[]>`
|
||||||
SELECT COUNT(*) AS total FROM (
|
SELECT COUNT(*) AS total FROM (
|
||||||
SELECT c.id
|
SELECT c.id
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
-- Must match the page query's filters exactly, or the total disagrees
|
-- Must match the page query's filters exactly, or the total disagrees
|
||||||
-- with the rows. (The void exclusion was missing here before the
|
-- with the rows. (The void exclusion was missing here before the
|
||||||
-- outstanding work; a voided-only customer inflated the count.)
|
-- outstanding work; a voided-only customer inflated the count.)
|
||||||
WHERE t.voidedAt IS NULL AND t.outstanding = 0 ${nameFilter} ${txFilter}
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
|
||||||
GROUP BY c.id
|
GROUP BY c.id
|
||||||
${having}
|
${having}
|
||||||
) x
|
) x
|
||||||
@@ -458,9 +610,27 @@ export class BillingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-line figures for the billing page header. */
|
/**
|
||||||
|
* Top-line figures for the billing page header.
|
||||||
|
*
|
||||||
|
* Two different questions live here and they use different row sets.
|
||||||
|
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
|
||||||
|
* INVENTORY — what is stored — and count everything not voided. Everything
|
||||||
|
* under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
|
||||||
|
* `balances()` takes — the opening-balance floor, the cash journal and the
|
||||||
|
* archive window — and the book has to agree with the worklist that sits
|
||||||
|
* under it. The four aggregates moved from Prisma groupBy to raw SQL to
|
||||||
|
* express that join; groupBy cannot.
|
||||||
|
*
|
||||||
|
* KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding
|
||||||
|
* rows, while `balances()` does. Reconciling them moves the book by about
|
||||||
|
* 1.95M MXN and turns on whether an unfunded charge is owed by the customer,
|
||||||
|
* which is the client's call and not settled yet.
|
||||||
|
*/
|
||||||
async stats() {
|
async stats() {
|
||||||
const [movements, ledgerCustomers, byCurrency, byDomain] = await Promise.all([
|
const scope = balanceScopeSql();
|
||||||
|
|
||||||
|
const [movements, ledgerCustomers] = await Promise.all([
|
||||||
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
this.prisma.transaction.count({ where: NOT_VOIDED }),
|
||||||
this.prisma.transaction
|
this.prisma.transaction
|
||||||
.findMany({
|
.findMany({
|
||||||
@@ -469,34 +639,47 @@ export class BillingService {
|
|||||||
select: { customerId: true },
|
select: { customerId: true },
|
||||||
})
|
})
|
||||||
.then((r) => r.length),
|
.then((r) => r.length),
|
||||||
this.prisma.transaction.groupBy({
|
|
||||||
by: ["currency"],
|
|
||||||
where: NOT_VOIDED,
|
|
||||||
_sum: { amount: true },
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
this.prisma.transaction.groupBy({
|
|
||||||
by: ["domain", "currency"],
|
|
||||||
where: NOT_VOIDED,
|
|
||||||
_sum: { amount: true },
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const charges = await this.prisma.transaction.groupBy({
|
const byCurrency = await this.prisma.$queryRaw<
|
||||||
by: ["currency"],
|
{
|
||||||
where: { AND: [{ amount: { lt: 0 } }, NOT_VOIDED] },
|
currency: string;
|
||||||
_sum: { amount: true },
|
net: Prisma.Decimal | null;
|
||||||
_count: { _all: true },
|
count: RawCount;
|
||||||
});
|
charges: Prisma.Decimal | null;
|
||||||
const credits = await this.prisma.transaction.groupBy({
|
chargeCount: RawCount;
|
||||||
by: ["currency"],
|
credits: Prisma.Decimal | null;
|
||||||
where: { AND: [{ amount: { gt: 0 } }, NOT_VOIDED] },
|
creditCount: RawCount;
|
||||||
_sum: { amount: true },
|
}[]
|
||||||
_count: { _all: true },
|
>`
|
||||||
});
|
SELECT t.currency AS currency,
|
||||||
const chargeMap = new Map(charges.map((c) => [c.currency, c]));
|
SUM(t.amount) AS net,
|
||||||
const creditMap = new Map(credits.map((c) => [c.currency, c]));
|
COUNT(*) AS count,
|
||||||
|
SUM(CASE WHEN t.amount < 0 THEN t.amount ELSE 0 END) AS charges,
|
||||||
|
SUM(t.amount < 0) AS chargeCount,
|
||||||
|
SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) AS credits,
|
||||||
|
SUM(t.amount > 0) AS creditCount
|
||||||
|
FROM transactions t
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
|
GROUP BY t.currency
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byDomain = await this.prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
domain: string;
|
||||||
|
currency: string;
|
||||||
|
net: Prisma.Decimal | null;
|
||||||
|
count: RawCount;
|
||||||
|
}[]
|
||||||
|
>`
|
||||||
|
SELECT t.domain AS domain, t.currency AS currency,
|
||||||
|
SUM(t.amount) AS net, COUNT(*) AS count
|
||||||
|
FROM transactions t
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
|
GROUP BY t.domain, t.currency
|
||||||
|
`;
|
||||||
|
|
||||||
// How many customers sit on each side of the line, per currency — the
|
// How many customers sit on each side of the line, per currency — the
|
||||||
// headline for a receivables view. Counted in SQL; a customer can be
|
// headline for a receivables view. Counted in SQL; a customer can be
|
||||||
@@ -504,16 +687,19 @@ export class BillingService {
|
|||||||
const sides = await this.prisma.$queryRaw<
|
const sides = await this.prisma.$queryRaw<
|
||||||
{
|
{
|
||||||
currency: string;
|
currency: string;
|
||||||
owing: bigint | number | string;
|
owing: RawCount;
|
||||||
inCredit: bigint | number | string;
|
inCredit: RawCount;
|
||||||
}[]
|
}[]
|
||||||
>`
|
>`
|
||||||
SELECT currency,
|
SELECT currency,
|
||||||
SUM(bal < -0.005) AS owing,
|
SUM(bal < -0.005) AS owing,
|
||||||
SUM(bal > 0.005) AS inCredit
|
SUM(bal > 0.005) AS inCredit
|
||||||
FROM (
|
FROM (
|
||||||
SELECT customerId, currency, SUM(amount) AS bal
|
SELECT t.customerId, t.currency, SUM(t.amount) AS bal
|
||||||
FROM transactions WHERE voidedAt IS NULL GROUP BY customerId, currency
|
FROM transactions t
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
|
||||||
|
GROUP BY t.customerId, t.currency
|
||||||
) x
|
) x
|
||||||
GROUP BY currency
|
GROUP BY currency
|
||||||
`;
|
`;
|
||||||
@@ -534,7 +720,7 @@ export class BillingService {
|
|||||||
|
|
||||||
// Customers whose ledger spans both business lines — the whole reason this
|
// Customers whose ledger spans both business lines — the whole reason this
|
||||||
// module is one view instead of two.
|
// module is one view instead of two.
|
||||||
const crossLine = await this.prisma.$queryRaw<{ n: bigint | number | string }[]>`
|
const crossLine = await this.prisma.$queryRaw<{ n: RawCount }[]>`
|
||||||
SELECT COUNT(*) AS n FROM (
|
SELECT COUNT(*) AS n FROM (
|
||||||
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
SELECT customerId FROM transactions WHERE voidedAt IS NULL
|
||||||
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
GROUP BY customerId HAVING COUNT(DISTINCT domain) > 1
|
||||||
@@ -549,20 +735,20 @@ export class BillingService {
|
|||||||
lastMovement: lastRow?.transactionDate ?? null,
|
lastMovement: lastRow?.transactionDate ?? null,
|
||||||
byCurrency: byCurrency.map((c) => ({
|
byCurrency: byCurrency.map((c) => ({
|
||||||
currency: c.currency,
|
currency: c.currency,
|
||||||
net: c._sum.amount,
|
net: c.net,
|
||||||
count: c._count._all,
|
count: num(c.count),
|
||||||
charges: chargeMap.get(c.currency)?._sum.amount ?? null,
|
charges: c.charges,
|
||||||
chargeCount: chargeMap.get(c.currency)?._count._all ?? 0,
|
chargeCount: num(c.chargeCount),
|
||||||
credits: creditMap.get(c.currency)?._sum.amount ?? null,
|
credits: c.credits,
|
||||||
creditCount: creditMap.get(c.currency)?._count._all ?? 0,
|
creditCount: num(c.creditCount),
|
||||||
owing: num(sideMap.get(c.currency)?.owing),
|
owing: num(sideMap.get(c.currency)?.owing),
|
||||||
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
inCredit: num(sideMap.get(c.currency)?.inCredit),
|
||||||
})),
|
})),
|
||||||
byDomain: byDomain.map((d) => ({
|
byDomain: byDomain.map((d) => ({
|
||||||
domain: d.domain,
|
domain: d.domain,
|
||||||
currency: d.currency,
|
currency: d.currency,
|
||||||
net: d._sum.amount,
|
net: d.net,
|
||||||
count: d._count._all,
|
count: num(d.count),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -589,7 +775,7 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const years = await this.prisma.$queryRaw<
|
const years = await this.prisma.$queryRaw<
|
||||||
{ year: number; count: bigint | number | string }[]
|
{ year: number; count: RawCount }[]
|
||||||
>`
|
>`
|
||||||
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
SELECT YEAR(transactionDate) AS year, COUNT(*) AS count
|
||||||
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
FROM transactions WHERE voidedAt IS NULL GROUP BY year ORDER BY year DESC
|
||||||
@@ -617,13 +803,22 @@ export class BillingService {
|
|||||||
/**
|
/**
|
||||||
* One customer's statement across both business lines.
|
* One customer's statement across both business lines.
|
||||||
*
|
*
|
||||||
* Returns the *whole* ledger rather than a page of it: the heaviest customer
|
* Scoped to one calendar year and listed oldest-first, matching the legacy
|
||||||
|
* EDO CUENTA report the office has printed for years: an opening balance at
|
||||||
|
* the top, then the year's movements in the order they happened.
|
||||||
|
*
|
||||||
|
* `year` selects the period. The current year is read from the live tables;
|
||||||
|
* any earlier year is read from its imported archive, which legacy kept as a
|
||||||
|
* separate table and this reads by its `datos2@YYYY` tag. `availableYears`
|
||||||
|
* reports which periods this customer actually has, so a caller never offers
|
||||||
|
* a year that would render empty.
|
||||||
|
*
|
||||||
|
* Returns the *whole* year rather than a page of it: the heaviest customer
|
||||||
* carries 365 movements (mean 26), and a running balance is meaningless if
|
* carries 365 movements (mean 26), and a running balance is meaningless if
|
||||||
* the client only holds a slice. The running balance is accumulated per
|
* the client only holds a slice. The running balance is accumulated per
|
||||||
* currency in chronological order, then the list is handed back newest-first
|
* currency in chronological order, with each row's balance-after attached.
|
||||||
* with each row's balance-after already attached.
|
|
||||||
*/
|
*/
|
||||||
async statement(customerId: string) {
|
async statement(customerId: string, year?: number) {
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
where: { id: customerId },
|
where: { id: customerId },
|
||||||
select: {
|
select: {
|
||||||
@@ -647,23 +842,116 @@ export class BillingService {
|
|||||||
throw new NotFoundException(`Customer ${customerId} not found`);
|
throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which periods this customer has. The current year is always offered —
|
||||||
|
// it is the live ledger even when empty — and each imported archive adds
|
||||||
|
// the year it holds.
|
||||||
|
const archives = await this.prisma.transaction.findMany({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
legacySourceTable: { startsWith: PERIOD_TABLE_PREFIX },
|
||||||
|
},
|
||||||
|
distinct: ["legacySourceTable"],
|
||||||
|
select: { legacySourceTable: true },
|
||||||
|
});
|
||||||
|
const thisYear = new Date().getUTCFullYear();
|
||||||
|
const archiveYears = archives
|
||||||
|
.map((a) => Number(a.legacySourceTable?.slice(PERIOD_TABLE_PREFIX.length)))
|
||||||
|
.filter((y) => Number.isInteger(y) && y < thisYear);
|
||||||
|
const availableYears = [...new Set([thisYear, ...archiveYears])].sort(
|
||||||
|
(a, b) => b - a,
|
||||||
|
);
|
||||||
|
|
||||||
|
// An unknown year would silently render as the current one, which reads as
|
||||||
|
// "this customer had no activity in 2019" rather than "there is no 2019".
|
||||||
|
const requested = year ?? thisYear;
|
||||||
|
if (!availableYears.includes(requested)) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`El cliente no tiene movimientos del periodo ${requested}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const isArchive = requested !== thisYear;
|
||||||
|
|
||||||
|
//
|
||||||
|
// Deliberately open-ended at the top. A period is a table in legacy, not a
|
||||||
|
// date range, so whatever the office filed in it belongs to it — including
|
||||||
|
// the future-dated rows the current ledger carries (it runs to 2028). An
|
||||||
|
// upper bound would hide them from every view, which is not what legacy did
|
||||||
|
// and not what the office has been reading.
|
||||||
|
//
|
||||||
|
// An archive needs no fold at all: it *is* the period, and its own Jan-1
|
||||||
|
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
|
||||||
|
const yearStart = isArchive
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.UTC(requested, 0, 1));
|
||||||
|
|
||||||
|
// One customer, so the balance floor is a single date rather than the
|
||||||
|
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
|
||||||
|
// the opening balance are already inside it, and showing them would both
|
||||||
|
// double the total and make every balanceAfter below wrong.
|
||||||
|
//
|
||||||
|
// This is also what stops FEE ANUAL and fee15 leaking in. They are not in
|
||||||
|
// STATEMENT_EXCLUDED_SOURCE_TABLES — that list exists to reproduce legacy's
|
||||||
|
// DATOS2-only `datosfreak`, and it was letting 2,092 pre-cutover fee rows
|
||||||
|
// across 1,062 customers through, skewing the statement by -5,129,764
|
||||||
|
// against the number those customers have been quoted for years. Dating
|
||||||
|
// rather than source is the right test: a FEE ANUAL row *after* the opening
|
||||||
|
// balance is a real charge and still counts.
|
||||||
|
const floor = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
const rows = await this.prisma.transaction.findMany({
|
const rows = await this.prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and
|
...(isArchive
|
||||||
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row
|
? // An archive is already exactly one period's ledger, so the tag is
|
||||||
// (which has no legacySourceTable) silently vanished from the
|
// the whole filter. The balance floor is deliberately NOT applied:
|
||||||
// statement while still showing in the movement browser. Rows the app
|
// it exists to stop a later opening balance double-counting the
|
||||||
// books must appear on the customer's statement, so the null case is
|
// history it summarizes, and here that history is the thing being
|
||||||
// spelled out.
|
// asked for. The exclusion list is moot too — an archive holds only
|
||||||
OR: [
|
// DATOS2 rows, which is what legacy's year table held.
|
||||||
{ legacySourceTable: null },
|
{ legacySourceTable: periodSourceTable(requested) }
|
||||||
{
|
: {
|
||||||
legacySourceTable: {
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
|
// An archive row belongs to this period only as history. Below
|
||||||
},
|
// the year start it is exactly what `opening` is for, and for the
|
||||||
},
|
// one customer whose newest BALANCE FORWARD lives *inside* an
|
||||||
],
|
// archive it is the only carry there is — dropping it outright
|
||||||
|
// understated NUMid 295 by his whole 2025 closing balance, 785.46.
|
||||||
|
//
|
||||||
|
// At or above the year start it must go. The archives spill a
|
||||||
|
// couple of rows into the following January, and those are
|
||||||
|
// already inside the next year's BALANCE FORWARD (which is the
|
||||||
|
// sum of the whole archive), so listing them here would both
|
||||||
|
// double-count and file a closed year's row as current.
|
||||||
|
//
|
||||||
|
// Spelled as a positive OR because `NOT (col LIKE ... AND ...)`
|
||||||
|
// is NULL for an app-captured row, which would drop every one.
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{
|
||||||
|
legacySourceTable: {
|
||||||
|
not: { startsWith: PERIOD_TABLE_PREFIX },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash receipt book is the ledger's own postings written a
|
||||||
|
// second time, so listing it here would show every counter
|
||||||
|
// payment twice and double the credit side.
|
||||||
|
notCashJournal(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -683,15 +971,50 @@ export class BillingService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The statement covers one calendar year. The floor above normally lands on
|
||||||
|
// January 1st of it already — the legacy publish writes one BALANCE FORWARD
|
||||||
|
// per customer per year — in which case nothing extra is dropped here. When
|
||||||
|
// it doesn't (a customer the last publish skipped, or one that never had an
|
||||||
|
// opening balance), the earlier rows still have to be *counted* or every
|
||||||
|
// balance below is wrong, so they are folded into `opening` rather than
|
||||||
|
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const movements = rows.map((r) => {
|
/** Balance carried into `yearStart`, per currency. */
|
||||||
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
/** The same carried balance split by business line, keyed `domain|currency`. */
|
||||||
|
const openingByDomain = new Map<
|
||||||
|
string,
|
||||||
|
{ domain: TransactionDomain; currency: string; amount: Prisma.Decimal }
|
||||||
|
>();
|
||||||
|
/** The rows the statement lists — this year's. Totals are built from these. */
|
||||||
|
const visible: typeof rows = [];
|
||||||
|
|
||||||
|
const movements = rows.flatMap((r) => {
|
||||||
const voided = r.voidedAt != null;
|
const voided = r.voidedAt != null;
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||||
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
// Neither a voided row nor an outstanding (unpaid) one moves the running
|
||||||
// balance — both show tagged, with the balance unchanged from the previous
|
// balance — both show tagged, with the balance unchanged from the previous
|
||||||
// live movement. Outstanding rows start counting once resolved.
|
// live movement. Outstanding rows start counting once resolved.
|
||||||
const next = voided || r.outstanding ? prev : prev.plus(r.amount);
|
const counted = !voided && !r.outstanding;
|
||||||
|
const next = counted ? prev.plus(r.amount) : prev;
|
||||||
running.set(r.currency, next);
|
running.set(r.currency, next);
|
||||||
|
|
||||||
|
if (r.transactionDate < yearStart) {
|
||||||
|
if (counted) {
|
||||||
|
opening.set(r.currency, next);
|
||||||
|
const dk = `${r.domain}|${r.currency}`;
|
||||||
|
const od = openingByDomain.get(dk) ?? {
|
||||||
|
domain: r.domain,
|
||||||
|
currency: r.currency,
|
||||||
|
amount: new Prisma.Decimal(0),
|
||||||
|
};
|
||||||
|
od.amount = od.amount.plus(r.amount);
|
||||||
|
openingByDomain.set(dk, od);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.push(r);
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
transactionDate: r.transactionDate,
|
transactionDate: r.transactionDate,
|
||||||
@@ -711,7 +1034,6 @@ export class BillingService {
|
|||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
movements.reverse();
|
|
||||||
|
|
||||||
// Per-currency summary, and the same split by business line so the two
|
// Per-currency summary, and the same split by business line so the two
|
||||||
// ledgers are visibly one statement without being illegally added up.
|
// ledgers are visibly one statement without being illegally added up.
|
||||||
@@ -739,7 +1061,29 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const r of rows) {
|
for (const [currency] of opening) {
|
||||||
|
perCurrency.set(currency, {
|
||||||
|
currency,
|
||||||
|
charges: new Prisma.Decimal(0),
|
||||||
|
credits: new Prisma.Decimal(0),
|
||||||
|
chargeCount: 0,
|
||||||
|
creditCount: 0,
|
||||||
|
count: 0,
|
||||||
|
first: null,
|
||||||
|
last: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const [key, o] of openingByDomain) {
|
||||||
|
perDomain.set(key, {
|
||||||
|
domain: o.domain,
|
||||||
|
currency: o.currency,
|
||||||
|
charges: new Prisma.Decimal(0),
|
||||||
|
credits: new Prisma.Decimal(0),
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of visible) {
|
||||||
// Voided rows never enter a total; outstanding rows don't either until
|
// Voided rows never enter a total; outstanding rows don't either until
|
||||||
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
|
||||||
if (r.voidedAt != null || r.outstanding) continue;
|
if (r.voidedAt != null || r.outstanding) continue;
|
||||||
@@ -789,7 +1133,7 @@ export class BillingService {
|
|||||||
string,
|
string,
|
||||||
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
{ name: string; currency: string; total: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const r of visible) {
|
||||||
if (r.voidedAt != null || r.outstanding) continue;
|
if (r.voidedAt != null || r.outstanding) continue;
|
||||||
if (!r.amount.lessThan(0)) continue;
|
if (!r.amount.lessThan(0)) continue;
|
||||||
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
|
||||||
@@ -808,25 +1152,38 @@ export class BillingService {
|
|||||||
propertyCount: customer._count.properties,
|
propertyCount: customer._count.properties,
|
||||||
policyCount: customer._count.policies,
|
policyCount: customer._count.policies,
|
||||||
},
|
},
|
||||||
summary: [...perCurrency.values()].map((c) => ({
|
year: requested,
|
||||||
currency: c.currency,
|
availableYears,
|
||||||
charges: c.charges.toFixed(2),
|
summary: [...perCurrency.values()].map((c) => {
|
||||||
credits: c.credits.toFixed(2),
|
const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
|
||||||
balance: c.charges.plus(c.credits).toFixed(2),
|
return {
|
||||||
chargeCount: c.chargeCount,
|
currency: c.currency,
|
||||||
creditCount: c.creditCount,
|
/** Balance carried in from before this year — legacy's BALANCE FORWARD. */
|
||||||
count: c.count,
|
opening: open.toFixed(2),
|
||||||
firstMovement: c.first,
|
charges: c.charges.toFixed(2),
|
||||||
lastMovement: c.last,
|
credits: c.credits.toFixed(2),
|
||||||
})),
|
balance: open.plus(c.charges).plus(c.credits).toFixed(2),
|
||||||
byDomain: [...perDomain.values()].map((d) => ({
|
chargeCount: c.chargeCount,
|
||||||
domain: d.domain,
|
creditCount: c.creditCount,
|
||||||
currency: d.currency,
|
count: c.count,
|
||||||
charges: d.charges.toFixed(2),
|
firstMovement: c.first,
|
||||||
credits: d.credits.toFixed(2),
|
lastMovement: c.last,
|
||||||
balance: d.charges.plus(d.credits).toFixed(2),
|
};
|
||||||
count: d.count,
|
}),
|
||||||
})),
|
byDomain: [...perDomain.values()].map((d) => {
|
||||||
|
const open =
|
||||||
|
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
|
||||||
|
new Prisma.Decimal(0);
|
||||||
|
return {
|
||||||
|
domain: d.domain,
|
||||||
|
currency: d.currency,
|
||||||
|
opening: open.toFixed(2),
|
||||||
|
charges: d.charges.toFixed(2),
|
||||||
|
credits: d.credits.toFixed(2),
|
||||||
|
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
|
||||||
|
count: d.count,
|
||||||
|
};
|
||||||
|
}),
|
||||||
byType: [...byType.values()]
|
byType: [...byType.values()]
|
||||||
.map((t) => ({
|
.map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import {
|
||||||
|
CASH_JOURNAL_SOURCE_DB,
|
||||||
|
CASH_JOURNAL_SOURCE_TABLES,
|
||||||
|
NOT_CASH_JOURNAL,
|
||||||
|
notCashJournal,
|
||||||
|
} from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `EFECTIVO` is the office's paper receipt book, and every receipt in it is
|
||||||
|
* also posted to the utilities ledger as `C<folio>`. Both copies were imported
|
||||||
|
* into one `transactions` table, so a balance that reads the journal counts
|
||||||
|
* each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide,
|
||||||
|
* and 3,500.00 of it on NUMid 10 alone.
|
||||||
|
*
|
||||||
|
* These fail silently in the worst way: the numbers stay plausible, they are
|
||||||
|
* just too generous to the customer. Two shapes of mistake are easy to make
|
||||||
|
* here and both are covered below — dropping the database qualifier (which
|
||||||
|
* erases the insurance line's only ledger) and writing the exclusion as a bare
|
||||||
|
* `NOT IN` (which erases every app-captured row).
|
||||||
|
*/
|
||||||
|
describe("cash journal exclusion", () => {
|
||||||
|
describe("raw SQL form", () => {
|
||||||
|
it("binds the source database rather than interpolating it", () => {
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("qualifies the table names with the database they came from", () => {
|
||||||
|
// `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's
|
||||||
|
// ONLY ledger — nothing posts it anywhere else. Matching on the table
|
||||||
|
// name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers.
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>");
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spells both null cases out instead of relying on NOT IN", () => {
|
||||||
|
// `NULL NOT IN (...)` is NULL, not true. Without these branches every
|
||||||
|
// app-captured row — the ones staff key in by hand — drops out of the
|
||||||
|
// balance while still showing in the movement browser.
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL");
|
||||||
|
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the whole cash family, not just EFECTIVO", () => {
|
||||||
|
for (const table of CASH_JOURNAL_SOURCE_TABLES) {
|
||||||
|
expect(NOT_CASH_JOURNAL.values).toContain(table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a single parenthesised term, safe to AND into a WHERE clause", () => {
|
||||||
|
// It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed
|
||||||
|
// OR chain would swallow every condition after it and silently widen the
|
||||||
|
// whole query to the entire table.
|
||||||
|
const sql = NOT_CASH_JOURNAL.sql.trim();
|
||||||
|
expect(sql.startsWith("(")).toBe(true);
|
||||||
|
expect(sql.endsWith(")")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Prisma form", () => {
|
||||||
|
it("matches the raw form's terms so the two cannot drift apart", () => {
|
||||||
|
const branches = notCashJournal().OR as Prisma.TransactionWhereInput[];
|
||||||
|
expect(branches).toEqual([
|
||||||
|
{ legacySourceDb: null },
|
||||||
|
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a fresh object each call", () => {
|
||||||
|
// It is spread into `AND: [...]` arrays that Prisma may mutate; a shared
|
||||||
|
// singleton would leak one query's filters into the next.
|
||||||
|
expect(notCashJournal()).not.toBe(notCashJournal());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { periodSourceTable } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A closed year is imported as its own tagged set of rows rather than being
|
||||||
|
* identified by date. The tag is written by migration/transform_transactions.py
|
||||||
|
* and read by BillingService.statement, the edo-cuenta-datos report, and the
|
||||||
|
* PHP portal — three places that must agree on the exact string.
|
||||||
|
*/
|
||||||
|
describe("periodSourceTable", () => {
|
||||||
|
it("names the archive the migration writes", () => {
|
||||||
|
expect(periodSourceTable(2025)).toBe("datos2@2025");
|
||||||
|
expect(periodSourceTable(2024)).toBe("datos2@2024");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays distinct from the live ledger's own table", () => {
|
||||||
|
// The live table is plain `datos2`. legacyId is a positional ordinal that
|
||||||
|
// restarts at 0 in every archive, so a shared name would collide with the
|
||||||
|
// current year row-for-row on the unique key.
|
||||||
|
expect(periodSourceTable(2025)).not.toBe("datos2");
|
||||||
|
expect(periodSourceTable(2025).startsWith("datos2@")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not matched by the statement's cash-source exclusion list", () => {
|
||||||
|
// STATEMENT_EXCLUDED_SOURCE_TABLES drops the EFECTIVO family to reproduce
|
||||||
|
// legacy's DATOS2-only datosfreak. An archive holds DATOS2 rows, so it must
|
||||||
|
// survive that filter or a prior year renders empty.
|
||||||
|
const excluded = [
|
||||||
|
"EFECTIVO",
|
||||||
|
"EFECTIVO_BACKUP",
|
||||||
|
"EFECTIVO FM3",
|
||||||
|
"CHEQUE FM3",
|
||||||
|
"IVA 2015",
|
||||||
|
];
|
||||||
|
expect(excluded).not.toContain(periodSourceTable(2025));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The statement is a *year* statement, like the EDO CUENTA report the office
|
||||||
|
* prints: this year's movements, oldest-first, opening on the balance carried
|
||||||
|
* in from before it.
|
||||||
|
*
|
||||||
|
* The carrying is the part worth testing. Dropping earlier rows from the list
|
||||||
|
* is easy; dropping them from the arithmetic too would restart every balance at
|
||||||
|
* zero on January 1st, and nothing would throw — the numbers would just be
|
||||||
|
* wrong, which is exactly how the double-counting bug lived for years.
|
||||||
|
*/
|
||||||
|
describe("statement year scoping", () => {
|
||||||
|
const YEAR = new Date().getUTCFullYear();
|
||||||
|
|
||||||
|
function d(iso: string) {
|
||||||
|
return new Date(`${iso}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowSpec = {
|
||||||
|
id: string;
|
||||||
|
date: Date;
|
||||||
|
amount: string;
|
||||||
|
currency?: string;
|
||||||
|
domain?: string;
|
||||||
|
voidedAt?: Date | null;
|
||||||
|
outstanding?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function row(r: RowSpec) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
transactionDate: r.date,
|
||||||
|
domain: r.domain ?? "UTILITY",
|
||||||
|
amount: new Prisma.Decimal(r.amount),
|
||||||
|
currency: r.currency ?? "MXN",
|
||||||
|
reference: null,
|
||||||
|
period: null,
|
||||||
|
checkNumber: null,
|
||||||
|
message: null,
|
||||||
|
legacySourceTable: null,
|
||||||
|
voidedAt: r.voidedAt ?? null,
|
||||||
|
outstanding: r.outstanding ?? false,
|
||||||
|
type: { nameEn: "WATER", nameEs: "AGUA" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No BALANCE FORWARD row, so the floor is null and every row is fetched. */
|
||||||
|
function serviceWith(rows: RowSpec[]) {
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: "c1",
|
||||||
|
name: "CUADROS, JORGE H.",
|
||||||
|
preferredCurrency: "MXN",
|
||||||
|
_count: { properties: 0, policies: 0 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
|
findMany: jest.fn().mockResolvedValue(rows.map(row)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return new BillingService(prisma as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("lists the year's movements oldest-first", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||||
|
{ id: "b", date: d(`${YEAR}-03-04`), amount: "250" },
|
||||||
|
{ id: "c", date: d(`${YEAR}-07-16`), amount: "-40" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
expect(s.movements.map((m) => m.id)).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves earlier years off the list", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-11-30`), amount: "-500" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-100" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
expect(s.movements.map((m) => m.id)).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the earlier years' balance instead of discarding it", async () => {
|
||||||
|
// 1,000 credit left over from last year, 300 charged this year: the
|
||||||
|
// customer is 700 in credit, not 300 in debt.
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||||
|
expect(mxn?.opening).toBe("1000.00");
|
||||||
|
expect(mxn?.charges).toBe("-300.00");
|
||||||
|
expect(mxn?.balance).toBe("700.00");
|
||||||
|
// The running balance on the listed row picks up where last year left off.
|
||||||
|
expect(s.movements[0].balanceAfter).toBe("700.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries it per business line as well", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", domain: "INSURANCE" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300", domain: "INSURANCE" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const line = s.byDomain.find((x) => x.domain === "INSURANCE");
|
||||||
|
expect(line?.opening).toBe("1000.00");
|
||||||
|
expect(line?.balance).toBe("700.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still reports a currency that only moved in earlier years", async () => {
|
||||||
|
// Otherwise a customer sitting on a dollar credit they haven't touched all
|
||||||
|
// year would appear to have no dollar balance at all.
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 2}-05-01`), amount: "180.83", currency: "USD" },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const usd = s.summary.find((x) => x.currency === "USD");
|
||||||
|
expect(usd?.balance).toBe("180.83");
|
||||||
|
expect(usd?.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not carry a voided earlier row", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", voidedAt: d(`${YEAR - 1}-12-16`) },
|
||||||
|
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
const mxn = s.summary.find((x) => x.currency === "MXN");
|
||||||
|
expect(mxn?.opening).toBe("0.00");
|
||||||
|
expect(mxn?.balance).toBe("-300.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the year it covers", async () => {
|
||||||
|
const s = await serviceWith([
|
||||||
|
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
|
||||||
|
]).statement("c1");
|
||||||
|
|
||||||
|
expect(s.year).toBe(YEAR);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Currency } from "@jorgecuadros/database";
|
||||||
|
|
||||||
|
/** The legacy EFE form: cliente, fecha, monto + moneda, concepto, depósito. */
|
||||||
|
export class CreateCashReceiptDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
/** Defaults to the next folio in the book. */
|
||||||
|
@IsOptional() @IsInt() @Min(1) folio?: number;
|
||||||
|
@IsString() @MinLength(1) receivedAt!: string;
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
@IsEnum(Currency) currency!: Currency;
|
||||||
|
/** Dollars credited to a pesos ledger: the rate they were taken at. */
|
||||||
|
@IsOptional() @IsNumber() exchangeRate?: number;
|
||||||
|
@IsString() @MinLength(1) concept!: string;
|
||||||
|
@IsOptional() @IsString() depositedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetDepositDto {
|
||||||
|
/** Empty clears it. */
|
||||||
|
@IsOptional() @IsString() depositedAt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import type { Request } 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 { CashReceiptsService } from "./cash-receipts.service";
|
||||||
|
import { CreateCashReceiptDto, SetDepositDto } from "./cash-receipt.dto";
|
||||||
|
|
||||||
|
/** The EFE cash receipt book. Literal routes sit above `:folio`. */
|
||||||
|
@Controller("cash-receipts")
|
||||||
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
|
export class CashReceiptsController {
|
||||||
|
constructor(
|
||||||
|
private readonly receipts: CashReceiptsService,
|
||||||
|
private readonly audit: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private actingId(req: Request): string {
|
||||||
|
return (req.user as { id: string }).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("next-folio")
|
||||||
|
async nextFolio() {
|
||||||
|
return { folio: await this.receipts.nextFolio() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("page") page?: string,
|
||||||
|
@Query("pageSize") pageSize?: string,
|
||||||
|
) {
|
||||||
|
return this.receipts.list(
|
||||||
|
query,
|
||||||
|
Math.max(1, Number(page) || 1),
|
||||||
|
Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":folio")
|
||||||
|
byFolio(@Param("folio", ParseIntPipe) folio: number) {
|
||||||
|
return this.receipts.byFolio(folio);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async create(@Body() dto: CreateCashReceiptDto, @Req() req: Request) {
|
||||||
|
const r = await this.receipts.create(dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "cash-receipt.create", {
|
||||||
|
folio: r.folio,
|
||||||
|
customerId: r.customerId,
|
||||||
|
amount: r.amount.toFixed(2),
|
||||||
|
currency: r.currency,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id/deposit")
|
||||||
|
@RequireAbility("ledger:create")
|
||||||
|
async setDeposit(@Param("id") id: string, @Body() dto: SetDepositDto, @Req() req: Request) {
|
||||||
|
const r = await this.receipts.setDeposited(id, dto.depositedAt);
|
||||||
|
void this.audit.log(this.actingId(req), "cash-receipt.deposit", {
|
||||||
|
folio: r.folio,
|
||||||
|
depositedAt: r.depositedAt,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { CashReceiptsController } from "./cash-receipts.controller";
|
||||||
|
import { CashReceiptsService } from "./cash-receipts.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CashReceiptsController],
|
||||||
|
providers: [CashReceiptsService],
|
||||||
|
// Customer onboarding issues the opening cash receipt in its own transaction.
|
||||||
|
exports: [CashReceiptsService],
|
||||||
|
})
|
||||||
|
export class CashReceiptsModule {}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CASH_JOURNAL_SOURCE_DB } from "../billing/billing.service";
|
||||||
|
import { getCompanyInfo } from "../reports/company";
|
||||||
|
import type { CreateCashReceiptDto } from "./cash-receipt.dto";
|
||||||
|
|
||||||
|
/** The ledger concept legacy posted every cash receipt under. */
|
||||||
|
const CASH_CONCEPT = "CASH DEPOSIT";
|
||||||
|
|
||||||
|
const RECEIPT_INCLUDE = {
|
||||||
|
customer: { select: { id: true, name: true, folderNumber: true } },
|
||||||
|
preparedBy: { select: { name: true } },
|
||||||
|
transaction: { select: { id: true, voidedAt: true, amount: true, currency: true } },
|
||||||
|
} satisfies Prisma.CashReceiptInclude;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy grouped a day's cash under one pseudo check: CASH + MMDD + P(esos) or
|
||||||
|
* D(ólares) — `CASH0714P`. Kept so by-check views put these rows where the
|
||||||
|
* office expects them.
|
||||||
|
*/
|
||||||
|
export function cashCheckNumber(receivedAt: Date, currency: "MXN" | "USD"): string {
|
||||||
|
const mm = String(receivedAt.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(receivedAt.getUTCDate()).padStart(2, "0");
|
||||||
|
return `CASH${mm}${dd}${currency === "USD" ? "D" : "P"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDay(v: string | undefined, field: string): Date | undefined {
|
||||||
|
if (!v) return undefined;
|
||||||
|
const d = new Date(v.length === 10 ? `${v}T00:00:00.000Z` : v);
|
||||||
|
if (isNaN(d.getTime())) throw new BadRequestException(`${field} inválida.`);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CashReceiptsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One past the highest folio in the book — the platform's own receipts or,
|
||||||
|
* before the first one, the legacy EFECTIVO journal imported at cutover.
|
||||||
|
* Only the utilities book: insurance kept its own EFECTIVO with its own
|
||||||
|
* numbers.
|
||||||
|
*/
|
||||||
|
async nextFolio(db: Prisma.TransactionClient = this.prisma): Promise<number> {
|
||||||
|
const native = await db.cashReceipt.aggregate({ _max: { folio: true } });
|
||||||
|
const [legacy] = await db.$queryRaw<{ mx: bigint | number | null }[]>`
|
||||||
|
SELECT MAX(CAST(reference AS UNSIGNED)) AS mx FROM transactions
|
||||||
|
WHERE legacySourceDb = ${CASH_JOURNAL_SOURCE_DB}
|
||||||
|
AND legacySourceTable = 'EFECTIVO'
|
||||||
|
AND reference REGEXP '^[0-9]+$'`;
|
||||||
|
return Math.max(native._max.folio ?? 0, Number(legacy?.mx ?? 0)) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a receipt and post its credit, in one database transaction: a
|
||||||
|
* receipt without its posting is money the customer paid and never sees,
|
||||||
|
* and a posting without its receipt is the folio-13536 kind of hole.
|
||||||
|
*
|
||||||
|
* Pass `db` to run inside a caller's transaction (customer onboarding).
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateCashReceiptDto,
|
||||||
|
preparedById: string,
|
||||||
|
db?: Prisma.TransactionClient,
|
||||||
|
) {
|
||||||
|
if (!(dto.amount > 0)) throw new BadRequestException("El monto debe ser mayor a cero.");
|
||||||
|
if (dto.exchangeRate != null) {
|
||||||
|
if (dto.currency !== "USD") {
|
||||||
|
throw new BadRequestException("El tipo de cambio sólo aplica a dólares.");
|
||||||
|
}
|
||||||
|
if (!(dto.exchangeRate > 0)) throw new BadRequestException("Tipo de cambio inválido.");
|
||||||
|
}
|
||||||
|
const receivedAt = parseDay(dto.receivedAt, "Fecha")!;
|
||||||
|
const depositedAt = parseDay(dto.depositedAt, "Fecha de depósito");
|
||||||
|
|
||||||
|
const run = (tx: Prisma.TransactionClient) => this.issue(tx, dto, receivedAt, depositedAt, preparedById);
|
||||||
|
if (db) return run(db);
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(run);
|
||||||
|
} catch (err) {
|
||||||
|
// Two receipts issued in the same instant both computed the same next
|
||||||
|
// folio; the loser simply takes the one after.
|
||||||
|
if (dto.folio == null && isUniqueViolation(err)) return this.prisma.$transaction(run);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issue(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
dto: CreateCashReceiptDto,
|
||||||
|
receivedAt: Date,
|
||||||
|
depositedAt: Date | undefined,
|
||||||
|
preparedById: string,
|
||||||
|
) {
|
||||||
|
const customer = await tx.customer.findUnique({
|
||||||
|
where: { id: dto.customerId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException("Cliente no encontrado.");
|
||||||
|
|
||||||
|
const folio = dto.folio ?? (await this.nextFolio(tx));
|
||||||
|
// A typed folio may belong to the legacy book, which has no unique key here.
|
||||||
|
const taken =
|
||||||
|
(await tx.cashReceipt.count({ where: { folio } })) +
|
||||||
|
(await tx.transaction.count({
|
||||||
|
where: {
|
||||||
|
legacySourceDb: CASH_JOURNAL_SOURCE_DB,
|
||||||
|
legacySourceTable: "EFECTIVO",
|
||||||
|
reference: String(folio),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (taken) throw new ConflictException(`El folio ${folio} ya existe.`);
|
||||||
|
|
||||||
|
const type = await tx.typeTransaction.findFirst({
|
||||||
|
where: { nameEn: CASH_CONCEPT },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dollars taken against a pesos account are credited in pesos at the
|
||||||
|
// rate given, as legacy did (C13830: 1,500 USD posted as 25,200 MXN).
|
||||||
|
const converted = dto.exchangeRate != null;
|
||||||
|
const amount = new Prisma.Decimal(dto.amount.toFixed(2));
|
||||||
|
const posted = converted
|
||||||
|
? amount.times(dto.exchangeRate!).toDecimalPlaces(2)
|
||||||
|
: amount;
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const transaction = await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
customerId: dto.customerId,
|
||||||
|
domain: "UTILITY",
|
||||||
|
typeId: type?.id,
|
||||||
|
transactionDate: receivedAt,
|
||||||
|
reference: `C${folio}`,
|
||||||
|
amount: posted,
|
||||||
|
currency: converted ? "MXN" : dto.currency,
|
||||||
|
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
|
||||||
|
checkNumber: cashCheckNumber(receivedAt, dto.currency),
|
||||||
|
message: dto.concept,
|
||||||
|
captureSource: "MANUAL",
|
||||||
|
captureRef: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tx.cashReceipt.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
folio,
|
||||||
|
customerId: dto.customerId,
|
||||||
|
receivedAt,
|
||||||
|
amount,
|
||||||
|
currency: dto.currency,
|
||||||
|
exchangeRate: converted ? new Prisma.Decimal(dto.exchangeRate!) : undefined,
|
||||||
|
concept: dto.concept,
|
||||||
|
preparedById,
|
||||||
|
depositedAt,
|
||||||
|
transactionId: transaction.id,
|
||||||
|
},
|
||||||
|
include: RECEIPT_INCLUDE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(query: string | undefined, page: number, pageSize: number) {
|
||||||
|
const q = query?.trim();
|
||||||
|
const where: Prisma.CashReceiptWhereInput = q
|
||||||
|
? /^\d+$/.test(q)
|
||||||
|
? { OR: [{ folio: Number(q) }, { customer: { folderNumber: Number(q) } }] }
|
||||||
|
: { customer: { name: { contains: q } } }
|
||||||
|
: {};
|
||||||
|
const [total, items] = await this.prisma.$transaction([
|
||||||
|
this.prisma.cashReceipt.count({ where }),
|
||||||
|
this.prisma.cashReceipt.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { folio: "desc" },
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
include: RECEIPT_INCLUDE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One receipt, with what the printout needs to head it. */
|
||||||
|
async byFolio(folio: number) {
|
||||||
|
const receipt = await this.prisma.cashReceipt.findUnique({
|
||||||
|
where: { folio },
|
||||||
|
include: RECEIPT_INCLUDE,
|
||||||
|
});
|
||||||
|
if (!receipt) throw new NotFoundException(`No existe el folio ${folio}.`);
|
||||||
|
const { logo: _logo, logoPath: _path, ...company } = getCompanyInfo();
|
||||||
|
return { ...receipt, company };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDeposited(id: string, depositedAt: string | undefined) {
|
||||||
|
const found = await this.prisma.cashReceipt.findUnique({ where: { id }, select: { id: true } });
|
||||||
|
if (!found) throw new NotFoundException("Recibo no encontrado.");
|
||||||
|
return this.prisma.cashReceipt.update({
|
||||||
|
where: { id },
|
||||||
|
data: { depositedAt: parseDay(depositedAt, "Fecha de depósito") ?? null },
|
||||||
|
include: RECEIPT_INCLUDE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(err: unknown): boolean {
|
||||||
|
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002";
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ConflictException } from "@nestjs/common";
|
||||||
|
import { CashReceiptsService, cashCheckNumber } from "./cash-receipts.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cash receipt is two writes that must agree: the receipt and its C<folio>
|
||||||
|
* credit. Checked here: the credit legacy would have posted, and that a folio
|
||||||
|
* the legacy book already used is refused. Folio numbering itself runs against
|
||||||
|
* real data (it reads the imported EFECTIVO journal).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function build(opts: { legacyTaken?: boolean } = {}) {
|
||||||
|
const posted: Record<string, unknown>[] = [];
|
||||||
|
const tx = {
|
||||||
|
customer: { findUnique: jest.fn().mockResolvedValue({ id: "c1" }) },
|
||||||
|
cashReceipt: {
|
||||||
|
aggregate: jest.fn().mockResolvedValue({ _max: { folio: 13934 } }),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
create: jest.fn(({ data }) => Promise.resolve(data)),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
count: jest.fn().mockResolvedValue(opts.legacyTaken ? 1 : 0),
|
||||||
|
create: jest.fn(({ data }) => {
|
||||||
|
posted.push(data);
|
||||||
|
return Promise.resolve({ id: "tx1", ...data });
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "cash-deposit" }) },
|
||||||
|
$queryRaw: jest.fn().mockResolvedValue([{ mx: 13900 }]),
|
||||||
|
};
|
||||||
|
const prisma = { $transaction: jest.fn((fn) => fn(tx)) };
|
||||||
|
return { service: new CashReceiptsService(prisma as never), posted };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
customerId: "c1",
|
||||||
|
receivedAt: "2026-07-14",
|
||||||
|
concept: "RECEIVED $1500 DLLS FOR ACCOUNT.",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("cash receipts", () => {
|
||||||
|
it("takes the next folio and posts a C<folio> credit", async () => {
|
||||||
|
const { service, posted } = build();
|
||||||
|
const r = await service.create({ ...base, amount: 9000, currency: "MXN" }, "u1");
|
||||||
|
|
||||||
|
expect(r.folio).toBe(13935);
|
||||||
|
expect(posted[0]).toMatchObject({
|
||||||
|
reference: "C13935",
|
||||||
|
checkNumber: "CASH0714P",
|
||||||
|
currency: "MXN",
|
||||||
|
typeId: "cash-deposit",
|
||||||
|
});
|
||||||
|
expect(Number(posted[0].amount)).toBe(9000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("credits dollars to a pesos ledger at the given rate, as legacy did", async () => {
|
||||||
|
// Legacy C13830: 1,500 USD at 16.80 posted as 25,200 MXN.
|
||||||
|
const { service, posted } = build();
|
||||||
|
await service.create(
|
||||||
|
{ ...base, amount: 1500, currency: "USD", exchangeRate: 16.8 },
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(posted[0]).toMatchObject({ currency: "MXN", checkNumber: "CASH0714D" });
|
||||||
|
expect(Number(posted[0].amount)).toBe(25200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a typed folio the legacy book already used", async () => {
|
||||||
|
const { service, posted } = build({ legacyTaken: true });
|
||||||
|
await expect(
|
||||||
|
service.create({ ...base, folio: 13800, amount: 1, currency: "MXN" }, "u1"),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(posted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the pseudo check by day and currency", () => {
|
||||||
|
expect(cashCheckNumber(new Date("2026-01-05T00:00:00Z"), "USD")).toBe("CASH0105D");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Currency } from "@jorgecuadros/database";
|
import { Currency } from "@jorgecuadros/database";
|
||||||
@@ -19,6 +21,8 @@ export class CreateCustomerDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
/** Shelf folder number; null clears it. Unique among non-archived customers. */
|
||||||
|
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
|
||||||
@IsOptional() @IsString() addressLine1?: string;
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
@IsOptional() @IsString() addressLine2?: string;
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
@IsOptional() @IsString() city?: string;
|
@IsOptional() @IsString() city?: string;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ConflictException } from "@nestjs/common";
|
||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folder number belongs to one open account at a time, and an archived
|
||||||
|
* customer's number is history, not a claim. The nextFolderNumber SQL is
|
||||||
|
* checked against real data, not here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function build(opts: { holder?: { name: string } | null; archived?: boolean; folder?: number | null }) {
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(opts.holder ?? null),
|
||||||
|
findUnique: jest.fn().mockResolvedValue({
|
||||||
|
id: "c1",
|
||||||
|
archivedAt: opts.archived ? new Date() : null,
|
||||||
|
folderNumber: opts.folder ?? null,
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
||||||
|
update: jest.fn().mockImplementation(({ data }) => Promise.resolve(data)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { service: new CustomersService(prisma as never, {} as never), prisma };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("customer folder numbers", () => {
|
||||||
|
it("refuses a number an open account already holds", async () => {
|
||||||
|
const { service, prisma } = build({ holder: { name: "SWEET, DONALD E." } });
|
||||||
|
|
||||||
|
await expect(service.create({ name: "Nuevo", folderNumber: 214 })).rejects.toThrow(
|
||||||
|
new ConflictException("La carpeta 214 ya la tiene SWEET, DONALD E."),
|
||||||
|
);
|
||||||
|
expect(prisma.customer.create).not.toHaveBeenCalled();
|
||||||
|
// Only open accounts count as holders.
|
||||||
|
expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({
|
||||||
|
folderNumber: 214,
|
||||||
|
archivedAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not count the customer being edited as the holder", async () => {
|
||||||
|
const { service, prisma } = build({});
|
||||||
|
|
||||||
|
await service.update("c1", { folderNumber: 214 });
|
||||||
|
expect(prisma.customer.findFirst.mock.calls[0][0].where).toMatchObject({
|
||||||
|
id: { not: "c1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an archived customer keep a number someone else now holds", async () => {
|
||||||
|
const { service, prisma } = build({ archived: true, holder: { name: "X" } });
|
||||||
|
|
||||||
|
await expect(service.update("c1", { folderNumber: 214 })).resolves.toBeDefined();
|
||||||
|
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not reopen an account whose folder was handed out meanwhile", async () => {
|
||||||
|
const { service, prisma } = build({ archived: true, folder: 214, holder: { name: "X" } });
|
||||||
|
|
||||||
|
await expect(service.restore("c1")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(prisma.customer.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the check when no number is given", async () => {
|
||||||
|
const { service, prisma } = build({ holder: { name: "X" } });
|
||||||
|
|
||||||
|
await service.create({ name: "Nuevo" });
|
||||||
|
await service.update("c1", { folderNumber: null });
|
||||||
|
expect(prisma.customer.findFirst).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The /clientes/:id ledger card is titled "Estado de cuenta" and links straight
|
||||||
|
* to the statement, so its per-line totals must be the statement's numbers.
|
||||||
|
*
|
||||||
|
* They were a raw lifetime sum — no floor, no source exclusion — which
|
||||||
|
* double-counted the pre-cutover history each BALANCE FORWARD row absorbs.
|
||||||
|
* Importing prior periods made it visibly worse: every closed year is now held
|
||||||
|
* a second time as its own tagged copy, so an unfloored sum adds each one on
|
||||||
|
* top of the opening balance that already contains it.
|
||||||
|
*/
|
||||||
|
describe("customer file ledger card", () => {
|
||||||
|
function serviceWith(floor: Date | null) {
|
||||||
|
const groupBy = jest.fn().mockResolvedValue([]);
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: "c1", transactions: [] }),
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
findFirst: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(floor ? { transactionDate: floor } : null),
|
||||||
|
groupBy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
service: new CustomersService(prisma as never, {} as never),
|
||||||
|
prisma,
|
||||||
|
groupBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("takes the same balance floor the statement takes", async () => {
|
||||||
|
const floor = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const { service, prisma, groupBy } = serviceWith(floor);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(prisma.transaction.findFirst).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.objectContaining({
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||||
|
transactionDate: { gte: floor },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies no floor when the customer never had an opening balance", async () => {
|
||||||
|
// 102 customers have no BALANCE FORWARD row at all. Inventing a floor for
|
||||||
|
// them would hide their whole ledger.
|
||||||
|
const { service, groupBy } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an archive as history but never as current", async () => {
|
||||||
|
// The floor alone is not enough: a customer floored by an archive clears
|
||||||
|
// it with every row of that archive, and the rows archives spill into the
|
||||||
|
// following January clear any floor. But excluding archives outright is
|
||||||
|
// wrong too — below the year start they are the only carry a
|
||||||
|
// floored-by-archive customer has (NUMid 295, 785.46).
|
||||||
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
|
const rule = and.find((c: { OR?: unknown[] }) =>
|
||||||
|
JSON.stringify(c).includes("datos2@"),
|
||||||
|
);
|
||||||
|
expect(rule.OR).toEqual([
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the cash-source exclusion so it reads like the statement", async () => {
|
||||||
|
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const and = groupBy.mock.calls[0][0].where.AND;
|
||||||
|
const sourceRule = and.find((c: { OR?: unknown[] }) =>
|
||||||
|
JSON.stringify(c).includes("EFECTIVO"),
|
||||||
|
);
|
||||||
|
expect(sourceRule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops outstanding rows, as every balance does", async () => {
|
||||||
|
const { service, groupBy } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
expect(groupBy.mock.calls[0][0].where).toMatchObject({
|
||||||
|
outstanding: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps archives out of the year's movement list too", async () => {
|
||||||
|
// datos2@2024 carries rows dated into 2026; a date test alone would show
|
||||||
|
// them as current-year movements next to the live ledger's own copy.
|
||||||
|
const { service, prisma } = serviceWith(null);
|
||||||
|
|
||||||
|
await service.detail("c1");
|
||||||
|
|
||||||
|
const include = prisma.customer.findUnique.mock.calls[0][0].include;
|
||||||
|
expect(include.transactions.where.OR).toEqual([
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
// Nothing below yearStart reaches this list, so the third branch never
|
||||||
|
// admits an archive row here — it is carried for one shared rule.
|
||||||
|
{ transactionDate: { lt: expect.any(Date) } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { CustomersService } from "./customers.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding writes four tables in one transaction; what is checked here is
|
||||||
|
* that a request it can't complete is refused before anything is written, and
|
||||||
|
* that a cash opening balance goes through the receipt book inside that same
|
||||||
|
* transaction. The rollback itself was exercised against the dev database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const tx = {
|
||||||
|
customer: { create: jest.fn().mockResolvedValue({ id: "c1" }) },
|
||||||
|
property: { create: jest.fn().mockResolvedValue({ id: "p1" }) },
|
||||||
|
propertyService: { createMany: jest.fn() },
|
||||||
|
typeTransaction: { findFirst: jest.fn() },
|
||||||
|
transaction: { create: jest.fn() },
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
customer: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||||
|
$transaction: jest.fn((fn) => fn(tx)),
|
||||||
|
};
|
||||||
|
const cash = { create: jest.fn().mockResolvedValue({ folio: 13935 }) };
|
||||||
|
return { service: new CustomersService(prisma as never, cash as never), prisma, tx, cash };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pay = { amount: 500, currency: "MXN" as const, receivedAt: "2026-10-12" };
|
||||||
|
|
||||||
|
describe("customer onboarding", () => {
|
||||||
|
it("refuses a check opening balance with no check number, writing nothing", async () => {
|
||||||
|
const { service, prisma } = build();
|
||||||
|
await expect(
|
||||||
|
service.onboard({ customer: { name: "X" }, openingPayment: { ...pay, method: "CHECK" } }, "u1"),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses services with no property to hang them on", async () => {
|
||||||
|
const { service, prisma } = build();
|
||||||
|
await expect(
|
||||||
|
service.onboard({ customer: { name: "X" }, services: [{ kind: "WATER" }] }, "u1"),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("issues the cash receipt inside the onboarding transaction", async () => {
|
||||||
|
const { service, tx, cash } = build();
|
||||||
|
const r = await service.onboard(
|
||||||
|
{
|
||||||
|
customer: { name: "X" },
|
||||||
|
property: { addressLine1: "Calle 1" },
|
||||||
|
services: [{ kind: "WATER", accountNumber: "3014057" }],
|
||||||
|
openingPayment: { ...pay, method: "CASH" },
|
||||||
|
},
|
||||||
|
"u1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cash.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ customerId: "c1", amount: 500, concept: "SALDO INICIAL" }),
|
||||||
|
"u1",
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
expect(tx.propertyService.createMany).toHaveBeenCalledWith({
|
||||||
|
data: [{ kind: "WATER", accountNumber: "3014057", propertyId: "p1" }],
|
||||||
|
});
|
||||||
|
expect(r.receipt).toEqual({ folio: 13935 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,14 +16,17 @@ import { AbilityGuard } from "../auth/ability.guard";
|
|||||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||||
|
|
||||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||||
@Controller("customers")
|
@Controller("customers")
|
||||||
export class CustomersController {
|
export class CustomersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly customers: CustomersService,
|
private readonly customers: CustomersService,
|
||||||
|
private readonly numids: NumidService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,6 +39,19 @@ export class CustomersController {
|
|||||||
return this.customers.stats();
|
return this.customers.stats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reusable portal ids, lowest first. Declared above `:id` so the literal
|
||||||
|
* path is not swallowed by the wildcard route. */
|
||||||
|
@Get("numid/candidates")
|
||||||
|
async numidCandidates() {
|
||||||
|
return { candidates: await this.numids.emptyCandidates() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suggested folder number for a new customer. Above `:id` like the rest. */
|
||||||
|
@Get("folder/next")
|
||||||
|
async nextFolder() {
|
||||||
|
return { folderNumber: await this.customers.nextFolderNumber() };
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
list(
|
||||||
@Query("query") query?: string,
|
@Query("query") query?: string,
|
||||||
@@ -68,6 +84,26 @@ export class CustomersController {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alta en una pantalla. Gated on customer:create; the property, service and
|
||||||
|
* ledger abilities it also exercises sit at the same STAFF tier.
|
||||||
|
*/
|
||||||
|
@Post("onboard")
|
||||||
|
@RequireAbility("customer:create")
|
||||||
|
async onboard(@Body() dto: OnboardCustomerDto, @Req() req: Request) {
|
||||||
|
const r = await this.customers.onboard(dto, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "customer.onboard", {
|
||||||
|
customerId: r.customer.id,
|
||||||
|
name: r.customer.name,
|
||||||
|
folderNumber: r.customer.folderNumber,
|
||||||
|
propertyId: r.property?.id,
|
||||||
|
services: dto.services?.length ?? 0,
|
||||||
|
folio: r.receipt?.folio,
|
||||||
|
openingTransactionId: r.receipt?.transactionId ?? r.payment?.id,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
@RequireAbility("customer:update")
|
@RequireAbility("customer:update")
|
||||||
async update(
|
async update(
|
||||||
@@ -95,4 +131,27 @@ export class CustomersController {
|
|||||||
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
void this.audit.log(this.actingId(req), "customer.restore", { customerId: id });
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give this customer a portal NUMid so they can log in to
|
||||||
|
* my.jorgecuadros.com. Idempotent — a customer who already has one gets it
|
||||||
|
* back rather than a second identity.
|
||||||
|
*/
|
||||||
|
@Post(":id/portal-access")
|
||||||
|
@RequireAbility("customer:portal-access")
|
||||||
|
async portalAccess(@Param("id") id: string, @Req() req: Request) {
|
||||||
|
const allocation = await this.numids.allocate(id);
|
||||||
|
if (allocation.origin !== "existing") {
|
||||||
|
// Logged with the origin and the previous holder: a recycled id is the one
|
||||||
|
// case where reading this record later has to answer "whose number was
|
||||||
|
// this before, and was it taken or minted".
|
||||||
|
void this.audit.log(this.actingId(req), "customer.portal-access", {
|
||||||
|
customerId: id,
|
||||||
|
numid: allocation.numid,
|
||||||
|
origin: allocation.origin,
|
||||||
|
previousCustomerId: allocation.previousCustomerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return allocation;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SettingsModule } from "../settings/settings.module";
|
||||||
|
import { CashReceiptsModule } from "../cash-receipts/cash-receipts.module";
|
||||||
import { CustomersController } from "./customers.controller";
|
import { CustomersController } from "./customers.controller";
|
||||||
import { CustomersService } from "./customers.service";
|
import { CustomersService } from "./customers.service";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [SettingsModule, CashReceiptsModule],
|
||||||
controllers: [CustomersController],
|
controllers: [CustomersController],
|
||||||
providers: [CustomersService],
|
providers: [CustomersService, NumidService],
|
||||||
|
exports: [NumidService],
|
||||||
})
|
})
|
||||||
export class CustomersModule {}
|
export class CustomersModule {}
|
||||||
@@ -1,8 +1,44 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreateCustomerDto } from "./create-customer.dto";
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
import { UpdateCustomerDto } from "./update-customer.dto";
|
import { UpdateCustomerDto } from "./update-customer.dto";
|
||||||
|
import type { OnboardCustomerDto } from "./onboard-customer.dto";
|
||||||
|
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
|
||||||
|
import {
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
notCashJournal,
|
||||||
|
PERIOD_TABLE_PREFIX,
|
||||||
|
} from "../billing/billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps an imported prior period out of the *current* period, NULL-safely.
|
||||||
|
*
|
||||||
|
* A closed year is imported as its own tagged copy (`datos2@2025`). Below the
|
||||||
|
* year start it is history and counts — for the one customer whose newest
|
||||||
|
* BALANCE FORWARD lives inside an archive it is the only carry there is, and
|
||||||
|
* dropping it understated NUMid 295 by his entire 2025 closing balance. At or
|
||||||
|
* above the year start it must go: the archives spill a couple of rows into the
|
||||||
|
* following January, and those already sit inside the next year's BALANCE
|
||||||
|
* FORWARD, which is the sum of the whole archive.
|
||||||
|
*
|
||||||
|
* Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
|
||||||
|
* NULL for an app-captured row (no legacySourceTable), dropping every one.
|
||||||
|
*/
|
||||||
|
const archiveIsHistory = (
|
||||||
|
yearStart: Date,
|
||||||
|
): Prisma.TransactionWhereInput => ({
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
|
||||||
|
{ transactionDate: { lt: yearStart } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
query?: string;
|
query?: string;
|
||||||
@@ -22,7 +58,10 @@ function toDate(v?: string): Date | null | undefined {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomersService {
|
export class CustomersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly cashReceipts: CashReceiptsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** Unified customer list with search + business-line filter, paginated. */
|
/** Unified customer list with search + business-line filter, paginated. */
|
||||||
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
async list({ query, page, pageSize, line, includeArchived }: ListParams) {
|
||||||
@@ -39,6 +78,7 @@ export class CustomersService {
|
|||||||
{ mobile: { contains: q } },
|
{ mobile: { contains: q } },
|
||||||
{ city: { contains: q } },
|
{ city: { contains: q } },
|
||||||
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
{ legacyRefs: { some: { legacyId: { contains: q } } } },
|
||||||
|
...(/^\d+$/.test(q) ? [{ folderNumber: Number(q) }] : []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (line === "utility") where.properties = { some: {} };
|
if (line === "utility") where.properties = { some: {} };
|
||||||
@@ -61,6 +101,7 @@ export class CustomersService {
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
nameSource: true,
|
nameSource: true,
|
||||||
|
folderNumber: true,
|
||||||
city: true,
|
city: true,
|
||||||
state: true,
|
state: true,
|
||||||
email: true,
|
email: true,
|
||||||
@@ -77,6 +118,7 @@ export class CustomersService {
|
|||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
nameSource: r.nameSource,
|
nameSource: r.nameSource,
|
||||||
|
folderNumber: r.folderNumber,
|
||||||
city: r.city,
|
city: r.city,
|
||||||
state: r.state,
|
state: r.state,
|
||||||
email: r.email,
|
email: r.email,
|
||||||
@@ -96,6 +138,13 @@ export class CustomersService {
|
|||||||
|
|
||||||
/** Full unified customer view: identity + both business lines + ledger. */
|
/** Full unified customer view: identity + both business lines + ledger. */
|
||||||
async detail(id: string) {
|
async detail(id: string) {
|
||||||
|
// The movement list on the customer file is the same statement the office
|
||||||
|
// prints, so it follows the same rule as BillingService.statement: this
|
||||||
|
// calendar year, oldest-first. No `take` any more — the cap used to hide
|
||||||
|
// the end of a busy customer's year once the order flipped, and a single
|
||||||
|
// year is small (365 rows for the heaviest customer in the book).
|
||||||
|
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||||
|
|
||||||
const customer = await this.prisma.customer.findUnique({
|
const customer = await this.prisma.customer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -117,8 +166,21 @@ export class CustomersService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
transactions: {
|
transactions: {
|
||||||
orderBy: { transactionDate: "desc" },
|
// Archives are excluded by tag, not by date. They are not cleanly
|
||||||
take: 100,
|
// bounded — datos2@2024 carries rows dated 2022, 2023, 2025 and one
|
||||||
|
// in 2026, datos2@2025 two more — so a date test alone would surface
|
||||||
|
// a closed year's rows in the current year's list, duplicating the
|
||||||
|
// live ledger's own copy of them for three customers.
|
||||||
|
// Archives are kept out by tag, not by date. They are not cleanly
|
||||||
|
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
|
||||||
|
// alone would surface a closed year's rows in the current year's
|
||||||
|
// list. Nothing below yearStart reaches this list anyway, so the
|
||||||
|
// window rule reduces to a plain exclusion here.
|
||||||
|
where: {
|
||||||
|
transactionDate: { gte: yearStart },
|
||||||
|
...archiveIsHistory(yearStart),
|
||||||
|
},
|
||||||
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
include: { type: true },
|
include: { type: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -130,16 +192,51 @@ export class CustomersService {
|
|||||||
|
|
||||||
// Ledger totals per domain + currency (the "one statement across both
|
// Ledger totals per domain + currency (the "one statement across both
|
||||||
// business lines" payoff), computed in the DB rather than in JS.
|
// business lines" payoff), computed in the DB rather than in JS.
|
||||||
|
//
|
||||||
|
// These have to answer the same question BillingService.statement answers,
|
||||||
|
// because this card is titled "Estado de cuenta" and links straight to it —
|
||||||
|
// two screens quoting one customer two different balances is worse than
|
||||||
|
// either number alone. So it takes the same three rules the statement uses:
|
||||||
|
// the balance floor, the cash-source exclusion, and dropping outstanding
|
||||||
|
// rows the office has not paid yet.
|
||||||
|
//
|
||||||
|
// Without the floor these were a raw lifetime sum, double-counting the
|
||||||
|
// pre-cutover history each BALANCE FORWARD row already absorbs. Importing
|
||||||
|
// prior periods made that visibly worse: for NUMid 501 the tiles read
|
||||||
|
// -7,119.29 before the archives landed and -15,270.59 after, against a true
|
||||||
|
// -10,715.29 — the difference being exactly the 2024 and 2025 closing
|
||||||
|
// balances, added a second time on top of the opening row that contains
|
||||||
|
// them.
|
||||||
|
const floor = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId: id,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
const summary = await this.prisma.transaction.groupBy({
|
const summary = await this.prisma.transaction.groupBy({
|
||||||
by: ["domain", "currency"],
|
by: ["domain", "currency"],
|
||||||
// Exclude voided rows so the per-domain balance matches the statement.
|
where: {
|
||||||
where: { customerId: id, voidedAt: null },
|
customerId: id,
|
||||||
|
voidedAt: null,
|
||||||
|
outstanding: false,
|
||||||
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
|
// The floor alone does not settle the archives: a customer floored by
|
||||||
|
// an archive clears it with every row of that archive, and the rows
|
||||||
|
// archives spill into the following January clear any floor.
|
||||||
|
AND: [archiveIsHistory(yearStart), notCashJournal()],
|
||||||
|
},
|
||||||
_sum: { amount: true },
|
_sum: { amount: true },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...customer,
|
...customer,
|
||||||
|
/** Calendar year the movement list covers. */
|
||||||
|
transactionYear: yearStart.getUTCFullYear(),
|
||||||
transactionSummary: summary.map((s) => ({
|
transactionSummary: summary.map((s) => ({
|
||||||
domain: s.domain,
|
domain: s.domain,
|
||||||
currency: s.currency,
|
currency: s.currency,
|
||||||
@@ -164,6 +261,7 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateCustomerDto) {
|
async create(dto: CreateCustomerDto) {
|
||||||
|
await this.assertFolderFree(dto.folderNumber);
|
||||||
return this.prisma.customer.create({
|
return this.prisma.customer.create({
|
||||||
// App-created rows: nameMissing false (name is required), no legacy
|
// App-created rows: nameMissing false (name is required), no legacy
|
||||||
// provenance — those columns stay null, marking a native record.
|
// provenance — those columns stay null, marking a native record.
|
||||||
@@ -171,8 +269,85 @@ export class CustomersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open an account from one screen: customer, property, services and the
|
||||||
|
* opening balance taken as the first payment. One database transaction — a
|
||||||
|
* half-created account (a customer with no property, a payment with no
|
||||||
|
* customer) is worse than none, because the office would key it again.
|
||||||
|
*/
|
||||||
|
async onboard(dto: OnboardCustomerDto, userId: string) {
|
||||||
|
const pay = dto.openingPayment;
|
||||||
|
if (pay) {
|
||||||
|
if (!(pay.amount > 0)) throw new BadRequestException("El saldo inicial debe ser mayor a cero.");
|
||||||
|
if (pay.method === "CHECK" && !pay.checkNumber?.trim()) {
|
||||||
|
throw new BadRequestException("Falta el número de cheque del saldo inicial.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dto.services?.length && !dto.property) {
|
||||||
|
throw new BadRequestException("Los servicios necesitan una propiedad.");
|
||||||
|
}
|
||||||
|
await this.assertFolderFree(dto.customer.folderNumber);
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const customer = await tx.customer.create({
|
||||||
|
data: { ...this.toData(dto.customer), name: dto.customer.name, nameMissing: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const property = dto.property
|
||||||
|
? await tx.property.create({ data: { ...dto.property, customerId: customer.id } })
|
||||||
|
: null;
|
||||||
|
if (property && dto.services?.length) {
|
||||||
|
await tx.propertyService.createMany({
|
||||||
|
data: dto.services.map((s) => ({ ...s, propertyId: property.id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const concept = pay?.concept?.trim() || "SALDO INICIAL";
|
||||||
|
let receipt = null;
|
||||||
|
let payment = null;
|
||||||
|
if (pay?.method === "CASH") {
|
||||||
|
receipt = await this.cashReceipts.create(
|
||||||
|
{
|
||||||
|
customerId: customer.id,
|
||||||
|
receivedAt: pay.receivedAt,
|
||||||
|
amount: pay.amount,
|
||||||
|
currency: pay.currency,
|
||||||
|
exchangeRate: pay.exchangeRate,
|
||||||
|
concept,
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
} else if (pay?.method === "CHECK") {
|
||||||
|
const date = new Date(pay.receivedAt);
|
||||||
|
if (isNaN(date.getTime())) throw new BadRequestException("Fecha del saldo inicial inválida.");
|
||||||
|
const type = await tx.typeTransaction.findFirst({
|
||||||
|
where: { nameEn: "CHECK DEPOSIT" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
payment = await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
customerId: customer.id,
|
||||||
|
domain: "UTILITY",
|
||||||
|
typeId: type?.id,
|
||||||
|
transactionDate: date,
|
||||||
|
amount: new Prisma.Decimal(pay.amount.toFixed(2)),
|
||||||
|
currency: pay.currency,
|
||||||
|
checkNumber: pay.checkNumber!.trim(),
|
||||||
|
message: concept,
|
||||||
|
captureSource: "MANUAL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { customer, property, receipt, payment };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateCustomerDto) {
|
async update(id: string, dto: UpdateCustomerDto) {
|
||||||
await this.ensureExists(id);
|
const current = await this.ensureExists(id);
|
||||||
|
// An archived customer's number is history, not a claim on the shelf.
|
||||||
|
if (!current.archivedAt) await this.assertFolderFree(dto.folderNumber, id);
|
||||||
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
return this.prisma.customer.update({ where: { id }, data: this.toData(dto) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +361,9 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async restore(id: string) {
|
async restore(id: string) {
|
||||||
await this.ensureExists(id);
|
const current = await this.ensureExists(id);
|
||||||
|
// Its folder may have been handed to someone else while it was closed.
|
||||||
|
await this.assertFolderFree(current.folderNumber, id);
|
||||||
return this.prisma.customer.update({
|
return this.prisma.customer.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { archivedAt: null },
|
data: { archivedAt: null },
|
||||||
@@ -196,9 +373,46 @@ export class CustomersService {
|
|||||||
private async ensureExists(id: string) {
|
private async ensureExists(id: string) {
|
||||||
const found = await this.prisma.customer.findUnique({
|
const found = await this.prisma.customer.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { id: true },
|
select: { id: true, archivedAt: true, folderNumber: true },
|
||||||
});
|
});
|
||||||
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
if (!found) throw new NotFoundException(`Customer ${id} not found`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- folder numbers ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folder number belongs to one open account at a time. Archived customers
|
||||||
|
* keep theirs as a record of which folder they used, without holding it.
|
||||||
|
*
|
||||||
|
* ponytail: check-then-write, not a constraint — two staff saving the same
|
||||||
|
* number in the same instant both win. A functional unique index on
|
||||||
|
* IF(archivedAt IS NULL, folderNumber, NULL) closes it, if Prisma ever models one.
|
||||||
|
*/
|
||||||
|
private async assertFolderFree(folderNumber: number | null | undefined, exceptId?: string) {
|
||||||
|
if (folderNumber == null) return;
|
||||||
|
const holder = await this.prisma.customer.findFirst({
|
||||||
|
where: { folderNumber, archivedAt: null, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
if (holder) {
|
||||||
|
throw new ConflictException(`La carpeta ${folderNumber} ya la tiene ${holder.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowest folder number no open account holds — how the office reuses them. */
|
||||||
|
async nextFolderNumber(): Promise<number> {
|
||||||
|
const [row] = await this.prisma.$queryRaw<{ n: bigint | number }[]>`
|
||||||
|
SELECT MIN(c.n) AS n FROM (
|
||||||
|
SELECT 1 AS n
|
||||||
|
UNION SELECT folderNumber + 1 FROM customers
|
||||||
|
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
|
||||||
|
) c
|
||||||
|
WHERE c.n NOT IN (
|
||||||
|
SELECT folderNumber FROM customers
|
||||||
|
WHERE archivedAt IS NULL AND folderNumber IS NOT NULL
|
||||||
|
)`;
|
||||||
|
return Number(row.n);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top-line counts for a dashboard header. */
|
/** Top-line counts for a dashboard header. */
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { NumidService } from "./numid.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What matters about the allocator is the two things it must never do: hand the
|
||||||
|
* same id to two customers, and hand out a recycled id while Access can still
|
||||||
|
* take it back. Both are tested here; the emptiness SQL itself is exercised
|
||||||
|
* against real data by scripts/numid-audit.mjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
existingRef?: { legacyId: string } | null;
|
||||||
|
archived?: boolean;
|
||||||
|
missing?: boolean;
|
||||||
|
recycle?: boolean;
|
||||||
|
empty?: { numid: string; refId: string; customerId: string }[];
|
||||||
|
max?: number | null;
|
||||||
|
/** Make the first N create() calls fail the unique key, as a race would. */
|
||||||
|
createConflicts?: number;
|
||||||
|
/** Make updateMany report "nothing matched", as a lost recycle race would. */
|
||||||
|
recycleMisses?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(opts: Options = {}) {
|
||||||
|
const created: { legacyId: string }[] = [];
|
||||||
|
let conflictsLeft = opts.createConflicts ?? 0;
|
||||||
|
let missesLeft = opts.recycleMisses ?? 0;
|
||||||
|
|
||||||
|
const prisma = {
|
||||||
|
customer: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue(
|
||||||
|
opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
customerLegacyRef: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
|
||||||
|
updateMany: jest.fn().mockImplementation(() => {
|
||||||
|
if (missesLeft > 0) {
|
||||||
|
missesLeft -= 1;
|
||||||
|
return Promise.resolve({ count: 0 });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ count: 1 });
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
|
||||||
|
if (conflictsLeft > 0) {
|
||||||
|
conflictsLeft -= 1;
|
||||||
|
return Promise.reject(
|
||||||
|
new Prisma.PrismaClientKnownRequestError("dup", {
|
||||||
|
code: "P2002",
|
||||||
|
clientVersion: "5",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
created.push(data);
|
||||||
|
return Promise.resolve(data);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Two different raw queries share one mock: the MAX lookup returns a single
|
||||||
|
// {max} row, everything else is the empty-candidate list.
|
||||||
|
$queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
|
||||||
|
const text = String((sql as unknown as { sql?: string }).sql ?? "");
|
||||||
|
if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
|
||||||
|
return Promise.resolve(opts.empty ?? []);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
numidRecycleEmpty: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new NumidService(prisma as never, settings as never),
|
||||||
|
prisma,
|
||||||
|
created,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("NUMid allocation", () => {
|
||||||
|
it("returns the id a customer already holds instead of minting a second one", async () => {
|
||||||
|
// A double-clicked button must not fork the customer's portal identity.
|
||||||
|
const { service, prisma } = build({ existingRef: { legacyId: "501" } });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "501",
|
||||||
|
origin: "existing",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allocates one past the highest id in the pool", async () => {
|
||||||
|
const { service, created } = build({ max: 1171 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(created[0]).toMatchObject({
|
||||||
|
sourceSystem: "utilities",
|
||||||
|
sourceTable: "DATGRAL",
|
||||||
|
legacyId: "1172",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts at 1 when the pool is empty", async () => {
|
||||||
|
const { service } = build({ max: null });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT recycle while the setting is off, even with candidates free", async () => {
|
||||||
|
// The default has to be the safe one: every reusable id still exists in
|
||||||
|
// Access, and a --sync run reassigns it back to its Access owner.
|
||||||
|
const { service, prisma } = build({
|
||||||
|
max: 1171,
|
||||||
|
empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the lowest empty id once recycling is switched on", async () => {
|
||||||
|
const { service, prisma } = build({
|
||||||
|
recycle: true,
|
||||||
|
max: 1171,
|
||||||
|
empty: [
|
||||||
|
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||||
|
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toEqual({
|
||||||
|
numid: "1089",
|
||||||
|
origin: "recycled",
|
||||||
|
previousCustomerId: "cust-old",
|
||||||
|
});
|
||||||
|
// Guarded on the owner read a moment ago, so a ref that moved underneath us
|
||||||
|
// matches nothing rather than being stolen.
|
||||||
|
expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: "ref-1089", customerId: "cust-old" },
|
||||||
|
data: { customerId: "cust-new" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a candidate that someone else took first", async () => {
|
||||||
|
const { service } = build({
|
||||||
|
recycle: true,
|
||||||
|
max: 1171,
|
||||||
|
recycleMisses: 1,
|
||||||
|
empty: [
|
||||||
|
{ numid: "1089", refId: "ref-1089", customerId: "cust-old" },
|
||||||
|
{ numid: "1094", refId: "ref-1094", customerId: "cust-other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1094",
|
||||||
|
origin: "recycled",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a new id when recycling is on but nothing is free", async () => {
|
||||||
|
const { service } = build({ recycle: true, max: 1171, empty: [] });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries when two writers pick the same id", async () => {
|
||||||
|
// The unique key on (sourceSystem, sourceTable, legacyId) is what decides
|
||||||
|
// the winner; the loser must retry, never overwrite.
|
||||||
|
const { service, prisma } = build({ max: 1171, createConflicts: 1 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).resolves.toMatchObject({
|
||||||
|
numid: "1172",
|
||||||
|
origin: "new",
|
||||||
|
});
|
||||||
|
expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up loudly rather than looping forever", async () => {
|
||||||
|
const { service } = build({ max: 1171, createConflicts: 99 });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an archived customer", async () => {
|
||||||
|
const { service } = build({ archived: true });
|
||||||
|
|
||||||
|
await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a customer that does not exist", async () => {
|
||||||
|
const { service } = build({ missing: true });
|
||||||
|
|
||||||
|
await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { SettingsService } from "../settings/settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allocation of the portal NUMid — the "Security Number" my.jorgecuadros.com
|
||||||
|
* asks for at login.
|
||||||
|
*
|
||||||
|
* The NUMid is not a column on `Customer`. It is a `CustomerLegacyRef` row with
|
||||||
|
* (sourceSystem='utilities', sourceTable='DATGRAL'), and `CustomersService.create`
|
||||||
|
* deliberately writes none: a natively created customer has no legacy provenance.
|
||||||
|
* The consequence is that every customer created in the staff UI is invisible to
|
||||||
|
* the portal until this service gives them an id.
|
||||||
|
*
|
||||||
|
* WHY THIS IS NOT DONE AT CREATE TIME. Insurance is expected to move to the
|
||||||
|
* platform before utilities, and an insurance-only customer has no reason to hold
|
||||||
|
* a portal identity. Allocating on every create would spend utilities ids — and
|
||||||
|
* the handful of reusable ones — on people who will never log in. So this is an
|
||||||
|
* explicit staff action instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The pair that identifies a portal NUMid. */
|
||||||
|
export const UTILITIES_SYSTEM = "utilities";
|
||||||
|
export const UTILITIES_TABLE = "DATGRAL";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* insurance/DATGRAL is a SEPARATE id space that reuses the same sourceTable name
|
||||||
|
* and runs past 4,000. It must never be read as a NUMid, and never allocated
|
||||||
|
* from: the portal cannot resolve those ids. Every query here filters on BOTH
|
||||||
|
* columns for that reason, never on sourceTable alone. A customer can also hold
|
||||||
|
* more than one insurance ref — 16 of them do, where several insurance rows
|
||||||
|
* folded into one customer — so those are tested with EXISTS rather than joined.
|
||||||
|
*/
|
||||||
|
const POOL = {
|
||||||
|
sourceSystem: UTILITIES_SYSTEM,
|
||||||
|
sourceTable: UTILITIES_TABLE,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type AllocationOrigin = "existing" | "new" | "recycled";
|
||||||
|
|
||||||
|
export interface Allocation {
|
||||||
|
numid: string;
|
||||||
|
origin: AllocationOrigin;
|
||||||
|
/** Set only on a recycle — the customer the id was taken from. */
|
||||||
|
previousCustomerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NUMids that were created and never used, safe for an allocator to take.
|
||||||
|
*
|
||||||
|
* THE TWO OBVIOUS RULES BOTH FIND NOTHING, which is why this one looks the way
|
||||||
|
* it does. "Owns no rows" matches nobody: migration gave all 1,171 NUMids a
|
||||||
|
* property and a transaction. "No transaction in N years" also matches nobody:
|
||||||
|
* every customer carries a synthetic Jan-1 opening-balance row, so everyone
|
||||||
|
* looks active in the current year. That row has to be subtracted before any
|
||||||
|
* activity test means anything, which is what `bf` does below.
|
||||||
|
*
|
||||||
|
* The balance-forward row is matched in two shapes on purpose.
|
||||||
|
* transform_transactions.py:120 mints a type literally named 'BALANCE FORWARD';
|
||||||
|
* databases loaded before that change carry the same rows with typeId NULL,
|
||||||
|
* dated Jan 1, legacySourceTable='datos2'. Matching only the type name floors
|
||||||
|
* nothing on such a database and turns the balance test into a raw lifetime sum
|
||||||
|
* — the double-count that read the whole book as +20.6M MXN in credit before
|
||||||
|
* d173c9e, and which here would mark live customers as empty.
|
||||||
|
*
|
||||||
|
* Services are tested as "any service" rather than "any ACTIVE service": a
|
||||||
|
* deactivated water account is still a record of somebody having lived behind
|
||||||
|
* this id.
|
||||||
|
*
|
||||||
|
* Kept in step with scripts/numid-audit.sql, which reports the same tier for a
|
||||||
|
* human. That script is the reporting copy of this rule; change both together.
|
||||||
|
*/
|
||||||
|
const EMPTY_NUMID_SQL = Prisma.sql`
|
||||||
|
WITH bf AS (
|
||||||
|
SELECT t.id, t.customerId
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
|
WHERE t.voidedAt IS NULL
|
||||||
|
AND (
|
||||||
|
tt.nameEn = 'BALANCE FORWARD'
|
||||||
|
OR (t.typeId IS NULL AND MONTH(t.transactionDate) = 1 AND DAY(t.transactionDate) = 1
|
||||||
|
AND t.legacySourceTable = 'datos2')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT r.legacyId AS numid, r.id AS refId, r.customerId AS customerId
|
||||||
|
FROM customer_legacy_refs r
|
||||||
|
JOIN customers c ON c.id = r.customerId
|
||||||
|
WHERE r.sourceSystem = ${UTILITIES_SYSTEM} AND r.sourceTable = ${UTILITIES_TABLE}
|
||||||
|
AND (c.email IS NULL OR c.email = '')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||||
|
AND t.id NOT IN (SELECT id FROM bf))
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM transactions t
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND t.outstanding = 1)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM property_services ps
|
||||||
|
JOIN properties p ON p.id = ps.propertyId WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM policies p WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM vehicles v WHERE v.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM trust_accounts ta
|
||||||
|
JOIN properties p ON p.id = ta.propertyId WHERE p.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM statement_documents s WHERE s.matchedCustomerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM policy_ocr_documents o WHERE o.matchedCustomerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM email_notification_log e WHERE e.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM email_log e WHERE e.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM account_status_history a WHERE a.customerId = c.id)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM customer_legacy_refs i
|
||||||
|
WHERE i.customerId = c.id AND i.sourceSystem = 'insurance')
|
||||||
|
ORDER BY CAST(r.legacyId AS UNSIGNED)`;
|
||||||
|
|
||||||
|
interface EmptyRow {
|
||||||
|
numid: string;
|
||||||
|
refId: string;
|
||||||
|
customerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NumidService {
|
||||||
|
private readonly logger = new Logger(NumidService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly settings: SettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** The customer's portal id, or null if they have none. */
|
||||||
|
async current(customerId: string): Promise<string | null> {
|
||||||
|
const ref = await this.prisma.customerLegacyRef.findFirst({
|
||||||
|
where: { customerId, ...POOL },
|
||||||
|
select: { legacyId: true },
|
||||||
|
});
|
||||||
|
return ref?.legacyId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reusable ids, lowest first. Empty unless recycling is switched on. */
|
||||||
|
async emptyCandidates(): Promise<string[]> {
|
||||||
|
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||||
|
return rows.map((r) => r.numid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a customer a portal NUMid.
|
||||||
|
*
|
||||||
|
* Idempotent: a customer who already holds one gets it back rather than a
|
||||||
|
* second id, so a double-clicked button cannot fork an identity.
|
||||||
|
*/
|
||||||
|
async allocate(customerId: string): Promise<Allocation> {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: customerId },
|
||||||
|
select: { id: true, archivedAt: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||||
|
if (customer.archivedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
"No se puede asignar un número de portal a un cliente archivado",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.current(customerId);
|
||||||
|
if (existing) return { numid: existing, origin: "existing" };
|
||||||
|
|
||||||
|
const recycle = await this.recycleEnabled();
|
||||||
|
|
||||||
|
// Two writers can pick the same id between the read and the write. The
|
||||||
|
// unique key on (sourceSystem, sourceTable, legacyId) is what actually
|
||||||
|
// decides the winner; the loser retries and takes the next id rather than
|
||||||
|
// silently overwriting. Bounded so a genuinely wedged pool fails loudly.
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
try {
|
||||||
|
if (recycle) {
|
||||||
|
const recycled = await this.tryRecycle(customerId);
|
||||||
|
if (recycled) return recycled;
|
||||||
|
}
|
||||||
|
return await this.allocateNext(customerId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUniqueViolation(error)) throw error;
|
||||||
|
this.logger.warn(
|
||||||
|
`NUMid allocation for ${customerId} lost a race (attempt ${attempt + 1}), retrying`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ConflictException(
|
||||||
|
"No se pudo asignar un número de portal; intente de nuevo",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the recycle tier is live.
|
||||||
|
*
|
||||||
|
* Off by default, and that default is the safe one while Access is still the
|
||||||
|
* utilities master. Every id in the pool ALSO exists in Access DATGRAL, and a
|
||||||
|
* `--sync` migration run upserts refs with ON DUPLICATE KEY UPDATE customerId
|
||||||
|
* (transform_customers.py:327) — so an id recycled today is silently handed
|
||||||
|
* back to its Access owner on the next sync, and the customer who was given it
|
||||||
|
* loses their portal identity. Turn this on once utilities has cut over, or
|
||||||
|
* for ids that have been deleted at the source.
|
||||||
|
*/
|
||||||
|
private async recycleEnabled(): Promise<boolean> {
|
||||||
|
const { value } = await this.settings.numidRecycleEmpty();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-point the lowest empty id at this customer. Null when none is free. */
|
||||||
|
private async tryRecycle(customerId: string): Promise<Allocation | null> {
|
||||||
|
const rows = await this.prisma.$queryRaw<EmptyRow[]>(EMPTY_NUMID_SQL);
|
||||||
|
for (const row of rows) {
|
||||||
|
// Guarded by the owner we just read: if anything moved the ref in the
|
||||||
|
// meantime the update matches nothing and we fall through to the next
|
||||||
|
// candidate rather than stealing an id that is no longer empty.
|
||||||
|
const moved = await this.prisma.customerLegacyRef.updateMany({
|
||||||
|
where: { id: row.refId, customerId: row.customerId },
|
||||||
|
data: { customerId },
|
||||||
|
});
|
||||||
|
if (moved.count === 1) {
|
||||||
|
this.logger.log(
|
||||||
|
`NUMid ${row.numid} recycled from ${row.customerId} to ${customerId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
numid: row.numid,
|
||||||
|
origin: "recycled",
|
||||||
|
previousCustomerId: row.customerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One past the highest id in the pool. */
|
||||||
|
private async allocateNext(customerId: string): Promise<Allocation> {
|
||||||
|
const [{ max }] = await this.prisma.$queryRaw<{ max: number | null }[]>(
|
||||||
|
// MAX over a CAST, not over the string: legacyId is VARCHAR, so a plain
|
||||||
|
// MAX returns '999' as the highest of 1,171 rows and the allocator hands
|
||||||
|
// out an id that is already taken.
|
||||||
|
Prisma.sql`SELECT MAX(CAST(legacyId AS UNSIGNED)) AS max
|
||||||
|
FROM customer_legacy_refs
|
||||||
|
WHERE sourceSystem = ${UTILITIES_SYSTEM} AND sourceTable = ${UTILITIES_TABLE}`,
|
||||||
|
);
|
||||||
|
const numid = String(Number(max ?? 0) + 1);
|
||||||
|
await this.prisma.customerLegacyRef.create({
|
||||||
|
data: { customerId, ...POOL, legacyId: numid },
|
||||||
|
});
|
||||||
|
return { numid, origin: "new" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { Currency } from "@jorgecuadros/database";
|
||||||
|
import { CreateCustomerDto } from "./create-customer.dto";
|
||||||
|
import { ServiceDto } from "../properties/property.dto";
|
||||||
|
|
||||||
|
/** The property, minus the customer it is being created for. */
|
||||||
|
export class OnboardPropertyDto {
|
||||||
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
|
@IsOptional() @IsString() phone1?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening balance the office takes when an account is opened, captured as
|
||||||
|
* the customer's first payment. Cash issues an EFE receipt; a check posts a
|
||||||
|
* CHECK DEPOSIT against that check number.
|
||||||
|
*/
|
||||||
|
export class OpeningPaymentDto {
|
||||||
|
@IsIn(["CASH", "CHECK"]) method!: "CASH" | "CHECK";
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
@IsEnum(Currency) currency!: Currency;
|
||||||
|
@IsString() @MinLength(1) receivedAt!: string;
|
||||||
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
|
/** Cash only: dollars credited to a pesos ledger. */
|
||||||
|
@IsOptional() @IsNumber() exchangeRate?: number;
|
||||||
|
@IsOptional() @IsString() concept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One screen: cliente + propiedad + servicios + saldo inicial. */
|
||||||
|
export class OnboardCustomerDto {
|
||||||
|
@ValidateNested() @Type(() => CreateCustomerDto) customer!: CreateCustomerDto;
|
||||||
|
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => OnboardPropertyDto)
|
||||||
|
property?: OnboardPropertyDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(12)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ServiceDto)
|
||||||
|
services?: ServiceDto[];
|
||||||
|
|
||||||
|
@IsOptional() @ValidateNested() @Type(() => OpeningPaymentDto)
|
||||||
|
openingPayment?: OpeningPaymentDto;
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Currency } from "@jorgecuadros/database";
|
import { Currency } from "@jorgecuadros/database";
|
||||||
@@ -12,6 +14,7 @@ import { Currency } from "@jorgecuadros/database";
|
|||||||
/** Same editable fields as create, all optional. */
|
/** Same editable fields as create, all optional. */
|
||||||
export class UpdateCustomerDto {
|
export class UpdateCustomerDto {
|
||||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
|
@IsOptional() @IsInt() @Min(1) folderNumber?: number | null;
|
||||||
@IsOptional() @IsString() addressLine1?: string;
|
@IsOptional() @IsString() addressLine1?: string;
|
||||||
@IsOptional() @IsString() addressLine2?: string;
|
@IsOptional() @IsString() addressLine2?: string;
|
||||||
@IsOptional() @IsString() city?: string;
|
@IsOptional() @IsString() city?: string;
|
||||||
|
|||||||
+13
-1
@@ -60,7 +60,19 @@ async function bootstrap() {
|
|||||||
app.use(passport.initialize());
|
app.use(passport.initialize());
|
||||||
app.use(passport.session());
|
app.use(passport.session());
|
||||||
|
|
||||||
app.enableCors({ credentials: true, origin: process.env.WEB_ORIGIN ?? "http://localhost:3000" });
|
// The same deployment is reached under several origins — the office LAN IP,
|
||||||
|
// the tailnet name, the demo domain — and the browser derives the API origin
|
||||||
|
// from whichever one served the page (apps/web/src/lib/api.ts). So WEB_ORIGIN
|
||||||
|
// is a comma-separated LIST, not a single value. A request whose Origin is
|
||||||
|
// not listed gets no CORS headers and the credentialed fetch fails, so add an
|
||||||
|
// entry when a new way of reaching the app is introduced. Same-origin setups
|
||||||
|
// (web and API behind one proxy) never hit CORS at all.
|
||||||
|
const webOrigins = (process.env.WEB_ORIGIN ?? "http://localhost:3000")
|
||||||
|
.split(",")
|
||||||
|
.map((o) => o.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
app.enableCors({ credentials: true, origin: webOrigins });
|
||||||
|
|
||||||
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
const port = process.env.PORT ? Number(process.env.PORT) : 3001;
|
||||||
await app.listen(port);
|
await app.listen(port);
|
||||||
|
|||||||
@@ -104,6 +104,24 @@ export class OpsController {
|
|||||||
return this.replication.status();
|
return this.replication.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full row-by-row comparison of the customer-visible tables against the master.
|
||||||
|
*
|
||||||
|
* POST rather than GET despite reading nothing: it is a full scan of both
|
||||||
|
* servers and must not be something a browser prefetch, a retry, or a refresh
|
||||||
|
* can set off. Audited for the same reason — it is a deliberate, costly act,
|
||||||
|
* and "who ran this while the site was slow" is a question worth answering.
|
||||||
|
*/
|
||||||
|
@Post("replication/verify")
|
||||||
|
async verifyReplication(@Req() req: Request) {
|
||||||
|
const result = await this.replication.verify();
|
||||||
|
void this.audit.log(this.actingId(req), "ops.replication.verify", {
|
||||||
|
identical: result.identical,
|
||||||
|
elapsedMs: result.elapsedMs,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Get("jobs")
|
@Get("jobs")
|
||||||
listJobs() {
|
listJobs() {
|
||||||
return this.ops.listJobs();
|
return this.ops.listJobs();
|
||||||
@@ -117,11 +135,17 @@ export class OpsController {
|
|||||||
@Post("jobs")
|
@Post("jobs")
|
||||||
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
async startJob(@Body() dto: StartJobDto, @Req() req: Request) {
|
||||||
const userId = this.actingId(req);
|
const userId = this.actingId(req);
|
||||||
const job = await this.ops.startJob(dto.kind, { file: dto.file }, userId);
|
const job = await this.ops.startJob(
|
||||||
|
dto.kind,
|
||||||
|
{ file: dto.file, forceFull: dto.forceFull },
|
||||||
|
userId,
|
||||||
|
);
|
||||||
void this.audit.log(userId, "ops.job.start", {
|
void this.audit.log(userId, "ops.job.start", {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
kind: dto.kind,
|
kind: dto.kind,
|
||||||
file: dto.file,
|
file: dto.file,
|
||||||
|
// Recorded because this is the flag that authorised deleting native rows.
|
||||||
|
forceFull: dto.forceFull,
|
||||||
});
|
});
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ export const INGEST_FILES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type IngestName = (typeof INGEST_FILES)[number];
|
export type IngestName = (typeof INGEST_FILES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prior-period archive: one Access snapshot per closed year, named for the
|
||||||
|
* period it holds. `2025.accdb` is UTILITIES as it stood when 2025 was cut.
|
||||||
|
*
|
||||||
|
* The filename is the entire declaration of the period — nothing inside the
|
||||||
|
* file names its year, because a snapshot's `datos2` is indistinguishable from
|
||||||
|
* the live one — so this pattern is both the allowlist and the contract. It is
|
||||||
|
* anchored and allows no separator, which is what keeps an upload from
|
||||||
|
* escaping the ingest directory.
|
||||||
|
*/
|
||||||
|
const PERIOD_FILE_RE = /^(\d{4})\.accdb$/i;
|
||||||
|
|
||||||
|
/** Earliest period we will accept, so a typo'd year cannot mint a bogus one. */
|
||||||
|
const PERIOD_MIN_YEAR = 1990;
|
||||||
|
|
||||||
|
export function periodYearOf(name: string): number | null {
|
||||||
|
const m = PERIOD_FILE_RE.exec(name);
|
||||||
|
if (!m) return null;
|
||||||
|
const year = Number(m[1]);
|
||||||
|
if (year < PERIOD_MIN_YEAR || year > new Date().getUTCFullYear()) return null;
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefix for every command containing a pipe. Without it the exit status of
|
* Prefix for every command containing a pipe. Without it the exit status of
|
||||||
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
* `mysqldump | gzip` is gzip's, so a dump that failed immediately still looks
|
||||||
@@ -120,19 +143,30 @@ export class OpsService implements OnModuleInit {
|
|||||||
|
|
||||||
/* -------------------------------------------------------------- ingest */
|
/* -------------------------------------------------------------- ingest */
|
||||||
|
|
||||||
private assertIngestName(name: string): IngestName {
|
private assertIngestName(name: string): string {
|
||||||
if (!INGEST_FILES.includes(name as IngestName)) {
|
if (INGEST_FILES.includes(name as IngestName)) return name;
|
||||||
throw new BadRequestException(
|
if (periodYearOf(name) !== null) return name;
|
||||||
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}`,
|
throw new BadRequestException(
|
||||||
);
|
`Archivo no permitido. Debe ser uno de: ${INGEST_FILES.join(", ")}` +
|
||||||
}
|
`, o un archivo de periodo anterior con nombre AAAA.accdb (por ejemplo 2025.accdb).`,
|
||||||
return name as IngestName;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listIngest(): Promise<
|
async listIngest(): Promise<
|
||||||
{ name: string; present: boolean; size: number | null; modifiedAt: string | null }[]
|
{
|
||||||
|
name: string;
|
||||||
|
present: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
/** Set only on a prior-period archive; null on the four fixed sources. */
|
||||||
|
periodYear: number | null;
|
||||||
|
}[]
|
||||||
> {
|
> {
|
||||||
return Promise.all(
|
// The four fixed sources are listed whether present or not — they are
|
||||||
|
// required, so "missing" is the useful state to show. Period archives are
|
||||||
|
// optional and unbounded, so they are listed only once uploaded, newest
|
||||||
|
// year first.
|
||||||
|
const fixed = await Promise.all(
|
||||||
INGEST_FILES.map(async (name) => {
|
INGEST_FILES.map(async (name) => {
|
||||||
try {
|
try {
|
||||||
const st = await fs.stat(path.join(this.ingestDir, name));
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
@@ -141,12 +175,46 @@ export class OpsService implements OnModuleInit {
|
|||||||
present: true,
|
present: true,
|
||||||
size: st.size,
|
size: st.size,
|
||||||
modifiedAt: st.mtime.toISOString(),
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
periodYear: null as number | null,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { name, present: false, size: null, modifiedAt: null };
|
return {
|
||||||
|
name,
|
||||||
|
present: false,
|
||||||
|
size: null,
|
||||||
|
modifiedAt: null,
|
||||||
|
periodYear: null as number | null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let entries: string[] = [];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(this.ingestDir);
|
||||||
|
} catch {
|
||||||
|
entries = [];
|
||||||
|
}
|
||||||
|
const periods = (
|
||||||
|
await Promise.all(
|
||||||
|
entries
|
||||||
|
.map((name) => ({ name, year: periodYearOf(name) }))
|
||||||
|
.filter((e): e is { name: string; year: number } => e.year !== null)
|
||||||
|
.sort((a, b) => b.year - a.year)
|
||||||
|
.map(async ({ name, year }) => {
|
||||||
|
const st = await fs.stat(path.join(this.ingestDir, name));
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
present: true,
|
||||||
|
size: st.size,
|
||||||
|
modifiedAt: st.mtime.toISOString(),
|
||||||
|
periodYear: year,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(Boolean);
|
||||||
|
|
||||||
|
return [...fixed, ...periods];
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveIngest(name: string, data: Buffer): Promise<void> {
|
async saveIngest(name: string, data: Buffer): Promise<void> {
|
||||||
@@ -415,12 +483,19 @@ export class OpsService implements OnModuleInit {
|
|||||||
const out = shq(path.join(this.backupDir, file));
|
const out = shq(path.join(this.backupDir, file));
|
||||||
const py = await this.pythonBin();
|
const py = await this.pythonBin();
|
||||||
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
const runAll = shq(path.join(this.migrationDir, "run_all.py"));
|
||||||
|
// run_all.py runs native_guard.py before it truncates anything and exits
|
||||||
|
// without touching the database when the target holds rows that only
|
||||||
|
// exist here — allocated portal NUMids, app-created customers, OCR
|
||||||
|
// captures. --force-full is what the operator ticks to delete them
|
||||||
|
// anyway; without it the job fails with the list.
|
||||||
|
const force = params.forceFull === true;
|
||||||
const cmd =
|
const cmd =
|
||||||
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
`${PIPEFAIL}echo '== Respaldo de seguridad previo ==' && ` +
|
||||||
`${this.dumpCommand(flags, db, out)} && ` +
|
`${this.dumpCommand(flags, db, out)} && ` +
|
||||||
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
`echo '== Reimportación desde carpeta de ingesta ==' && ` +
|
||||||
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage`;
|
`${shq(py)} ${runAll} --env ${shq(this.migrationEnv)} --stage` +
|
||||||
return { cmd, resolvedParams: { safetyBackup: file } };
|
(force ? " --force-full" : "");
|
||||||
|
return { cmd, resolvedParams: { safetyBackup: file, forceFull: force } };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
throw new BadRequestException(`Operación no soportada: ${kind}`);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { periodYearOf } from "./ops.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `periodYearOf` is the upload allowlist for prior-period archives, so it is
|
||||||
|
* doing two jobs at once: deciding what counts as a period file, and keeping a
|
||||||
|
* caller-supplied name from escaping the ingest directory. Both are pinned here.
|
||||||
|
*/
|
||||||
|
describe("periodYearOf", () => {
|
||||||
|
it("accepts a four-digit year archive", () => {
|
||||||
|
expect(periodYearOf("2025.accdb")).toBe(2025);
|
||||||
|
expect(periodYearOf("1999.accdb")).toBe(1999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the extension", () => {
|
||||||
|
expect(periodYearOf("2025.ACCDB")).toBe(2025);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a path that would escape the ingest directory", () => {
|
||||||
|
// The name is joined onto the ingest path, so anything with a separator or
|
||||||
|
// a parent reference has to fail before it reaches the filesystem.
|
||||||
|
expect(periodYearOf("../2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("../../etc/passwd")).toBeNull();
|
||||||
|
expect(periodYearOf("sub/2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.accdb/../../x")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects names that only look like a period", () => {
|
||||||
|
expect(periodYearOf("202.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("20255.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.mdb")).toBeNull();
|
||||||
|
expect(periodYearOf("copia 2025.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf("2025.accdb.bak")).toBeNull();
|
||||||
|
expect(periodYearOf("UTILITIES.accdb")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects years outside the plausible range", () => {
|
||||||
|
// A typo'd year would otherwise mint a period nobody can ever reconcile:
|
||||||
|
// there is no BALANCE FORWARD for the year after it to check against.
|
||||||
|
expect(periodYearOf("1889.accdb")).toBeNull();
|
||||||
|
expect(periodYearOf(`${new Date().getUTCFullYear() + 1}.accdb`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the current year, which is the earliest a period can be cut", () => {
|
||||||
|
expect(periodYearOf(`${new Date().getUTCFullYear()}.accdb`)).toBe(
|
||||||
|
new Date().getUTCFullYear(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,58 @@ export interface ApplyProgress {
|
|||||||
percent: number | null;
|
percent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the replica's executed history is from the master's, in transactions.
|
||||||
|
*
|
||||||
|
* This is the check `SHOW REPLICA STATUS` cannot give you, and it is stronger
|
||||||
|
* than everything else on the card for one specific reason: every other field is
|
||||||
|
* self-reported by the replica. `Seconds_Behind_Source` reads 0 both when there
|
||||||
|
* is genuinely nothing to apply AND when the I/O thread is disconnected — with
|
||||||
|
* no incoming event there is nothing to measure staleness against, so a dead
|
||||||
|
* link reports as perfectly current. `GTID_SUBTRACT(master, replica)` asks the
|
||||||
|
* master what it has done and the replica what it has applied, so a silent
|
||||||
|
* disconnect shows up immediately as a growing number.
|
||||||
|
*/
|
||||||
|
export interface GtidDrift {
|
||||||
|
/** Transactions the master executed that the replica has not. 0 = identical. */
|
||||||
|
missingTransactions: number;
|
||||||
|
/** The missing GTID set verbatim. Null when nothing is missing. */
|
||||||
|
missingGtidSet: string | null;
|
||||||
|
/**
|
||||||
|
* Transactions in the replica's `gtid_executed` under its OWN server UUID —
|
||||||
|
* writes that happened here and exist nowhere on the master.
|
||||||
|
*
|
||||||
|
* Reported, never alarmed on. A non-zero count is the expected residue of the
|
||||||
|
* seed load: restoring a dump executes its statements locally, and they take
|
||||||
|
* GTIDs from this server's UUID. They never propagate (`log_replica_updates`
|
||||||
|
* is off and nothing sources from this node), so they are harmless — right up
|
||||||
|
* until someone tries to promote this box, where they become a real divergence.
|
||||||
|
*/
|
||||||
|
localTransactions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One table's row count and content fingerprint, on one side of the link. */
|
||||||
|
export interface TableFingerprint {
|
||||||
|
table: string;
|
||||||
|
masterRows: number;
|
||||||
|
replicaRows: number;
|
||||||
|
/** Order-independent checksum over every column of every row. */
|
||||||
|
masterChecksum: string;
|
||||||
|
replicaChecksum: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
/** True only when every table matched on both count and checksum. */
|
||||||
|
identical: boolean;
|
||||||
|
tables: TableFingerprint[];
|
||||||
|
/** Set instead of `tables` when the comparison could not be run at all. */
|
||||||
|
problem: string | null;
|
||||||
|
checkedAt: string;
|
||||||
|
/** Wall-clock cost, because this is a full scan and the caller should see it. */
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReplicationStatus {
|
export interface ReplicationStatus {
|
||||||
/** false when the replica is not configured for this environment at all. */
|
/** false when the replica is not configured for this environment at all. */
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
@@ -58,11 +110,31 @@ export interface ReplicationStatus {
|
|||||||
sourceHost: string | null;
|
sourceHost: string | null;
|
||||||
/** Relay-log apply progress. Null when the status output has no positions. */
|
/** Relay-log apply progress. Null when the status output has no positions. */
|
||||||
apply: ApplyProgress | null;
|
apply: ApplyProgress | null;
|
||||||
|
/** GTID comparison against the master. Null when the master was unreachable. */
|
||||||
|
drift: GtidDrift | null;
|
||||||
/** Human-readable reason when healthy is false. */
|
/** Human-readable reason when healthy is false. */
|
||||||
problem: string | null;
|
problem: string | null;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tables `my.jorgecuadros.com` reads through the `web_reader` grant.
|
||||||
|
*
|
||||||
|
* This list is the verification surface, not the replication surface — the
|
||||||
|
* replica carries the whole schema. These are the eight whose divergence would
|
||||||
|
* actually be visible to a customer, so they are the ones worth a full scan.
|
||||||
|
*/
|
||||||
|
export const REPLICATED_TABLES = [
|
||||||
|
"transactions",
|
||||||
|
"customers",
|
||||||
|
"customer_legacy_refs",
|
||||||
|
"type_transactions",
|
||||||
|
"exchange_rates",
|
||||||
|
"properties",
|
||||||
|
"property_services",
|
||||||
|
"trust_accounts",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reports whether the my.jorgecuadros.com read replica is still replicating.
|
* Reports whether the my.jorgecuadros.com read replica is still replicating.
|
||||||
*
|
*
|
||||||
@@ -99,6 +171,7 @@ export class ReplicationService {
|
|||||||
lastSqlError: null,
|
lastSqlError: null,
|
||||||
sourceHost: null,
|
sourceHost: null,
|
||||||
apply: null,
|
apply: null,
|
||||||
|
drift: null,
|
||||||
problem: null,
|
problem: null,
|
||||||
checkedAt: now,
|
checkedAt: now,
|
||||||
};
|
};
|
||||||
@@ -109,33 +182,7 @@ export class ReplicationService {
|
|||||||
|
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
// --ssl is required: the replica sets require_secure_transport=ON.
|
raw = await this.onReplica("SHOW REPLICA STATUS\\G");
|
||||||
//
|
|
||||||
// --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off
|
|
||||||
// the website makes. This hop never leaves Tailscale — the replica is
|
|
||||||
// reached on its CGNAT tailnet address and its firewall admits only this
|
|
||||||
// host — so WireGuard already authenticates the peer. The DreamHost leg
|
|
||||||
// crosses the public internet and therefore pins the CA instead. The
|
|
||||||
// client here is MariaDB's, which rejects our self-signed CA outright
|
|
||||||
// unless it is handed the CA file, which would mean shipping a cert into
|
|
||||||
// this image for a link that is already authenticated.
|
|
||||||
const { stdout } = await exec(
|
|
||||||
"mysql",
|
|
||||||
[
|
|
||||||
`--host=${host}`,
|
|
||||||
`--user=${user}`,
|
|
||||||
"--ssl",
|
|
||||||
"--ssl-verify-server-cert=0",
|
|
||||||
"--connect-timeout=5",
|
|
||||||
"-e",
|
|
||||||
"SHOW REPLICA STATUS\\G",
|
|
||||||
],
|
|
||||||
{
|
|
||||||
env: { ...process.env, MYSQL_PWD: password },
|
|
||||||
timeout: 15_000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
raw = stdout;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
this.logger.warn(`no se pudo consultar la réplica: ${msg}`);
|
this.logger.warn(`no se pudo consultar la réplica: ${msg}`);
|
||||||
@@ -189,10 +236,341 @@ export class ReplicationService {
|
|||||||
// alarming on it would cry wolf. It is here to answer "is it moving?"
|
// alarming on it would cry wolf. It is here to answer "is it moving?"
|
||||||
// when the lag counter is stuck.
|
// when the lag counter is stuck.
|
||||||
apply: applyProgress(raw),
|
apply: applyProgress(raw),
|
||||||
|
// Also reported rather than alarmed on, for the same reason: a busy master
|
||||||
|
// is always a few transactions ahead for the instant they are in flight.
|
||||||
|
// Null rather than zero when the master could not be reached — "unknown"
|
||||||
|
// and "identical" must not render the same.
|
||||||
|
drift: await this.gtidDrift(),
|
||||||
problem,
|
problem,
|
||||||
checkedAt: now,
|
checkedAt: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare executed history between master and replica.
|
||||||
|
*
|
||||||
|
* Two round trips: ask the master what it has executed, then ask the replica
|
||||||
|
* to subtract its own history from that. The subtraction runs on the replica
|
||||||
|
* rather than in TypeScript because `GTID_SUBTRACT` already implements the
|
||||||
|
* interval algebra correctly, and reimplementing set subtraction over binlog
|
||||||
|
* ranges is exactly the kind of thing that looks right and is wrong at the
|
||||||
|
* boundaries.
|
||||||
|
*
|
||||||
|
* @returns null on any failure — a broken drift check must never be mistaken
|
||||||
|
* for a healthy zero.
|
||||||
|
*/
|
||||||
|
private async gtidDrift(): Promise<GtidDrift | null> {
|
||||||
|
try {
|
||||||
|
const masterGtid = (await this.onMaster("SELECT @@gtid_executed")).trim();
|
||||||
|
|
||||||
|
// GTID sets are UUIDs, digits, colons, commas, hyphens, whitespace and
|
||||||
|
// (since 8.4) alphanumeric tags. Nothing else is legal, so rejecting
|
||||||
|
// anything outside that alphabet is a whitelist, not a blacklist: with no
|
||||||
|
// quote and no backslash able to survive it, the value cannot escape the
|
||||||
|
// string literal it is interpolated into below.
|
||||||
|
if (masterGtid && !/^[0-9a-fA-F:,\s_-]+$/.test(masterGtid)) {
|
||||||
|
this.logger.warn("gtid_executed del maestro con formato inesperado");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// An empty set means the master has GTID mode off, and there is nothing
|
||||||
|
// meaningful to compare.
|
||||||
|
if (!masterGtid) return null;
|
||||||
|
|
||||||
|
const flat = masterGtid.replace(/\s+/g, "");
|
||||||
|
// Every GTID set is flattened with REPLACE before it leaves the server.
|
||||||
|
// MySQL wraps `gtid_executed` across lines once it holds more than one
|
||||||
|
// source UUID, and this is read back as tab-separated columns — an
|
||||||
|
// embedded newline would split one row into two and silently truncate the
|
||||||
|
// set at the first UUID.
|
||||||
|
const out = await this.onReplica(
|
||||||
|
"SELECT REPLACE(GTID_SUBTRACT(" +
|
||||||
|
`'${flat}', @@gtid_executed), '\\n', ''), ` +
|
||||||
|
"@@server_uuid, REPLACE(@@gtid_executed, '\\n', '')",
|
||||||
|
["-N"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trailing newline only — never `.trim()`. When nothing is missing the
|
||||||
|
// first column is the empty string, so the line begins with a tab, and
|
||||||
|
// trimming it would shift every column one position left and report the
|
||||||
|
// replica's own UUID as the missing GTID set.
|
||||||
|
const [missingSet = "", serverUuid = "", executed = ""] = out
|
||||||
|
.replace(/\r?\n+$/, "")
|
||||||
|
.split("\t");
|
||||||
|
|
||||||
|
return {
|
||||||
|
missingTransactions: countGtids(missingSet),
|
||||||
|
missingGtidSet: missingSet || null,
|
||||||
|
localTransactions: countGtids(gtidsForUuid(executed, serverUuid)),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
this.logger.warn(`no se pudo comparar GTIDs con el maestro: ${msg}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-scan comparison of the customer-visible tables on both sides.
|
||||||
|
*
|
||||||
|
* Deliberately NOT part of `status()`: this reads every row of every table in
|
||||||
|
* `REPLICATED_TABLES` on both servers, so it belongs behind a button, not a
|
||||||
|
* 30-second poll.
|
||||||
|
*
|
||||||
|
* It answers the one question GTID drift cannot. GTIDs prove the replica
|
||||||
|
* applied every transaction the master produced; they say nothing about rows
|
||||||
|
* changed on the replica by some other route. A local write is invisible to
|
||||||
|
* every other field on the card and shows up here as a checksum mismatch.
|
||||||
|
*/
|
||||||
|
async verify(): Promise<VerifyResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const base: VerifyResult = {
|
||||||
|
identical: false,
|
||||||
|
tables: [],
|
||||||
|
problem: null,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql: string;
|
||||||
|
try {
|
||||||
|
sql = await this.fingerprintSql();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ...base, problem: `No se pudo leer el esquema: ${msg}`, elapsedMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
|
||||||
|
let masterOut: string;
|
||||||
|
let replicaOut: string;
|
||||||
|
try {
|
||||||
|
// Sequential, not parallel. Running both at once would have the master
|
||||||
|
// scan under the replica's own read load only sometimes, which makes a
|
||||||
|
// slow run hard to attribute; and the boxes are small enough that two
|
||||||
|
// concurrent full scans is a real memory event on the 946MB replica.
|
||||||
|
masterOut = await this.onMaster(sql);
|
||||||
|
replicaOut = await this.onReplica(sql);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return { ...base, problem: `No se pudo comparar: ${msg}`, elapsedMs: Date.now() - started };
|
||||||
|
}
|
||||||
|
|
||||||
|
const master = parseFingerprints(masterOut);
|
||||||
|
const replica = parseFingerprints(replicaOut);
|
||||||
|
|
||||||
|
const tables: TableFingerprint[] = REPLICATED_TABLES.map((table) => {
|
||||||
|
const m = master.get(table);
|
||||||
|
const r = replica.get(table);
|
||||||
|
return {
|
||||||
|
table,
|
||||||
|
masterRows: m?.rows ?? -1,
|
||||||
|
replicaRows: r?.rows ?? -1,
|
||||||
|
masterChecksum: m?.checksum ?? "?",
|
||||||
|
replicaChecksum: r?.checksum ?? "?",
|
||||||
|
// Both sides must have answered. A missing row on either side is a
|
||||||
|
// mismatch, never a pass — `undefined === undefined` would otherwise
|
||||||
|
// report two failed reads as agreement.
|
||||||
|
matches:
|
||||||
|
m !== undefined && r !== undefined && m.rows === r.rows && m.checksum === r.checksum,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
identical: tables.every((t) => t.matches),
|
||||||
|
tables,
|
||||||
|
problem: null,
|
||||||
|
checkedAt: base.checkedAt,
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the count+checksum query from the live column list.
|
||||||
|
*
|
||||||
|
* The columns come from `information_schema` on the master rather than being
|
||||||
|
* hardcoded, so the check keeps covering the whole row after a migration adds
|
||||||
|
* one. Reading the schema from the master is safe by construction: if the two
|
||||||
|
* schemas had diverged, replication would already be broken.
|
||||||
|
*/
|
||||||
|
private async fingerprintSql(): Promise<string> {
|
||||||
|
const list = REPLICATED_TABLES.map((t) => `'${t}'`).join(",");
|
||||||
|
const raw = await this.onMaster(
|
||||||
|
"SELECT CONCAT(TABLE_NAME, '\\t', COLUMN_NAME) FROM information_schema.COLUMNS " +
|
||||||
|
`WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${list}) ` +
|
||||||
|
"ORDER BY TABLE_NAME, ORDINAL_POSITION",
|
||||||
|
["-N"],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cols = new Map<string, string[]>();
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const [table, column] = line.trim().split("\t");
|
||||||
|
if (!table || !column) continue;
|
||||||
|
cols.set(table, [...(cols.get(table) ?? []), column]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selects = REPLICATED_TABLES.map((table) => {
|
||||||
|
const columns = cols.get(table);
|
||||||
|
if (!columns?.length) throw new Error(`tabla ${table} sin columnas`);
|
||||||
|
// CONVERT(... USING binary), never CAST(... AS CHAR).
|
||||||
|
//
|
||||||
|
// CAST to CHAR transcodes into the *connection* character set, which is
|
||||||
|
// not the same on the two servers: the mysql client inside the master's
|
||||||
|
// container negotiates latin1, while the replica's negotiates utf8mb4.
|
||||||
|
// Every accented character in a Mexican name, street or note therefore
|
||||||
|
// hashes to different bytes on each side, and the comparison reports a
|
||||||
|
// permanent mismatch on exactly the tables that hold free text — a
|
||||||
|
// verification tool that always cries wolf, which is worse than none.
|
||||||
|
// Comparing the stored bytes sidesteps the session entirely. (Verified
|
||||||
|
// 2026-08-06: with CAST, `customers.name` gave 3344437324815 vs
|
||||||
|
// 3339150372121; with CONVERT both give 3339150372121.)
|
||||||
|
//
|
||||||
|
// 0x1f (unit separator) joins the columns and 0x1e (record separator)
|
||||||
|
// stands in for NULL. Both matter: CONCAT_WS *skips* NULLs rather than
|
||||||
|
// emitting an empty field, so without a placeholder the rows
|
||||||
|
// ('a', NULL, 'b') and ('a', 'b', NULL) produce the same string and a
|
||||||
|
// column-shifting bug would checksum as identical.
|
||||||
|
const expr = columns
|
||||||
|
.map((c) => `IFNULL(CONVERT(\`${c}\` USING binary), 0x1e)`)
|
||||||
|
.join(", 0x1f, ");
|
||||||
|
// SUM, not a running hash: addition is commutative, so the result does not
|
||||||
|
// depend on the order rows come back in. The two servers have no reason to
|
||||||
|
// scan in the same order and are not asked to.
|
||||||
|
return (
|
||||||
|
`SELECT '${table}' AS t, COUNT(*) AS n, ` +
|
||||||
|
`IFNULL(SUM(CRC32(CONCAT_WS(0x1f, ${expr}))), 0) AS c FROM \`${table}\``
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return selects.join(" UNION ALL ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ plumbing */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a statement on the replica.
|
||||||
|
*
|
||||||
|
* --ssl is required: the replica sets require_secure_transport=ON.
|
||||||
|
*
|
||||||
|
* --ssl-verify-server-cert=0 is deliberate and is NOT the same trade-off the
|
||||||
|
* website makes. This hop never leaves Tailscale — the replica is reached on
|
||||||
|
* its CGNAT tailnet address and the tailnet ACL admits only this host — so
|
||||||
|
* WireGuard already authenticates the peer. The DreamHost leg crosses the
|
||||||
|
* public internet and therefore pins the CA instead. The client here is
|
||||||
|
* MariaDB's, which rejects our self-signed CA outright unless it is handed the
|
||||||
|
* CA file, which would mean shipping a cert into this image for a link that is
|
||||||
|
* already authenticated.
|
||||||
|
*/
|
||||||
|
private async onReplica(sql: string, extra: string[] = []): Promise<string> {
|
||||||
|
const host = process.env.REPLICA_DB_HOST!;
|
||||||
|
const user = process.env.REPLICA_DB_USER!;
|
||||||
|
const password = process.env.REPLICA_DB_PASS!;
|
||||||
|
const { stdout } = await exec(
|
||||||
|
"mysql",
|
||||||
|
[
|
||||||
|
`--host=${host}`,
|
||||||
|
`--user=${user}`,
|
||||||
|
"--ssl",
|
||||||
|
"--ssl-verify-server-cert=0",
|
||||||
|
"--connect-timeout=5",
|
||||||
|
...extra,
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
],
|
||||||
|
{ env: { ...process.env, MYSQL_PWD: password }, timeout: VERIFY_TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a statement on the master, using the application's own DATABASE_URL.
|
||||||
|
*
|
||||||
|
* The app credential is enough here on purpose — everything this class sends
|
||||||
|
* to the master is a SELECT against `information_schema` or a system variable.
|
||||||
|
* Reaching for OPS_DB_ADMIN_* the way OpsService does would hand a monitoring
|
||||||
|
* read path a credential that can also restore a dump.
|
||||||
|
*/
|
||||||
|
private async onMaster(sql: string, extra: string[] = []): Promise<string> {
|
||||||
|
const raw = process.env.DATABASE_URL;
|
||||||
|
if (!raw) throw new Error("DATABASE_URL no está configurada");
|
||||||
|
const u = new URL(raw);
|
||||||
|
const { stdout } = await exec(
|
||||||
|
"mysql",
|
||||||
|
[
|
||||||
|
`--host=${u.hostname}`,
|
||||||
|
`--port=${u.port || "3306"}`,
|
||||||
|
`--user=${decodeURIComponent(u.username)}`,
|
||||||
|
"--connect-timeout=5",
|
||||||
|
...extra,
|
||||||
|
"-N",
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
u.pathname.replace(/^\//, ""),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: { ...process.env, MYSQL_PWD: decodeURIComponent(u.password) },
|
||||||
|
timeout: VERIFY_TIMEOUT_MS,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full scans on a 1-vCPU replica are not fast; 15s would cut them off. */
|
||||||
|
const VERIFY_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
/** Parse the `t\tn\tc` rows the fingerprint query emits under `mysql -N`. */
|
||||||
|
function parseFingerprints(raw: string): Map<string, { rows: number; checksum: string }> {
|
||||||
|
const out = new Map<string, { rows: number; checksum: string }>();
|
||||||
|
for (const line of raw.split("\n")) {
|
||||||
|
const [table, n, c] = line.trim().split("\t");
|
||||||
|
if (!table || n === undefined || c === undefined) continue;
|
||||||
|
const rows = Number(n);
|
||||||
|
if (!Number.isFinite(rows)) continue;
|
||||||
|
// The checksum stays a string. Sums of CRC32 over 40k rows exceed 2^53, so
|
||||||
|
// parsing it as a number would round and make distinct tables compare equal.
|
||||||
|
out.set(table, { rows, checksum: c });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count the transactions in a GTID set.
|
||||||
|
*
|
||||||
|
* Exported for testing. The format is `uuid[:tag]:interval[:interval]...`,
|
||||||
|
* comma-separated, where an interval is `N` or `N-M` inclusive at both ends —
|
||||||
|
* so `1-5` is five transactions, not four.
|
||||||
|
*
|
||||||
|
* MySQL 8.4 added an optional alphanumeric tag between the UUID and the first
|
||||||
|
* interval. It is skipped rather than parsed: any segment that is not a number
|
||||||
|
* or a number range is not an interval, whatever else it may be.
|
||||||
|
*/
|
||||||
|
export function countGtids(set: string): number {
|
||||||
|
if (!set.trim()) return 0;
|
||||||
|
let total = 0;
|
||||||
|
for (const group of set.split(",")) {
|
||||||
|
for (const part of group.trim().split(":").slice(1)) {
|
||||||
|
const m = /^(\d+)(?:-(\d+))?$/.exec(part.trim());
|
||||||
|
if (!m) continue;
|
||||||
|
const from = Number(m[1]);
|
||||||
|
const to = m[2] === undefined ? from : Number(m[2]);
|
||||||
|
if (Number.isFinite(from) && Number.isFinite(to) && to >= from) total += to - from + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow a GTID set to the intervals belonging to one server UUID.
|
||||||
|
*
|
||||||
|
* Exported for testing. Used to isolate the replica's own writes from the
|
||||||
|
* history it replicated, which are interleaved in the same `gtid_executed`.
|
||||||
|
*/
|
||||||
|
export function gtidsForUuid(set: string, uuid: string): string {
|
||||||
|
if (!uuid.trim()) return "";
|
||||||
|
const wanted = uuid.trim().toLowerCase();
|
||||||
|
return set
|
||||||
|
.split(",")
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter((g) => g.toLowerCase().startsWith(`${wanted}:`))
|
||||||
|
.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { applyProgress, replicaField } from "./replication.service";
|
import {
|
||||||
|
applyProgress,
|
||||||
|
countGtids,
|
||||||
|
gtidsForUuid,
|
||||||
|
replicaField,
|
||||||
|
} from "./replication.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to
|
* Verbatim shape of `SHOW REPLICA STATUS\G` from the live replica, trimmed to
|
||||||
@@ -178,3 +183,72 @@ describe("applyProgress", () => {
|
|||||||
expect(applyProgress(positions("binlog.000042", "NULL", "binlog.000042", 400))).toBeNull();
|
expect(applyProgress(positions("binlog.000042", "NULL", "binlog.000042", 400))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real GTID sets from the live pair, captured 2026-08-06. The replica's own
|
||||||
|
* server UUID (3b103283…) carries the transactions the seed dump load executed
|
||||||
|
* locally; the master's UUID (defc34e2…) carries the replicated history.
|
||||||
|
*/
|
||||||
|
const REPLICA_EXECUTED =
|
||||||
|
"3b103283-8f15-11f1-a52b-020017027b33:1-513," +
|
||||||
|
"defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-525";
|
||||||
|
const REPLICA_UUID = "3b103283-8f15-11f1-a52b-020017027b33";
|
||||||
|
|
||||||
|
describe("countGtids", () => {
|
||||||
|
it("counts an inclusive range at both ends", () => {
|
||||||
|
// 1-5 is five transactions. Off-by-one here understates the gap, which is
|
||||||
|
// the direction that hides a problem.
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5")).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a bare single transaction", () => {
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:7")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums several intervals under one UUID", () => {
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:1-5:8:10-12")).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums across UUIDs, including the wrapped form MySQL prints", () => {
|
||||||
|
expect(countGtids(REPLICA_EXECUTED)).toBe(513 + 525);
|
||||||
|
// `gtid_executed` comes back wrapped once it holds more than one UUID.
|
||||||
|
expect(countGtids(REPLICA_EXECUTED.replace(",", ",\n"))).toBe(513 + 525);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** An empty subtraction result is the caught-up case and must be zero. */
|
||||||
|
it("returns 0 for an empty or blank set", () => {
|
||||||
|
expect(countGtids("")).toBe(0);
|
||||||
|
expect(countGtids(" \n ")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL 8.4 allows an alphanumeric tag between the UUID and the intervals.
|
||||||
|
* It is not an interval and must not be counted as one.
|
||||||
|
*/
|
||||||
|
it("skips a tag without counting it", () => {
|
||||||
|
expect(countGtids("defc34e2-8c5d-11f1-8e58-52c4c853bce8:mytag:1-3")).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gtidsForUuid", () => {
|
||||||
|
it("isolates the replica's own transactions from the replicated history", () => {
|
||||||
|
expect(countGtids(gtidsForUuid(REPLICA_EXECUTED, REPLICA_UUID))).toBe(513);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a UUID that is not in the set", () => {
|
||||||
|
expect(gtidsForUuid(REPLICA_EXECUTED, "00000000-0000-0000-0000-000000000000")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The colon matters. Without it a UUID prefix would match a longer UUID that
|
||||||
|
* merely starts the same way, and the replica's local writes would be
|
||||||
|
* over-reported.
|
||||||
|
*/
|
||||||
|
it("does not match on a bare prefix", () => {
|
||||||
|
expect(gtidsForUuid(REPLICA_EXECUTED, "3b103283")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when the UUID is blank", () => {
|
||||||
|
expect(gtidsForUuid(REPLICA_EXECUTED, "")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsEnum, IsOptional, IsString } from "class-validator";
|
import { IsBoolean, IsEnum, IsOptional, IsString } from "class-validator";
|
||||||
import { OpsJobKind } from "@jorgecuadros/database";
|
import { OpsJobKind } from "@jorgecuadros/database";
|
||||||
|
|
||||||
export class StartJobDto {
|
export class StartJobDto {
|
||||||
@@ -9,4 +9,13 @@ export class StartJobDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
file?: string;
|
file?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REIMPORT only: proceed even though the rebuild deletes rows that exist only
|
||||||
|
* in the platform. Off by default, so the guard in run_all.py stops the job
|
||||||
|
* and lists what would be lost rather than the operator finding out after.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
forceFull?: boolean;
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,10 @@ import { Currency } from "@jorgecuadros/database";
|
|||||||
// Each child DTO covers create; updates reuse the same shape with all fields
|
// Each child DTO covers create; updates reuse the same shape with all fields
|
||||||
// optional via the corresponding Update class. Route supplies the policyId.
|
// optional via the corresponding Update class. Route supplies the policyId.
|
||||||
|
|
||||||
|
// A policy split into several exhibiciones prices each payment on its own —
|
||||||
|
// the Access form printed the whole money row once per pago — so the premium
|
||||||
|
// breakdown repeats here. `amount` remains what was actually collected and is
|
||||||
|
// never recomputed from the breakdown; the two differ by rounding in the books.
|
||||||
export class InstallmentDto {
|
export class InstallmentDto {
|
||||||
@IsInt() sequence!: number;
|
@IsInt() sequence!: number;
|
||||||
@IsOptional() @IsNumber() amount?: number;
|
@IsOptional() @IsNumber() amount?: number;
|
||||||
@@ -20,6 +24,13 @@ export class InstallmentDto {
|
|||||||
@IsOptional() @IsString() paidDate?: string;
|
@IsOptional() @IsString() paidDate?: string;
|
||||||
@IsOptional() @IsString() checkNumber?: string;
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||||
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
}
|
}
|
||||||
export class UpdateInstallmentDto {
|
export class UpdateInstallmentDto {
|
||||||
@IsOptional() @IsInt() sequence?: number;
|
@IsOptional() @IsInt() sequence?: number;
|
||||||
@@ -29,6 +40,13 @@ export class UpdateInstallmentDto {
|
|||||||
@IsOptional() @IsString() paidDate?: string;
|
@IsOptional() @IsString() paidDate?: string;
|
||||||
@IsOptional() @IsString() checkNumber?: string;
|
@IsOptional() @IsString() checkNumber?: string;
|
||||||
@IsOptional() @IsBoolean() isCash?: boolean;
|
@IsOptional() @IsBoolean() isCash?: boolean;
|
||||||
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VehicleDto {
|
export class VehicleDto {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { PoliciesService } from "./policies.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a lookup row that policies still reference used to succeed and
|
||||||
|
* silently blank the field on every one of them, because both FKs are
|
||||||
|
* `ON DELETE SET NULL` (`0000_init`). That is not a hypothetical: it is how
|
||||||
|
* the `M_EMPR` policy type disappeared from the dev database and left 5
|
||||||
|
* policies with a null `policyTypeId`, found only by querying months later.
|
||||||
|
*
|
||||||
|
* These tests pin the refusal. They drive the service with a stub client
|
||||||
|
* rather than a database because what is being asserted is the guard, not
|
||||||
|
* Prisma — and a test that needed a live MySQL would not run in CI.
|
||||||
|
*/
|
||||||
|
function serviceWith(counts: {
|
||||||
|
policies?: number;
|
||||||
|
claims?: number;
|
||||||
|
}): { service: PoliciesService; deleted: string[] } {
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const prisma = {
|
||||||
|
policy: { count: async () => counts.policies ?? 0 },
|
||||||
|
claim: { count: async () => counts.claims ?? 0 },
|
||||||
|
insuranceProvider: {
|
||||||
|
findUnique: async () => ({ id: "p1", name: "ANA SEGUROS" }),
|
||||||
|
delete: async () => {
|
||||||
|
deleted.push("provider");
|
||||||
|
return { id: "p1" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
policyType: {
|
||||||
|
findUnique: async () => ({ id: "t1", name: "M_EMPR" }),
|
||||||
|
delete: async () => {
|
||||||
|
deleted.push("policyType");
|
||||||
|
return { id: "t1" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adjuster: {
|
||||||
|
findUnique: async () => ({ id: "a1", name: "JUAN PEREZ" }),
|
||||||
|
delete: async () => {
|
||||||
|
deleted.push("adjuster");
|
||||||
|
return { id: "a1" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const storage = {} as never;
|
||||||
|
return {
|
||||||
|
service: new PoliciesService(prisma as never, storage),
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("lookup deletes refuse while the row is in use", () => {
|
||||||
|
it("refuses a policy type that policies still carry, and names the count", () => {
|
||||||
|
const { service, deleted } = serviceWith({ policies: 5 });
|
||||||
|
return service.removePolicyType("t1").then(
|
||||||
|
() => {
|
||||||
|
throw new Error("expected the delete to be refused");
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
expect(err).toBeInstanceOf(BadRequestException);
|
||||||
|
// The operator has to be told WHICH row and HOW MANY, or the message
|
||||||
|
// is not actionable.
|
||||||
|
expect((err as Error).message).toContain("M_EMPR");
|
||||||
|
expect((err as Error).message).toContain("5");
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a carrier that policies still carry", async () => {
|
||||||
|
const { service, deleted } = serviceWith({ policies: 738 });
|
||||||
|
await expect(service.removeProvider("p1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an adjuster still assigned to claims", async () => {
|
||||||
|
// Same `ON DELETE SET NULL` trap, on `claims.adjusterId`.
|
||||||
|
const { service, deleted } = serviceWith({ claims: 2 });
|
||||||
|
await expect(service.removeAdjuster("a1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(deleted).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the delete once nothing references the row", async () => {
|
||||||
|
const { service, deleted } = serviceWith({ policies: 0, claims: 0 });
|
||||||
|
await service.removePolicyType("t1");
|
||||||
|
await service.removeProvider("p1");
|
||||||
|
await service.removeAdjuster("a1");
|
||||||
|
expect(deleted).toEqual(["policyType", "provider", "adjuster"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, MinLength } from "class-validator";
|
import { IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator";
|
||||||
|
|
||||||
export class ProviderDto {
|
export class ProviderDto {
|
||||||
@IsString() @MinLength(1) name!: string;
|
@IsString() @MinLength(1) name!: string;
|
||||||
@@ -7,13 +7,19 @@ export class UpdateProviderDto {
|
|||||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `taxRate` is the IVA fraction for this line of business (0.08 = 8%), the
|
||||||
|
// legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made editable. Bounded at
|
||||||
|
// 1 because a rate is a fraction, not a percentage: 8 entered here would tax a
|
||||||
|
// $600 premium $4,800, and the mistake is easy to make.
|
||||||
export class PolicyTypeDto {
|
export class PolicyTypeDto {
|
||||||
@IsString() @MinLength(1) name!: string;
|
@IsString() @MinLength(1) name!: string;
|
||||||
@IsOptional() @IsString() shortDescription?: string;
|
@IsOptional() @IsString() shortDescription?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||||
}
|
}
|
||||||
export class UpdatePolicyTypeDto {
|
export class UpdatePolicyTypeDto {
|
||||||
@IsOptional() @IsString() @MinLength(1) name?: string;
|
@IsOptional() @IsString() @MinLength(1) name?: string;
|
||||||
@IsOptional() @IsString() shortDescription?: string;
|
@IsOptional() @IsString() shortDescription?: string;
|
||||||
|
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdjusterDto {
|
export class AdjusterDto {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
@@ -250,7 +250,16 @@ export class PoliciesService {
|
|||||||
const [types, providers] = await this.prisma.$transaction([
|
const [types, providers] = await this.prisma.$transaction([
|
||||||
this.prisma.policyType.findMany({
|
this.prisma.policyType.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true, _count: { select: { policies: true } } },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
shortDescription: true,
|
||||||
|
// The capture form computes IVA client-side as the operator types,
|
||||||
|
// so the rate has to travel with the type list it already loads —
|
||||||
|
// an extra round-trip per keystroke is not an option.
|
||||||
|
taxRate: true,
|
||||||
|
_count: { select: { policies: true } },
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.insuranceProvider.findMany({
|
this.prisma.insuranceProvider.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
@@ -259,7 +268,13 @@ export class PoliciesService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
|
types: types.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
shortDescription: t.shortDescription,
|
||||||
|
taxRate: t.taxRate,
|
||||||
|
count: t._count.policies,
|
||||||
|
})),
|
||||||
providers: providers.map((p) => ({
|
providers: providers.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
@@ -397,6 +412,13 @@ export class PoliciesService {
|
|||||||
paidDate: toDate(dto.paidDate) ?? undefined,
|
paidDate: toDate(dto.paidDate) ?? undefined,
|
||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
isCash: dto.isCash,
|
isCash: dto.isCash,
|
||||||
|
netPremium: dto.netPremium,
|
||||||
|
surcharge: dto.surcharge,
|
||||||
|
policyFee: dto.policyFee,
|
||||||
|
tax: dto.tax,
|
||||||
|
taxRate: dto.taxRate,
|
||||||
|
total: dto.total,
|
||||||
|
commission: dto.commission,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -412,6 +434,13 @@ export class PoliciesService {
|
|||||||
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
|
||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
isCash: dto.isCash,
|
isCash: dto.isCash,
|
||||||
|
netPremium: dto.netPremium,
|
||||||
|
surcharge: dto.surcharge,
|
||||||
|
policyFee: dto.policyFee,
|
||||||
|
tax: dto.tax,
|
||||||
|
taxRate: dto.taxRate,
|
||||||
|
total: dto.total,
|
||||||
|
commission: dto.commission,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -554,7 +583,40 @@ export class PoliciesService {
|
|||||||
updateProvider(id: string, dto: UpdateProviderDto) {
|
updateProvider(id: string, dto: UpdateProviderDto) {
|
||||||
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
removeProvider(id: string) {
|
/**
|
||||||
|
* Deleting a lookup row that policies still point at is silent data loss.
|
||||||
|
*
|
||||||
|
* Both FKs are `ON DELETE SET NULL` (see `0000_init`), so the delete
|
||||||
|
* succeeds, returns 200, and blanks the field on every policy that used it
|
||||||
|
* — with no error and nothing in the UI to suggest anything happened. That
|
||||||
|
* is how the `M_EMPR` policy type disappeared and left 5 policies with a
|
||||||
|
* null `policyTypeId`, only found later by querying.
|
||||||
|
*
|
||||||
|
* Refusing is the whole fix. There is no "are you sure": the operator
|
||||||
|
* reassigns those policies first, which is work the app cannot do for them
|
||||||
|
* because only they know which type is correct.
|
||||||
|
*/
|
||||||
|
private async assertLookupUnused(
|
||||||
|
kind: "provider" | "policyType",
|
||||||
|
id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const where = kind === "provider" ? { insuranceProviderId: id } : { policyTypeId: id };
|
||||||
|
const count = await this.prisma.policy.count({ where });
|
||||||
|
if (count === 0) return;
|
||||||
|
|
||||||
|
const label =
|
||||||
|
kind === "provider"
|
||||||
|
? (await this.prisma.insuranceProvider.findUnique({ where: { id } }))?.name
|
||||||
|
: (await this.prisma.policyType.findUnique({ where: { id } }))?.name;
|
||||||
|
const noun = kind === "provider" ? "La aseguradora" : "El tipo de póliza";
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${noun} «${label ?? id}» está en uso por ${count} póliza(s). ` +
|
||||||
|
"Reasígnelas antes de eliminarlo.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeProvider(id: string) {
|
||||||
|
await this.assertLookupUnused("provider", id);
|
||||||
return this.prisma.insuranceProvider.delete({ where: { id } });
|
return this.prisma.insuranceProvider.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,7 +626,8 @@ export class PoliciesService {
|
|||||||
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
|
||||||
return this.prisma.policyType.update({ where: { id }, data: dto });
|
return this.prisma.policyType.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
removePolicyType(id: string) {
|
async removePolicyType(id: string) {
|
||||||
|
await this.assertLookupUnused("policyType", id);
|
||||||
return this.prisma.policyType.delete({ where: { id } });
|
return this.prisma.policyType.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +637,17 @@ export class PoliciesService {
|
|||||||
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
|
||||||
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
return this.prisma.adjuster.update({ where: { id }, data: dto });
|
||||||
}
|
}
|
||||||
removeAdjuster(id: string) {
|
/** Same `ON DELETE SET NULL` trap as the two above, on `claims.adjusterId`:
|
||||||
|
* deleting a busy adjuster would quietly strip them off their claims. */
|
||||||
|
async removeAdjuster(id: string) {
|
||||||
|
const count = await this.prisma.claim.count({ where: { adjusterId: id } });
|
||||||
|
if (count > 0) {
|
||||||
|
const row = await this.prisma.adjuster.findUnique({ where: { id } });
|
||||||
|
throw new BadRequestException(
|
||||||
|
`El ajustador «${row?.name ?? id}» está asignado a ${count} siniestro(s). ` +
|
||||||
|
"Reasígnelos antes de eliminarlo.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.prisma.adjuster.delete({ where: { id } });
|
return this.prisma.adjuster.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,16 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Currency } from "@jorgecuadros/database";
|
import { Currency, PaymentFrequency } from "@jorgecuadros/database";
|
||||||
import { IsEnum } from "class-validator";
|
import { IsEnum } from "class-validator";
|
||||||
|
|
||||||
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
|
||||||
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
* exposed for editing. Dates arrive as ISO strings and are coerced by the
|
||||||
* service. `total` is legacy-dead data — the UI uses netPremium. */
|
* service. `total` is legacy-dead data on migrated rows — list and sort code
|
||||||
|
* still uses netPremium — but the capture form writes it going forward, along
|
||||||
|
* with `tax`, from the arithmetic in premium.ts. Both arrive as plain numbers
|
||||||
|
* rather than being recomputed server-side: the printed policy is the record
|
||||||
|
* of truth and staff must be able to key its rounding verbatim. */
|
||||||
export class CreatePolicyDto {
|
export class CreatePolicyDto {
|
||||||
@IsString() @MinLength(1) policyNumber!: string;
|
@IsString() @MinLength(1) policyNumber!: string;
|
||||||
@IsString() @MinLength(1) customerId!: string;
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
@@ -24,10 +28,14 @@ export class CreatePolicyDto {
|
|||||||
@IsOptional() @IsString() policyTo?: string;
|
@IsOptional() @IsString() policyTo?: string;
|
||||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||||
@IsOptional() @IsNumber() netPremium?: number;
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
@IsOptional() @IsNumber() policyFee?: number;
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
@IsOptional() @IsNumber() commission?: number;
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
@IsOptional() @IsString() observations?: string;
|
@IsOptional() @IsString() observations?: string;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
@@ -48,10 +56,14 @@ export class UpdatePolicyDto {
|
|||||||
@IsOptional() @IsString() policyTo?: string;
|
@IsOptional() @IsString() policyTo?: string;
|
||||||
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
@IsOptional() @IsInt() coveragePeriodDays?: number;
|
||||||
@IsOptional() @IsNumber() netPremium?: number;
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
|
@IsOptional() @IsNumber() surcharge?: number;
|
||||||
@IsOptional() @IsNumber() policyFee?: number;
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
@IsOptional() @IsNumber() commission?: number;
|
@IsOptional() @IsNumber() commission?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
|
@IsOptional() @IsNumber() taxRate?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
|
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
|
||||||
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
@IsOptional() @IsEnum(Currency) currency?: Currency;
|
||||||
@IsOptional() @IsString() observations?: string;
|
@IsOptional() @IsString() observations?: string;
|
||||||
@IsOptional() @IsString() notes?: string;
|
@IsOptional() @IsString() notes?: string;
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_TAX_RATE,
|
||||||
|
computeTax,
|
||||||
|
computeTotal,
|
||||||
|
resolveTaxRate,
|
||||||
|
surchargeApplies,
|
||||||
|
taxableBase,
|
||||||
|
} from "./premium";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reference case is policy 7006785 (MULT, semestral, GMX, two payments) as
|
||||||
|
* it stands in the Access books — the screen Jorge sent. Both of its money
|
||||||
|
* rows are asserted, because the second one is the case that proves the
|
||||||
|
* surcharge belongs in the taxable base and that a zero policy fee is a real
|
||||||
|
* value rather than a missing one.
|
||||||
|
*/
|
||||||
|
describe("premium arithmetic", () => {
|
||||||
|
it("matches the first payment of policy 7006785", () => {
|
||||||
|
const parts = { netPremium: 610.86, surcharge: 8.55, policyFee: 31.0 };
|
||||||
|
expect(taxableBase(parts)).toBe(650.41);
|
||||||
|
expect(computeTax(parts, 0.08)).toBe(52.03);
|
||||||
|
expect(computeTotal(parts, 0.08)).toBe(702.44);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches the second payment of policy 7006785", () => {
|
||||||
|
const parts = { netPremium: 589.71, surcharge: 8.26, policyFee: 0 };
|
||||||
|
expect(computeTax(parts, 0.08)).toBe(47.84);
|
||||||
|
expect(computeTotal(parts, 0.08)).toBe(645.81);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excluding the surcharge does NOT reconcile", () => {
|
||||||
|
// Guards the one decision in this module that is easy to get wrong: the
|
||||||
|
// spoken-language version of the rule ("prima neta + derecho * 8%") gives
|
||||||
|
// 51.35, and the printed policy says 52.03.
|
||||||
|
const withoutSurcharge = { netPremium: 610.86, surcharge: 0, policyFee: 31.0 };
|
||||||
|
expect(computeTax(withoutSurcharge, 0.08)).not.toBe(52.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats blank and null money as zero, not NaN", () => {
|
||||||
|
expect(taxableBase({ netPremium: "610.86", surcharge: null, policyFee: "" })).toBe(
|
||||||
|
610.86,
|
||||||
|
);
|
||||||
|
expect(computeTax({ netPremium: undefined, surcharge: null, policyFee: null }, 0.08))
|
||||||
|
.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds half-up to cents", () => {
|
||||||
|
// 100.06 * 0.08 = 8.0048 -> 8.00; 100.13 * 0.08 = 8.0104 -> 8.01.
|
||||||
|
expect(computeTax({ netPremium: 100.06, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8);
|
||||||
|
expect(computeTax({ netPremium: 100.13, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("surchargeApplies", () => {
|
||||||
|
it("is false for the two single-payment frequencies", () => {
|
||||||
|
expect(surchargeApplies("ANNUAL")).toBe(false);
|
||||||
|
expect(surchargeApplies("SINGLE")).toBe(false);
|
||||||
|
});
|
||||||
|
it("is true for every split frequency", () => {
|
||||||
|
expect(surchargeApplies("SEMIANNUAL")).toBe(true);
|
||||||
|
expect(surchargeApplies("QUARTERLY")).toBe(true);
|
||||||
|
expect(surchargeApplies("MONTHLY")).toBe(true);
|
||||||
|
});
|
||||||
|
it("allows it when the frequency is unknown", () => {
|
||||||
|
// Every migrated policy is null here — the original ETL dropped FORMA
|
||||||
|
// PAGO — and those rows DO carry recargo figures in the legacy data.
|
||||||
|
expect(surchargeApplies(null)).toBe(true);
|
||||||
|
expect(surchargeApplies(undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTaxRate", () => {
|
||||||
|
it("prefers the rate the policy was issued at", () => {
|
||||||
|
expect(resolveTaxRate(0.16, 0.08)).toBe(0.16);
|
||||||
|
});
|
||||||
|
it("falls back to the line of business", () => {
|
||||||
|
expect(resolveTaxRate(null, 0.08)).toBe(0.08);
|
||||||
|
});
|
||||||
|
it("falls back to the default when nothing is configured", () => {
|
||||||
|
expect(resolveTaxRate(null, null)).toBe(DEFAULT_TAX_RATE);
|
||||||
|
expect(resolveTaxRate(undefined, "")).toBe(DEFAULT_TAX_RATE);
|
||||||
|
});
|
||||||
|
it("accepts a zero rate as a real choice, not as absent", () => {
|
||||||
|
// An exempt line of business must read 0, not silently fall through to 8%.
|
||||||
|
expect(resolveTaxRate(null, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
it("accepts Prisma's decimal strings", () => {
|
||||||
|
expect(resolveTaxRate(null, "0.0800")).toBe(0.08);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* The premium arithmetic the Access capture form did in unbound calculated
|
||||||
|
* controls, moved somewhere it can be tested.
|
||||||
|
*
|
||||||
|
* Two figures are derived, everything else is keyed by hand:
|
||||||
|
*
|
||||||
|
* base = netPremium + surcharge + policyFee
|
||||||
|
* tax = round(base * rate)
|
||||||
|
* total = base + tax
|
||||||
|
*
|
||||||
|
* The surcharge IS part of the taxable base. That is not an assumption — it is
|
||||||
|
* the only reading that reconciles the books. Policy 7006785 (MULT, semestral,
|
||||||
|
* two payments) prints IVA 52.03 and 47.84 against net premiums 610.86 / 589.71,
|
||||||
|
* surcharges 8.55 / 8.26 and policy fees 31.00 / 0.00; excluding the surcharge
|
||||||
|
* gives 51.35, which matches nothing on the page.
|
||||||
|
*
|
||||||
|
* The surcharge itself is NEVER derived. It is the carrier's financing charge
|
||||||
|
* for paying in installments, quoted per policy, so staff key it in. It only
|
||||||
|
* ever appears on a policy that is not paid annually or in a single exhibición
|
||||||
|
* — `surchargeApplies` is what the UI uses to grey the field out.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Used when neither the policy nor its type carries a rate. Matches the
|
||||||
|
* single row both legacy IMPUESTOS tables held (0.08 = 8%). */
|
||||||
|
export const DEFAULT_TAX_RATE = 0.08;
|
||||||
|
|
||||||
|
export type PaymentFrequencyValue =
|
||||||
|
| "ANNUAL"
|
||||||
|
| "SEMIANNUAL"
|
||||||
|
| "QUARTERLY"
|
||||||
|
| "MONTHLY"
|
||||||
|
| "SINGLE";
|
||||||
|
|
||||||
|
/** Paying in more than one exhibición is what earns a surcharge. A null
|
||||||
|
* frequency (every migrated row — Access's FORMA PAGO was dropped by the
|
||||||
|
* original ETL) is treated as "unknown, allow it" rather than "annual":
|
||||||
|
* refusing to show a figure that is sitting in the legacy data would hide it. */
|
||||||
|
export function surchargeApplies(
|
||||||
|
frequency: PaymentFrequencyValue | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: unknown): number {
|
||||||
|
if (v === null || v === undefined || v === "") return 0;
|
||||||
|
const n = typeof v === "number" ? v : Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-up to cents, the way the printed policy rounds. */
|
||||||
|
export function round2(n: number): number {
|
||||||
|
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PremiumParts {
|
||||||
|
netPremium?: unknown;
|
||||||
|
surcharge?: unknown;
|
||||||
|
policyFee?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taxableBase(p: PremiumParts): number {
|
||||||
|
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTax(p: PremiumParts, rate: number): number {
|
||||||
|
return round2(taxableBase(p) * rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||||
|
return round2(taxableBase(p) + computeTax(p, rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rate ladder: the figure stored on the policy (so an old policy keeps the
|
||||||
|
* rate it was issued at even after the catalog changes), else the rate on its
|
||||||
|
* line of business, else the shipped default. */
|
||||||
|
export function resolveTaxRate(
|
||||||
|
policyRate: unknown,
|
||||||
|
policyTypeRate: unknown,
|
||||||
|
): number {
|
||||||
|
for (const candidate of [policyRate, policyTypeRate]) {
|
||||||
|
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||||
|
const n = Number(candidate);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return DEFAULT_TAX_RATE;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
nameTokens,
|
||||||
|
suggestCustomersByName,
|
||||||
|
suggestionNote,
|
||||||
|
type CustomerNameRow,
|
||||||
|
} from "./name-matcher";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every row here is a real name out of the customer book (1536 rows, dev
|
||||||
|
* mirror of production), chosen because it is one of the shapes that breaks
|
||||||
|
* naive matching: surname-first ordering, a middle initial, a Spanish double
|
||||||
|
* surname, a joint account, a missing comma, and the `(SIN NOMBRE)`
|
||||||
|
* placeholder the migration left for customers whose DATGRAL row had no name.
|
||||||
|
*/
|
||||||
|
const BOOK: CustomerNameRow[] = [
|
||||||
|
{ id: "c1", name: "WAGONER, PAMELA" },
|
||||||
|
{ id: "c2", name: "MCWILLIAMS, BRIAN MICHAEL" },
|
||||||
|
{ id: "c3", name: "MCWILLIAMS, BRIAN" },
|
||||||
|
{ id: "c4", name: "WEAKLAND, RICHARD E." },
|
||||||
|
{ id: "c5", name: "ESTRADA, JERRY & MARILYN" },
|
||||||
|
{ id: "c6", name: "CABALLERO PRIETO, GUILLERMO" },
|
||||||
|
{ id: "c7", name: "GREENE STEPHANIE" },
|
||||||
|
{ id: "c8", name: "(SIN NOMBRE)" },
|
||||||
|
{ id: "c9", name: "MUÑOZ, LUIS ALBERTO" },
|
||||||
|
{ id: "c10", name: "SMITH, DANIEL" },
|
||||||
|
{ id: "c11", name: "SMITH, JOHN" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("nameTokens", () => {
|
||||||
|
it("makes the two orderings the same set", () => {
|
||||||
|
expect(nameTokens("PAMELA WAGONER").sort()).toEqual(
|
||||||
|
nameTokens("WAGONER, PAMELA").sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops initials, particles and corporate suffixes", () => {
|
||||||
|
expect(nameTokens("WEAKLAND, RICHARD E.")).toEqual(["WEAKLAND", "RICHARD"]);
|
||||||
|
expect(nameTokens("GARCIA DE LA TORRE, ANA")).toEqual(["GARCIA", "TORRE", "ANA"]);
|
||||||
|
expect(nameTokens("CONSTRUCTORA BAJA S.A. DE C.V.")).toEqual([
|
||||||
|
"CONSTRUCTORA",
|
||||||
|
"BAJA",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds accents so OCR's MUNOZ reaches the book's MUÑOZ", () => {
|
||||||
|
expect(nameTokens("MUÑOZ")).toEqual(["MUNOZ"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the phone number ANA prints against the insured name", () => {
|
||||||
|
// Observed verbatim from the ANA automobile face.
|
||||||
|
expect(nameTokens("MARIA GARCIA Ph.3102001538")).toEqual([
|
||||||
|
"MARIA",
|
||||||
|
"GARCIA",
|
||||||
|
"PH",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestCustomersByName", () => {
|
||||||
|
it("matches the reversed name exactly", () => {
|
||||||
|
const [top] = suggestCustomersByName("PAMELA WAGONER", BOOK);
|
||||||
|
expect(top).toMatchObject({ customerId: "c1", tier: "EXACT", score: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a printed middle name the book lacks as a partial hit", () => {
|
||||||
|
const hits = suggestCustomersByName("PAMELA DENISE WAGONER", BOOK);
|
||||||
|
expect(hits[0]).toMatchObject({ customerId: "c1", tier: "PARTIAL" });
|
||||||
|
expect(hits[0].score).toBeCloseTo(2 / 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the exact row above the row that merely contains it", () => {
|
||||||
|
// Both MCWILLIAMS rows are reachable from this name; the one that holds
|
||||||
|
// the middle name is the exact set and must come first.
|
||||||
|
const hits = suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK);
|
||||||
|
expect(hits.map((h) => h.customerId)).toEqual(["c2", "c3"]);
|
||||||
|
expect(hits[0].tier).toBe("EXACT");
|
||||||
|
expect(hits[1].tier).toBe("PARTIAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches a joint account from the one spouse the carrier printed", () => {
|
||||||
|
const hits = suggestCustomersByName("JERRY ESTRADA", BOOK);
|
||||||
|
expect(hits[0]).toMatchObject({ customerId: "c5", tier: "PARTIAL" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("will not reach a joint account on given names alone", () => {
|
||||||
|
// No surname printed: `JERRY MARILYN` overlaps ESTRADA, JERRY & MARILYN
|
||||||
|
// on two tokens, and matching on that would book a stranger's policy.
|
||||||
|
expect(suggestCustomersByName("JERRY MARILYN", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches a Spanish double surname regardless of where the comma fell", () => {
|
||||||
|
const [top] = suggestCustomersByName("GUILLERMO CABALLERO PRIETO", BOOK);
|
||||||
|
expect(top).toMatchObject({ customerId: "c6", tier: "EXACT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still matches a book row that has no comma", () => {
|
||||||
|
const [top] = suggestCustomersByName("STEPHANIE GREENE", BOOK);
|
||||||
|
expect(top).toMatchObject({ customerId: "c7", tier: "EXACT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never suggests the (SIN NOMBRE) placeholder", () => {
|
||||||
|
expect(suggestCustomersByName("SIN NOMBRE", BOOK)).toEqual([]);
|
||||||
|
expect(suggestCustomersByName("NOMBRE DEL ASEGURADO", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing on a shared surname alone", () => {
|
||||||
|
// 185 surnames are shared by 524 customers; one token is not evidence.
|
||||||
|
expect(suggestCustomersByName("SMITH", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for a different person with the same surname", () => {
|
||||||
|
expect(suggestCustomersByName("ROBERT SMITH", BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a page-sized blob", () => {
|
||||||
|
// GMX's especificación has no field labels and the parser has handed its
|
||||||
|
// whole first page over as the insured name.
|
||||||
|
const blob =
|
||||||
|
"ESPECIFICACION DE LA POLIZA DE SEGURO DE RESPONSABILIDAD CIVIL " +
|
||||||
|
"EXPEDIDA A FAVOR DE PAMELA WAGONER CON VIGENCIA DEL 01 DE ENERO";
|
||||||
|
expect(suggestCustomersByName(blob, BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the list", () => {
|
||||||
|
expect(suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK, 1)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a null insured name", () => {
|
||||||
|
expect(suggestCustomersByName(null, BOOK)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("suggestionNote", () => {
|
||||||
|
it("says nothing when there is nothing", () => {
|
||||||
|
expect(suggestionNote([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a single exact hit", () => {
|
||||||
|
expect(suggestionNote(suggestCustomersByName("PAMELA WAGONER", BOOK))).toBe(
|
||||||
|
"posible cliente por nombre: WAGONER, PAMELA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a tie rather than picking one", () => {
|
||||||
|
// The book really does hold EMERY, LAURA twice and KIRCHHOFF, CINDY
|
||||||
|
// three times.
|
||||||
|
const dupes: CustomerNameRow[] = [
|
||||||
|
{ id: "d1", name: "EMERY, LAURA" },
|
||||||
|
{ id: "d2", name: "EMERY, LAURA" },
|
||||||
|
];
|
||||||
|
expect(suggestionNote(suggestCustomersByName("LAURA EMERY", dupes))).toBe(
|
||||||
|
"2 clientes tienen ese mismo nombre; elija cuál",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists partial hits", () => {
|
||||||
|
expect(suggestionNote(suggestCustomersByName("PAMELA DENISE WAGONER", BOOK))).toBe(
|
||||||
|
"posibles clientes por nombre: WAGONER, PAMELA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Suggests which existing customer a printed insured name belongs to.
|
||||||
|
*
|
||||||
|
* The office books customers surname-first ("WAGONER, PAMELA") and carriers
|
||||||
|
* print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare
|
||||||
|
* never hits. Comparing *token sets* does, and it is order-insensitive by
|
||||||
|
* construction — which is the whole trick.
|
||||||
|
*
|
||||||
|
* **These are suggestions, never matches.** Nothing here sets
|
||||||
|
* `matchedCustomerId` or `confident`; the review screen offers the ranked
|
||||||
|
* names and a human picks. That line is not caution, it is what the book
|
||||||
|
* measures out to: of 1536 customers, 1487 have a distinct normalized token
|
||||||
|
* set — but loosen the rule to surname + first given name only and 131 of
|
||||||
|
* them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL`
|
||||||
|
* *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three
|
||||||
|
* `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a
|
||||||
|
* surname alone carries no information at all.
|
||||||
|
*
|
||||||
|
* The two tiers below are drawn at the two places that measurement puts a
|
||||||
|
* cliff: full token-set equality, where cross-person collisions are
|
||||||
|
* effectively zero, and strict containment, where they are common enough
|
||||||
|
* that the result can only ever be a hint.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A customer row as the matcher needs it — id and the book's name. */
|
||||||
|
export interface CustomerNameRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NameMatchTier = "EXACT" | "PARTIAL";
|
||||||
|
|
||||||
|
export interface CustomerNameSuggestion {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
/**
|
||||||
|
* `EXACT` — the two names carry the same tokens, in any order.
|
||||||
|
* `PARTIAL` — one name's tokens are all present in the other's, plus the
|
||||||
|
* surname. A printed middle name the book does not hold, or a joint
|
||||||
|
* account where the carrier named one spouse, both land here.
|
||||||
|
*/
|
||||||
|
tier: NameMatchTier;
|
||||||
|
/** Shared tokens over the longer name's token count, 0..1. */
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Words that carry no identity. Spanish particles and the ampersand joining
|
||||||
|
* a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.`
|
||||||
|
* does not make every company look alike.
|
||||||
|
*/
|
||||||
|
const NOISE = new Set([
|
||||||
|
"DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA",
|
||||||
|
"JR", "SR", "II", "III", "IV",
|
||||||
|
"SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder rows the migration left behind. Fourteen customers are named
|
||||||
|
* literally `(SIN NOMBRE)`; without this they would be one 14-way tie on
|
||||||
|
* every unreadable name.
|
||||||
|
*/
|
||||||
|
const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A name blob longer than this is not a name. GMX's PVL especificación has
|
||||||
|
* no field labels, and the parser has been seen handing its entire first
|
||||||
|
* page over as `insuredName`; matching that against the book would find
|
||||||
|
* a surname somewhere in the prose and suggest a stranger.
|
||||||
|
*/
|
||||||
|
const MAX_TOKENS = 8;
|
||||||
|
const MAX_CHARS = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a name into comparable tokens.
|
||||||
|
*
|
||||||
|
* Accents go first, and deliberately in both directions: the book holds
|
||||||
|
* `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same
|
||||||
|
* ASCII makes that a hit rather than a miss.
|
||||||
|
*
|
||||||
|
* Tokens containing digits are dropped outright. ANA's automobile face
|
||||||
|
* prints the phone number hard against the insured name — the parser has
|
||||||
|
* emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise
|
||||||
|
* be an extra token forever blocking `EXACT`.
|
||||||
|
*
|
||||||
|
* Single letters are dropped as initials: the book is full of
|
||||||
|
* `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in
|
||||||
|
* full should still match the row that abbreviates it.
|
||||||
|
*/
|
||||||
|
export function nameTokens(raw: string): string[] {
|
||||||
|
const cleaned = raw
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z0-9]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const tokens = cleaned
|
||||||
|
.split(" ")
|
||||||
|
.filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t));
|
||||||
|
|
||||||
|
return [...new Set(tokens)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The surname tokens — everything before the comma the book writes. */
|
||||||
|
function surnameTokens(bookName: string): string[] {
|
||||||
|
const comma = bookName.indexOf(",");
|
||||||
|
// 54 of 1536 rows have no comma at all ("GREENE STEPHANIE",
|
||||||
|
// "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring
|
||||||
|
// a surname we cannot identify would silently exclude those rows, so they
|
||||||
|
// fall back to requiring nothing beyond the containment rule.
|
||||||
|
if (comma < 0) return [];
|
||||||
|
return nameTokens(bookName.slice(0, comma));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaceholder(tokens: string[]): boolean {
|
||||||
|
return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsAll(haystack: Set<string>, needles: string[]): boolean {
|
||||||
|
return needles.every((n) => haystack.has(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ranks the book against one printed name.
|
||||||
|
*
|
||||||
|
* Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher
|
||||||
|
* score first. An empty array means the printed name was unusable (too
|
||||||
|
* long, too few real tokens) or nothing in the book came close — both of
|
||||||
|
* which leave the review screen exactly as it is today.
|
||||||
|
*/
|
||||||
|
export function suggestCustomersByName(
|
||||||
|
printedName: string | null | undefined,
|
||||||
|
customers: CustomerNameRow[],
|
||||||
|
limit = 3,
|
||||||
|
): CustomerNameSuggestion[] {
|
||||||
|
if (!printedName || printedName.length > MAX_CHARS) return [];
|
||||||
|
|
||||||
|
const printed = nameTokens(printedName);
|
||||||
|
// One usable token is a surname or a given name on its own, and 34% of the
|
||||||
|
// book shares a surname with someone. Nothing useful can come of it.
|
||||||
|
if (printed.length < 2 || printed.length > MAX_TOKENS) return [];
|
||||||
|
|
||||||
|
const printedSet = new Set(printed);
|
||||||
|
const out: CustomerNameSuggestion[] = [];
|
||||||
|
|
||||||
|
for (const c of customers) {
|
||||||
|
const book = nameTokens(c.name);
|
||||||
|
if (isPlaceholder(book) || book.length < 2) continue;
|
||||||
|
|
||||||
|
const bookSet = new Set(book);
|
||||||
|
const overlap = printed.filter((t) => bookSet.has(t)).length;
|
||||||
|
// Two shared tokens is the floor: one is a bare surname collision.
|
||||||
|
if (overlap < 2) continue;
|
||||||
|
|
||||||
|
const bookInPrinted = containsAll(printedSet, book);
|
||||||
|
const printedInBook = containsAll(bookSet, printed);
|
||||||
|
if (!bookInPrinted && !printedInBook) continue;
|
||||||
|
|
||||||
|
// When the book's name is the shorter one, containment already proves
|
||||||
|
// the surname was printed. When the printed name is shorter — the book
|
||||||
|
// holds a middle name or a second spouse the carrier omitted — the
|
||||||
|
// surname must be there explicitly, or `JERRY MARILYN` would match
|
||||||
|
// `ESTRADA, JERRY & MARILYN` on given names alone.
|
||||||
|
if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue;
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
customerId: c.id,
|
||||||
|
customerName: c.name,
|
||||||
|
tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL",
|
||||||
|
score: overlap / Math.max(book.length, printed.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort((a, b) => {
|
||||||
|
if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1;
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return a.customerName.localeCompare(b.customerName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return out.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Review-queue wording for what the suggestions amount to. */
|
||||||
|
export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null {
|
||||||
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
|
const exact = suggestions.filter((s) => s.tier === "EXACT");
|
||||||
|
// More than one exact hit is the duplicate-customer case the book really
|
||||||
|
// has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is
|
||||||
|
// more useful than naming whichever one sorted first.
|
||||||
|
if (exact.length > 1) {
|
||||||
|
return `${exact.length} clientes tienen ese mismo nombre; elija cuál`;
|
||||||
|
}
|
||||||
|
if (exact.length === 1) {
|
||||||
|
return `posible cliente por nombre: ${exact[0].customerName}`;
|
||||||
|
}
|
||||||
|
return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`;
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ function page(text: string): OcrPage {
|
|||||||
return { text, words: [], confidence: 0.95 };
|
return { text, words: [], confidence: 0.95 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coverages keyed by their risk label, so an assertion names the coverage
|
||||||
|
* it is about instead of an array index that shifts when one is added. */
|
||||||
|
const byRisk = (p: ReturnType<typeof parsePolicy>): Record<string, ParsedCoverage> =>
|
||||||
|
Object.fromEntries(p.coverages.map((c) => [c.risk, c]));
|
||||||
|
|
||||||
describe("detectPolicyProvider", () => {
|
describe("detectPolicyProvider", () => {
|
||||||
it("claims GMX from the brand wordmark on the letterhead", () => {
|
it("claims GMX from the brand wordmark on the letterhead", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -111,6 +116,13 @@ describe("parsePolicy / GMX", () => {
|
|||||||
expect(p.coverages.length).toBeGreaterThan(10);
|
expect(p.coverages.length).toBeGreaterThan(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names the product MULT for confirm to resolve", () => {
|
||||||
|
// The caratula's own header reads "Multiple Policy / Home". MULT is the
|
||||||
|
// legacy discriminator for that multi-line home policy; INCENDIO is
|
||||||
|
// fire-only and no policy in the book has ever used it.
|
||||||
|
expect(parsePolicy(GMX_FULL).policyTypeName).toBe("MULT");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves premium fields null on the certificate page and notes it", () => {
|
it("leaves premium fields null on the certificate page and notes it", () => {
|
||||||
const p = parsePolicy(GMX_FULL);
|
const p = parsePolicy(GMX_FULL);
|
||||||
expect(p.netPremium).toBeNull();
|
expect(p.netPremium).toBeNull();
|
||||||
@@ -144,4 +156,779 @@ describe("parsePolicy / GMX", () => {
|
|||||||
expect(eqTyped.deductible).toContain("sum insured");
|
expect(eqTyped.deductible).toContain("sum insured");
|
||||||
expect(eqTyped.lossParticipation).toBe("20%");
|
expect(eqTyped.lossParticipation).toBe("20%");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The second GMX document family: the Spanish PVL "especificación" the office
|
||||||
|
* receives as `…-CondicionesParticulares.pdf`. Verbatim excerpts from
|
||||||
|
* `007_LGS-HGMX_07006957_01_0-CondicionesParticulares.pdf` through
|
||||||
|
* `pdftotext -layout`, indentation included — the column positions and the
|
||||||
|
* blank lines between blocks are what the parser reads, so a cleaned-up
|
||||||
|
* fixture would test nothing.
|
||||||
|
*/
|
||||||
|
describe("parsePolicy / GMX especificación (PVL Hogar)", () => {
|
||||||
|
const HEADER =
|
||||||
|
" ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA\n" +
|
||||||
|
" 07-037-07006957-00000-01\n" +
|
||||||
|
"\n";
|
||||||
|
|
||||||
|
const GMX_ESPEC = page(
|
||||||
|
HEADER +
|
||||||
|
"\n" +
|
||||||
|
" Nombre del asegurado EMMER . KATHLEEN\n" +
|
||||||
|
"\n" +
|
||||||
|
" Tipo Persona Asegurada Propietario\n" +
|
||||||
|
"\n" +
|
||||||
|
" Ubicación del riesgo LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS\n" +
|
||||||
|
" DE ROSARITO BAJA CALIFORNIA 22713\n" +
|
||||||
|
"\n" +
|
||||||
|
" Características del Inmueble Casa Tipo constructivo Combinado: Macizo y Madera.\n" +
|
||||||
|
" Consta de 2 pisos incluyendo sótanos y planta baja.\n" +
|
||||||
|
"\n" +
|
||||||
|
" -500 mts.cuerpo agua SI\n" +
|
||||||
|
"\n" +
|
||||||
|
" Asegurado Adicional\n" +
|
||||||
|
"\n" +
|
||||||
|
"PVL Hogar - GMX Seguros Página: 1 de 10\n" +
|
||||||
|
HEADER +
|
||||||
|
"\n" +
|
||||||
|
" SECCIÓN INCENDIO EDIFICIO Y CONTENIDOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" EDIFICIO\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" $200,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" Quedan amparados los muros de contención y bardas, así como puertas y portones, hasta un sublimite de $ 50,000.00 M.N. o su\n" +
|
||||||
|
" equivalente en dólares americanos, o hasta el 10% de la suma asegurada de la sección de Edificio, lo que resulte menor.\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" CONTENIDOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" $20,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" 2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO\n" +
|
||||||
|
"\n" +
|
||||||
|
" 3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" Riesgos adicionales.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Remoción de escombros\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" Edificio\n" +
|
||||||
|
" $20,000.00 USD\n" +
|
||||||
|
" Contenidos\n" +
|
||||||
|
" $2,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" Gastos extraordinarios para casa habitación\n" +
|
||||||
|
"\n" +
|
||||||
|
" En caso de siniestro por los riesgos cubiertos en esta póliza, GMX Seguros pagará la renta de casa o departamento, casa de\n" +
|
||||||
|
" huéspedes u hotel cuando se asegure el inmueble, así como los gastos de mudanza, seguro de transporte del menaje de casa y\n" +
|
||||||
|
" efectuados.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad:\n" +
|
||||||
|
" $22,000.00 USD\n" +
|
||||||
|
" Periodo de indemnización: 4 meses.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Bienes a la Intemperie:\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA\n" +
|
||||||
|
" Y CADA PÉRDIDA.\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite Máximo de Responsabilidad: $10,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
" DEDUCIBLES:\n" +
|
||||||
|
"\n" +
|
||||||
|
" El procedimiento que se seguirá para la aplicación de deducibles en caso de que la póliza cuente con cláusula inflacionaria en todas\n" +
|
||||||
|
" y/o en algunas de sus coberturas será como sigue:\n" +
|
||||||
|
"\n" +
|
||||||
|
" Fenómenos hidrometeorológicos\n" +
|
||||||
|
" Zona: A2\n" +
|
||||||
|
" Deducible\n" +
|
||||||
|
" Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||||
|
" Coaseguro:\n" +
|
||||||
|
" Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||||
|
" Zona 2: Participación a cargo del asegurado del 10% de toda y cada pérdida.\n" +
|
||||||
|
"\n" +
|
||||||
|
" Deducible\n" +
|
||||||
|
" Contenidos: 1 POR CIENTO SOBRE SUMA ASEGURADA\n" +
|
||||||
|
"\n" +
|
||||||
|
" II.- SECCIÓN DIVERSOS MISCELÁNEOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" ROBO DE CONTENIDOS\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite de Responsabilidad:\n" +
|
||||||
|
" $4,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Deducible:\n" +
|
||||||
|
" Sin deducible\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Sublímites:\n" +
|
||||||
|
" Joyas, artículos de oro y plata, armas, relojes, pieles, piedras preciosas montadas, colecciones, obras de arte y demás que por su\n" +
|
||||||
|
"\n" +
|
||||||
|
"PVL Hogar - GMX Seguros Página: 7 de 10\n" +
|
||||||
|
HEADER +
|
||||||
|
"\n" +
|
||||||
|
" naturaleza se consideran como objetos de difícil o imposible reposición\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Límite de Responsabilidad:\n" +
|
||||||
|
"\n" +
|
||||||
|
" $2,000.00 USD\n" +
|
||||||
|
"\n" +
|
||||||
|
"\n" +
|
||||||
|
" Deducible:\n" +
|
||||||
|
" Sin deducible\n" +
|
||||||
|
"\n" +
|
||||||
|
" Las condiciones generales que forman parte de la presente póliza son las identificadas bajo el nombre:\n" +
|
||||||
|
" W_HogarGMX_12.11.2025.pdf\n" +
|
||||||
|
"\n" +
|
||||||
|
"PVL Hogar - GMX Seguros Página: 10 de 10\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("reads a policy number whose groups are not the caratula's widths", () => {
|
||||||
|
// 2-3-8-5-2 here vs 3-3-8-4-2 on the English caratula. Pinning the widths
|
||||||
|
// reads one family and returns null on the other.
|
||||||
|
expect(parsePolicy(GMX_ESPEC).policyNumber).toBe("07-037-07006957-00000-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the insured, the risk location across its wrapped line, and the ZIP", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
expect(p.provider).toBe("GMX");
|
||||||
|
expect(p.insuredName).toBe("EMMER . KATHLEEN");
|
||||||
|
expect(p.legalAddress).toBe(
|
||||||
|
"LOS PELICANOS ESTE NO. 98 Col. LAS GAVIOTAS PLAYAS DE ROSARITO BAJA CALIFORNIA 22713",
|
||||||
|
);
|
||||||
|
expect(p.zip).toBe("22713");
|
||||||
|
// The cell is printed but empty on this policy — an empty label must not
|
||||||
|
// capture the next line of the form.
|
||||||
|
expect(p.additionalInsured).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the fields this document does not carry null, and says so", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
expect(p.policyFrom).toBeNull();
|
||||||
|
expect(p.policyTo).toBeNull();
|
||||||
|
expect(p.policyDate).toBeNull();
|
||||||
|
expect(p.agentName).toBeNull();
|
||||||
|
expect(p.netPremium).toBeNull();
|
||||||
|
expect(p.total).toBeNull();
|
||||||
|
// The note must tell the reviewer to key them in — those three are
|
||||||
|
// captured by hand on this layout — and must say what silently breaks if
|
||||||
|
// the vigencia is left empty.
|
||||||
|
const notes = p.notes.join(" ");
|
||||||
|
expect(notes).toMatch(/no trae vigencia, agente ni prima/i);
|
||||||
|
expect(notes).toMatch(/captúrelos a mano/i);
|
||||||
|
expect(notes).toMatch(/avisos de renovación/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the currency from the printed limits, not from the M.N. sublimits", () => {
|
||||||
|
// The body prose quotes sublimits in pesos ("$ 50,000.00 M.N."); every
|
||||||
|
// limit is in USD, and only the limits vote.
|
||||||
|
expect(parsePolicy(GMX_ESPEC).currency).toBe("USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads each coverage under its own heading", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c.EDIFICIO?.insuredAmount).toBe(200000);
|
||||||
|
expect(c.CONTENIDOS?.insuredAmount).toBe(20000);
|
||||||
|
expect(c["ROBO DE CONTENIDOS"]?.insuredAmount).toBe(4000);
|
||||||
|
expect(c["ROBO DE CONTENIDOS"]?.deductible).toBe("Sin deducible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits a limit printed under Edificio / Contenidos sub-labels", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Remoción de escombros — Edificio"]?.insuredAmount).toBe(20000);
|
||||||
|
expect(c["Remoción de escombros — Contenidos"]?.insuredAmount).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a coverage after its heading, not after the wrapped tail of the prose above it", () => {
|
||||||
|
// Walking back from the limit hits "efectuados." — short, and the only
|
||||||
|
// thing separating it from a heading is that it is not preceded by a
|
||||||
|
// blank line.
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Gastos extraordinarios para casa habitación"]?.insuredAmount).toBe(22000);
|
||||||
|
expect(c["efectuados."]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a limit printed on the label's own line", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Bienes a la Intemperie"]?.insuredAmount).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a deductible stated as a sentence above the limit", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["Bienes a la Intemperie"]?.deductible).toBe(
|
||||||
|
"5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO DEL ASEGURADO DE TODA Y CADA PÉRDIDA.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never borrows a neighbouring coverage's prose as a deductible", () => {
|
||||||
|
// "…o hasta el 10% de la suma asegurada de la sección de Edificio" is a
|
||||||
|
// sublimit rule for EDIFICIO, printed two paragraphs above CONTENIDOS.
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c.CONTENIDOS?.deductible).toBeNull();
|
||||||
|
expect(c.EDIFICIO?.deductible).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read the page-level DEDUCIBLES paragraph as a deductible", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
expect(
|
||||||
|
p.coverages.some((c) => (c.deductible ?? "").includes("cláusula inflacionaria")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a sublimit block as a sublimit OF the coverage above it", () => {
|
||||||
|
// The amount sits after a blank line AND a page break, and the block's
|
||||||
|
// own heading ("Sublímites:") names no risk.
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
expect(c["ROBO DE CONTENIDOS — sublímite"]?.insuredAmount).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records an excluded catastrophic risk as excluded, never as zero", () => {
|
||||||
|
const p = parsePolicy(GMX_ESPEC);
|
||||||
|
const quake = p.coverages.filter((c) => /Terremoto/i.test(c.risk));
|
||||||
|
expect(quake).toHaveLength(2);
|
||||||
|
for (const c of quake) {
|
||||||
|
expect(c.risk).toMatch(/EXCLUIDO/);
|
||||||
|
// A coverage insured for $0 and an excluded coverage are the same
|
||||||
|
// number and very different facts.
|
||||||
|
expect(c.insuredAmount).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the hydrometeorological deductible and coinsurance from its own block", () => {
|
||||||
|
const c = byRisk(parsePolicy(GMX_ESPEC));
|
||||||
|
const building = c["Fenómenos hidrometeorológicos — Sección Edificio"];
|
||||||
|
expect(building?.insuredAmount).toBe(200000);
|
||||||
|
expect(building?.deductible).toBe("1 POR CIENTO SOBRE SUMA ASEGURADA");
|
||||||
|
expect(building?.lossParticipation).toBe("10%");
|
||||||
|
expect(c["Fenómenos hidrometeorológicos — Sección Contenidos"]?.insuredAmount).toBe(20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the same product as the caratula — one policy, two artifacts", () => {
|
||||||
|
expect(parsePolicy(GMX_ESPEC).policyTypeName).toBe("MULT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the underwriting context the fields have no home for", () => {
|
||||||
|
const notes = parsePolicy(GMX_ESPEC).notes.join(" | ");
|
||||||
|
expect(notes).toMatch(/tipo de persona asegurada: Propietario/);
|
||||||
|
expect(notes).toMatch(/características del inmueble: Casa/);
|
||||||
|
expect(notes).toMatch(/cuerpo de agua/);
|
||||||
|
expect(notes).toMatch(/zona catastrófica declarada: A2/);
|
||||||
|
expect(notes).toMatch(/W_HogarGMX_12\.11\.2025\.pdf/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ ANA */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbatim `pdftotext -layout` output of the PDFs A.N.A.'s portal produced
|
||||||
|
* for three real policies, cut at the end of the risk table (the legal
|
||||||
|
* boilerplate and the repeated AGENT COPY below it are not parsed, and the
|
||||||
|
* repeats are covered by their own test).
|
||||||
|
*
|
||||||
|
* The column padding is load-bearing on the driver's policy, which
|
||||||
|
* distinguishes SUM INSURED from PREMIUM by horizontal position alone — do
|
||||||
|
* not reflow these strings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ANA_AUTO_AMPLIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||||
|
LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA
|
||||||
|
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||||
|
www.anaseguros.com.mx
|
||||||
|
AUTOMOBILE
|
||||||
|
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||||
|
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS
|
||||||
|
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||||
|
WHATSAPP + 52-55-80-50-3633
|
||||||
|
No. 700489651
|
||||||
|
ISSUED BY: DATE ISSUED TERM OF INSURANCE
|
||||||
|
DAYS
|
||||||
|
JORGE HUMBERTO CUADROS DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||||
|
BENITO JUAREZ 25 No.50 INT 38 CENTRO
|
||||||
|
04 08 2026 FROM 07 08 2026 12:01
|
||||||
|
365
|
||||||
|
ROSARITO, BAJA CALIFORNIA 22710
|
||||||
|
. 70175 TO 07 08 2027 12:01
|
||||||
|
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||||
|
- 298.61 30.00 26.29 0.00 354.90
|
||||||
|
|
||||||
|
INSURED RAY DEAN II AND SUSAN ROCKHOLD
|
||||||
|
LICENSE P0066762
|
||||||
|
ADDRESS 10308 DONNA AVE EMAIL PROLABSALE@AOL.COM
|
||||||
|
CITY & STATE NORTHRIDGE, CA 91326 TELEPHONE 8184453524
|
||||||
|
PAYMENT DEADLINE
|
||||||
|
INSURANCE COMPANY LIEN HOLDER
|
||||||
|
IMMEDIATE
|
||||||
|
|
||||||
|
ITEM YEAR MAKE BODY SERIAL No. PLATES
|
||||||
|
VEHICLE 2017 CHRYSLER PACIFICA 2C4RC1DG7HR654698 8BPX206
|
||||||
|
TRAILER . .
|
||||||
|
TOWING . .
|
||||||
|
*** VALUE STATED MUST NOT EXCEED MARKET VALUE ***
|
||||||
|
***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING
|
||||||
|
AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE
|
||||||
|
SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY.
|
||||||
|
SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY
|
||||||
|
MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 8,000.00 DLLS.
|
||||||
|
1 DEDUCTIBLE: WITH MINIMUM OF $500.00 ON AUTOS
|
||||||
|
TRAILER
|
||||||
|
(SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED
|
||||||
|
AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES). 0.00 DLLS.
|
||||||
|
|
||||||
|
TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING
|
||||||
|
2 DEDUCTIBLE: WITH MINIMUM OF $1,000.00 ON AUTOS 0.00 DLLS.
|
||||||
|
(SEDANS, COUPES, CONVERTIBLES AND STATION WAGONS) COVERED
|
||||||
|
AND $1,000.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND MOTOR HOMES).
|
||||||
|
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES
|
||||||
|
3 100,000.00 DLLS.
|
||||||
|
|
||||||
|
|
||||||
|
BODILY INJURY LIABILITY PER PER
|
||||||
|
4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS.
|
||||||
|
|
||||||
|
MEDICAL EXPENSES PER PER
|
||||||
|
5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS.
|
||||||
|
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
6 A.N.A.'s LEGAL AID
|
||||||
|
COVERED 40.00
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
7 A.N.A.'s ROADSIDE ASSISTANCE
|
||||||
|
COVERED 40.00
|
||||||
|
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM
|
||||||
|
8 EXCLUDED
|
||||||
|
PARTIES DLLS. 0.00
|
||||||
|
ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED
|
||||||
|
9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00
|
||||||
|
VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
ISSUED ONLINE`);
|
||||||
|
|
||||||
|
const ANA_AUTO_RC_DIAS = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||||
|
LUIS CABRERA #2033 INT. 201, Col. ZONA URBANA RIO TIJUANA
|
||||||
|
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||||
|
www.anaseguros.com.mx
|
||||||
|
AUTOMOBILE
|
||||||
|
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||||
|
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9 SPECIAL POLICY FOR TOURISTS
|
||||||
|
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||||
|
WHATSAPP + 52-55-80-50-3633
|
||||||
|
No. 700487807
|
||||||
|
ISSUED BY: DATE ISSUED TERM OF INSURANCE
|
||||||
|
DAYS
|
||||||
|
JORGE HUMBERTO CUADROS DIARIA DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||||
|
BENITO JUAREZ 25 NO50 INT 38 COL CENTRO
|
||||||
|
22 07 2026 FROM 23 07 2026 12:01
|
||||||
|
3
|
||||||
|
ROSARITO BAJA CALIFORNIA 22710
|
||||||
|
(661) 612 12 55 70175 TO 26 07 2026 12:01
|
||||||
|
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||||
|
- 10.77 25.00 2.86 0.00 38.63
|
||||||
|
|
||||||
|
INSURED STEPHEN RUPAN SHATAFIAN
|
||||||
|
LICENSE C1394198
|
||||||
|
ADDRESS 13181 CROSSROADS PARKWAY NORTH STE 300 EMAIL sshatafian@lee-associates.com
|
||||||
|
|
||||||
|
|
||||||
|
CITY & STATE CITY OF INDUSTRY, CA 91746 TELEPHONE 7143221072
|
||||||
|
PAYMENT DEADLINE
|
||||||
|
INSURANCE COMPANY LIEN HOLDER
|
||||||
|
IMMEDIATE
|
||||||
|
|
||||||
|
ITEM YEAR MAKE BODY SERIAL No. PLATES
|
||||||
|
VEHICLE 2022 FORD TRANSIT 1FBAX2CG3NKA69091 EC46T99
|
||||||
|
TRAILER . .
|
||||||
|
TOWING . .
|
||||||
|
*** VALUE STATED MUST NOT EXCEED MARKET VALUE ***
|
||||||
|
***VEHICLES THAT HAVE BEEN ACQUIRED AS SALVAGE, REBUILT, OR HAVE BEEN USED PREVIOUSLY AS A TAXI WILL BE CONSIDERED WITH A REDUCED VALUE OF 35% (thirty-five percent), TAKING
|
||||||
|
AS A BASE THE VALUE OF A SIMILAR NORMAL VEHICLE, THAT IS, ONE THAT HAS NOT BEEN ACQUIRED AS SALVAGE AND ITS PREVIOUS USE HAS NOT BEEN AS A TAXI OR REBUILT. IT WILL BE THE
|
||||||
|
SOLE OBLIGATION AND RESPONSIBILITY OF THE INSURED TO DECLARATE THIS WHEN ACQUIRING THE POLICY.
|
||||||
|
SECTION SPECIFICATION OF RISKS LIMIT OF LIABILITY
|
||||||
|
MATERIAL DAMAGE WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED VEHICLE 0.00 DLLS.
|
||||||
|
1 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND
|
||||||
|
TRAILER
|
||||||
|
STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED
|
||||||
|
SUV´s AND MOTOR HOMES). 0.00 DLLS.
|
||||||
|
|
||||||
|
TOTAL THEFT WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED TOWING
|
||||||
|
2 DEDUCTIBLE: ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND 0.00 DLLS.
|
||||||
|
STATION WAGONS) AND OTHERS (PICK UPS, VANS, EXCLUDED
|
||||||
|
SUV´s AND MOTOR HOMES).
|
||||||
|
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES
|
||||||
|
3 100,000.00 DLLS.
|
||||||
|
|
||||||
|
|
||||||
|
BODILY INJURY LIABILITY PER PER
|
||||||
|
4 PERSON 100,000.00 ACCIDENT 200,000.00 DLLS.
|
||||||
|
|
||||||
|
MEDICAL EXPENSES PER PER
|
||||||
|
5 PERSON 5,000.00 ACCIDENT 25,000.00 DLLS.
|
||||||
|
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
6 A.N.A.'s LEGAL AID
|
||||||
|
COVERED 2.25
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
7 A.N.A.'s ROADSIDE ASSISTANCE
|
||||||
|
COVERED 2.25
|
||||||
|
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PREMIUM
|
||||||
|
8 EXCLUDED
|
||||||
|
PARTIES DLLS. 0.00
|
||||||
|
ELITE OR ELITE PLUS WITH MANDATORY DEDUCTIBLE COVERED/EXCLUDED
|
||||||
|
9 PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) 0.00
|
||||||
|
VANDALISM (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER EVENT) EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
ISSUED ONLINE`);
|
||||||
|
|
||||||
|
const ANA_LICENCIA = page(`A.N.A. COMPAÑIA DE SEGUROS SA DE CV
|
||||||
|
LUIS CABRERA #2033 INT. 201, Col.4 ZONA URBANA RIO TIJUANA
|
||||||
|
C.P. 22010 MUNICIPIO DE TIJUANA, BAJA CALIFORNIA
|
||||||
|
www.anaseguros.com.mx
|
||||||
|
DRIVER´S POLICY FOR AUTOMOBILE
|
||||||
|
ALL CLAIMS MUST BE REPORTED BEFORE LEAVING MEXICO
|
||||||
|
U.S. CELL PHONES TRY + 011-52-55-5322-82-66 MEXICAN CELL PHONES 800-911-911-9
|
||||||
|
SPECIAL POLICY FOR TOURISTS
|
||||||
|
TOLL-FREE FROM THE U.S.A. 888-335-7072 BELIZE CELL PHONES 00-52-55-5322-8266
|
||||||
|
WHATSAPP + 52-55-80-50-3633 No. 700489616
|
||||||
|
ISSUED BY: DATE ISSUED & TIME TERM OF INSURANCE
|
||||||
|
JORGE HUMBERTO CUADROS
|
||||||
|
DAYS
|
||||||
|
DAY MONTH YEAR DAY MONTH YEAR TIME
|
||||||
|
BENITO JUAREZ 25 No.50 INT 38 CENTRO 04 08 2026 FROM 06 08 2026 12:01
|
||||||
|
365
|
||||||
|
ROSARITO, BAJA CALIFORNIA 22710 TO 06 08 2027 12:01
|
||||||
|
. 70175
|
||||||
|
DISCOUNT PREMIUM POLICY FEE TAX LOCAL TAX TOTAL
|
||||||
|
- 142.78 30.00 13.82 0.00 186.60
|
||||||
|
|
||||||
|
LICENSE N0017668 EMAIL PWAGONER49@AOL.COM TELEPHONE 3102001538
|
||||||
|
POLICY HOLDER
|
||||||
|
1. NAME : PAMELA DENISE WAGONER Ph.3102001538
|
||||||
|
ADDRESS : 49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260,
|
||||||
|
DRIVER LICENSE : N0017668
|
||||||
|
2. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE :
|
||||||
|
NONE
|
||||||
|
3. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE :
|
||||||
|
NONE
|
||||||
|
4. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE : NONE
|
||||||
|
5. NAME :
|
||||||
|
ADDRESS :
|
||||||
|
DRIVER LICENSE : NONE
|
||||||
|
SPECIFICATION OF RISKS SUM INSURED PREMIUM
|
||||||
|
LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES 100,000.00 usd. 18.70 usd.
|
||||||
|
BODILY INJURY LIABILITY ( EXCLUDING OCCUPANTS OF THE VEHICLE ) 100,000.00 usd. Per Person
|
||||||
|
54.27 usd.
|
||||||
|
200,000.00 usd. Per Accident
|
||||||
|
CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES 0.00 usd. 0.00 usd.
|
||||||
|
|
||||||
|
MEDICAL EXPENSES 4,000.00 usd. Per Person
|
||||||
|
9.81 usd.
|
||||||
|
20,000.00 usd. Per Accident
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
LEGAL AID
|
||||||
|
COVERED 30.00 usd.
|
||||||
|
COVERED/EXCLUDED PREMIUM
|
||||||
|
AUTOMOBILE ASSISTANCE
|
||||||
|
COVERED 30.00 usd.
|
||||||
|
|
||||||
|
The following risks are excluded Collision, overtuning and glass breakage, fire, total theft and natural disasters, partial theft and vandalism.`);
|
||||||
|
|
||||||
|
|
||||||
|
describe("detectPolicyProvider / ANA", () => {
|
||||||
|
it("claims ANA from the letterhead", () => {
|
||||||
|
expect(
|
||||||
|
detectPolicyProvider("A.N.A. COMPAÑIA DE SEGUROS SA DE CV\nwww.anaseguros.com.mx"),
|
||||||
|
).toBe("ANA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let GMX's layout rules claim an ANA page", () => {
|
||||||
|
// Both books print "MATERIAL DAMAGE"-ish headings; the brand pass runs
|
||||||
|
// before any layout rule precisely so this can't go the other way.
|
||||||
|
expect(detectPolicyProvider(ANA_AUTO_AMPLIA.text)).toBe("ANA");
|
||||||
|
expect(detectPolicyProvider(ANA_LICENCIA.text)).toBe("ANA");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy / ANA automobile", () => {
|
||||||
|
const p = parsePolicy(ANA_AUTO_AMPLIA);
|
||||||
|
|
||||||
|
it("reads the header band", () => {
|
||||||
|
expect(p.provider).toBe("ANA");
|
||||||
|
expect(p.policyNumber).toBe("700489651");
|
||||||
|
expect(p.insuredName).toBe("RAY DEAN II AND SUSAN ROCKHOLD");
|
||||||
|
expect(p.agentName).toBe("JORGE HUMBERTO CUADROS");
|
||||||
|
expect(p.legalAddress).toBe("10308 DONNA AVE, NORTHRIDGE, CA 91326");
|
||||||
|
expect(p.zip).toBe("91326");
|
||||||
|
expect(p.currency).toBe("USD");
|
||||||
|
expect(p.premiumPayment).toBe("IMMEDIATE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads DD MM YYYY out of the three date column cells", () => {
|
||||||
|
expect(p.policyDate?.toISOString().slice(0, 10)).toBe("2026-08-04");
|
||||||
|
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-08-07");
|
||||||
|
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2027-08-07");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps the six money cells positionally, not by finding six amounts", () => {
|
||||||
|
// DISCOUNT prints as a bare "-" here. A "take the amounts in order"
|
||||||
|
// reading would shift every value one column left.
|
||||||
|
expect(p.netPremium).toBe(298.61);
|
||||||
|
expect(p.policyFee).toBe(30);
|
||||||
|
expect(p.tax).toBe(26.29);
|
||||||
|
expect(p.total).toBe(354.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a TAX that reconciles against the rest of the row", () => {
|
||||||
|
// 298.61 + 30.00 = 328.61, taxed at 8% -> 26.29, totalling 354.90. The
|
||||||
|
// whole row agreeing is what proves the positional mapping landed on the
|
||||||
|
// right cells rather than merely on six numbers.
|
||||||
|
const base = p.netPremium! + p.policyFee!;
|
||||||
|
expect(Math.round(base * 0.08 * 100) / 100).toBe(p.tax);
|
||||||
|
expect(Math.round((base + p.tax!) * 100) / 100).toBe(p.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fold LOCAL TAX into the IVA", () => {
|
||||||
|
// It prints 0.00 here, so nothing to fold — but the guard is that a
|
||||||
|
// non-zero one would surface as a note instead of inflating `tax`.
|
||||||
|
expect(p.notes.join(" | ")).not.toMatch(/impuesto local/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the vehicle by token role, not by column", () => {
|
||||||
|
expect(p.vehicles).toHaveLength(1);
|
||||||
|
expect(p.vehicles[0]).toEqual({
|
||||||
|
item: "VEHICLE",
|
||||||
|
modelYear: "2017",
|
||||||
|
make: "CHRYSLER",
|
||||||
|
bodyType: "PACIFICA",
|
||||||
|
vinNumber: "2C4RC1DG7HR654698",
|
||||||
|
licensePlate: "8BPX206",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a two-word BODY cell without losing the VIN", () => {
|
||||||
|
// "GENESIS SEDAN" is two tokens where "PACIFICA" is one — the VIN shape
|
||||||
|
// is the anchor, not the token count.
|
||||||
|
const v = parsePolicy(ANA_AUTO_RC_DIAS).vehicles[0];
|
||||||
|
expect(v.make).toBe("FORD");
|
||||||
|
expect(v.vinNumber).toBe("1FBAX2CG3NKA69091");
|
||||||
|
expect(v.licensePlate).toBe("EC46T99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the empty TRAILER and TOWING slots", () => {
|
||||||
|
// Both print a "." per cell rather than being absent.
|
||||||
|
expect(p.vehicles.map((v) => v.item)).toEqual(["VEHICLE"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the insured as a named driver with their licence", () => {
|
||||||
|
expect(p.drivers).toHaveLength(1);
|
||||||
|
expect(p.drivers[0].fullName).toBe("RAY DEAN II AND SUSAN ROCKHOLD");
|
||||||
|
expect(p.drivers[0].licenseNumber).toBe("P0066762");
|
||||||
|
expect(p.drivers[0].email).toBe("PROLABSALE@AOL.COM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read the agent's own street number as the policy number", () => {
|
||||||
|
// "BENITO JUAREZ 25 No.50 INT 38" sits three lines above the No. cell.
|
||||||
|
expect(p.policyNumber).not.toBe("50");
|
||||||
|
expect(p.notes.join(" | ")).not.toMatch(/formas/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the agent clave without picking up their postal code", () => {
|
||||||
|
// "ROSARITO, BAJA CALIFORNIA 22710" is five digits in the same band.
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/);
|
||||||
|
expect(p.notes.join(" | ")).not.toMatch(/22710/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the declared values by their printed item slot", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["MATERIAL DAMAGE — VEHICLE"]?.insuredAmount).toBe(8000);
|
||||||
|
expect(c["MATERIAL DAMAGE — TRAILER"]?.insuredAmount).toBe(0);
|
||||||
|
expect(c["TOTAL THEFT — TOWING"]?.insuredAmount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the deductible sentence out of the value columns", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["MATERIAL DAMAGE — VEHICLE"]?.deductible).toBe(
|
||||||
|
"WITH MINIMUM OF $500.00 ON AUTOS (SEDANS, COUPES, CONVERTIBLES AND " +
|
||||||
|
"STATION WAGONS) AND $500.00 ON ALL OTHERS (PICK UPS, VANS, SUV´s AND " +
|
||||||
|
"MOTOR HOMES).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake the $500.00 inside the deductible for a sum insured", () => {
|
||||||
|
// It is the one amount in the block not suffixed "DLLS.".
|
||||||
|
const amounts = p.coverages.map((c) => c.insuredAmount);
|
||||||
|
expect(amounts).not.toContain(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the per-person and per-accident limits", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(5000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(25000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records an add-on's figure as a premium, never as a sum insured", () => {
|
||||||
|
// $40 is what legal aid COST. As `insuredAmount` it would read on the
|
||||||
|
// review screen as a $40 liability limit.
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["LEGAL AID"]?.premium).toBe(40);
|
||||||
|
expect(c["LEGAL AID"]?.insuredAmount).toBeNull();
|
||||||
|
expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unpacks section 9's parenthesised limit and deductible", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
const theft = c["ELITE / ELITE PLUS — PARTIAL THEFT: EXCLUDED"];
|
||||||
|
expect(theft?.insuredAmount).toBe(0);
|
||||||
|
expect(theft?.deductible).toBe("0.00 DLLS. POR EVENTO");
|
||||||
|
expect(c["ELITE / ELITE PLUS — VANDALISM: EXCLUDED"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits each coverage once even though the PDF prints the face twice", () => {
|
||||||
|
// The real upload is ORIGINAL + AGENT COPY + receipt + three travel
|
||||||
|
// cards, all concatenated into one string before parsing.
|
||||||
|
const doubled = page(ANA_AUTO_AMPLIA.text + "\n\n" + ANA_AUTO_AMPLIA.text);
|
||||||
|
expect(parsePolicy(doubled).coverages).toHaveLength(p.coverages.length);
|
||||||
|
expect(parsePolicy(doubled).vehicles).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("policy type, as a name for confirm to resolve", () => {
|
||||||
|
it("names ANA's two faces after the legacy tables they belong to", () => {
|
||||||
|
expect(parsePolicy(ANA_AUTO_AMPLIA).policyTypeName).toBe("AUTO");
|
||||||
|
expect(parsePolicy(ANA_AUTO_RC_DIAS).policyTypeName).toBe("AUTO");
|
||||||
|
expect(parsePolicy(ANA_LICENCIA).policyTypeName).toBe("LICENCIAS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a NAME, never an id — the parser must not need a database", () => {
|
||||||
|
// Anything id-shaped here would mean the parser had reached for the DB.
|
||||||
|
for (const p of [ANA_AUTO_AMPLIA, ANA_AUTO_RC_DIAS, ANA_LICENCIA]) {
|
||||||
|
expect(parsePolicy(p).policyTypeName).toMatch(/^[A-Z_]+$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the type unnamed when no parser claimed the page", () => {
|
||||||
|
expect(parsePolicy(page("a laundry receipt")).policyTypeName).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy / ANA responsabilidad civil por días", () => {
|
||||||
|
const p = parsePolicy(ANA_AUTO_RC_DIAS);
|
||||||
|
|
||||||
|
it("reads a by-the-day term rather than defaulting to a year", () => {
|
||||||
|
// Left at the schema's 365 default this weekend policy would sit in the
|
||||||
|
// renewals window a year out.
|
||||||
|
expect(p.policyFrom?.toISOString().slice(0, 10)).toBe("2026-07-23");
|
||||||
|
expect(p.policyTo?.toISOString().slice(0, 10)).toBe("2026-07-26");
|
||||||
|
expect(p.coveragePeriodDays).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the clave when the agent's phone occupies the left cell", () => {
|
||||||
|
// The by-the-day products print "(661) 612 12 55" ahead of the clave, so
|
||||||
|
// it is no longer the first thing on its line.
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/clave de agente: 70175/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the excluded sections as excluded, not as insured for zero", () => {
|
||||||
|
const risks = p.coverages.map((c) => c.risk);
|
||||||
|
expect(risks).toContain("MATERIAL DAMAGE — VEHICLE: EXCLUDED");
|
||||||
|
expect(risks).toContain("TOTAL THEFT — TOWING: EXCLUDED");
|
||||||
|
// The liability sections are what this product actually sells, and they
|
||||||
|
// are NOT excluded.
|
||||||
|
expect(risks).toContain("LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parsePolicy / ANA driver's policy (licencia)", () => {
|
||||||
|
const p = parsePolicy(ANA_LICENCIA);
|
||||||
|
|
||||||
|
it("reads the holder off the numbered POLICY HOLDER list", () => {
|
||||||
|
expect(p.policyNumber).toBe("700489616");
|
||||||
|
expect(p.insuredName).toBe("PAMELA DENISE WAGONER");
|
||||||
|
expect(p.legalAddress).toBe("49305 HIGHWAY 74 SPC 10, PALM DESERT, CA, 92260");
|
||||||
|
expect(p.zip).toBe("92260");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists one driver, not one per printed copy of the page", () => {
|
||||||
|
// The face renders three times in the real PDF; an unbounded walk
|
||||||
|
// returns the same person three times, which reads as a three-driver
|
||||||
|
// policy rather than as a parse bug.
|
||||||
|
const tripled = page([ANA_LICENCIA.text, ANA_LICENCIA.text, ANA_LICENCIA.text].join("\n\n"));
|
||||||
|
expect(p.drivers).toHaveLength(1);
|
||||||
|
expect(parsePolicy(tripled).drivers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits the phone off the name even without the printed column gap", () => {
|
||||||
|
// The phone shares the name cell, and the only thing marking it off is
|
||||||
|
// white space — which the OCR seam is free to collapse. Depending on the
|
||||||
|
// gap surviving is what put "PAMELA DENISE WAGONER Ph.3102001538" in the
|
||||||
|
// insured field, where it matched no customer.
|
||||||
|
const collapsed = page(ANA_LICENCIA.text.replace(/ {2,}/g, " "));
|
||||||
|
expect(parsePolicy(collapsed).insuredName).toBe("PAMELA DENISE WAGONER");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the four empty driver slots", () => {
|
||||||
|
// Slots 2-5 print an empty NAME and a bare "NONE" licence.
|
||||||
|
expect(p.drivers.map((d) => d.fullName)).toEqual(["PAMELA DENISE WAGONER"]);
|
||||||
|
expect(p.drivers[0].licenseNumber).toBe("N0017668");
|
||||||
|
expect(p.drivers[0].phone).toBe("3102001538");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insures no vehicle", () => {
|
||||||
|
expect(p.vehicles).toEqual([]);
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/no ampara un veh[íi]culo determinado/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates the SUM INSURED and PREMIUM columns by position", () => {
|
||||||
|
// Both columns print the same shape ("100,000.00 usd." / "18.70 usd.")
|
||||||
|
// and neither is labelled per row — only the offset tells them apart.
|
||||||
|
const c = byRisk(p);
|
||||||
|
const pd = c["LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES"];
|
||||||
|
expect(pd?.insuredAmount).toBe(100000);
|
||||||
|
expect(pd?.premium).toBe(18.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the trailing Per Person / Per Accident labels on this layout", () => {
|
||||||
|
// They FOLLOW their amount here and PRECEDE it on the automobile face.
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.insuredAmount).toBe(100000);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.insuredAmount).toBe(200000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR PERSONA"]?.insuredAmount).toBe(4000);
|
||||||
|
expect(c["MEDICAL EXPENSES — POR EVENTO"]?.insuredAmount).toBe(20000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges a section's premium once, not once per limit", () => {
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR PERSONA"]?.premium).toBe(54.27);
|
||||||
|
expect(c["BODILY INJURY LIABILITY — POR EVENTO"]?.premium).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the section order this layout uses", () => {
|
||||||
|
// CATASTROPHIC LIABILITY prints ABOVE MEDICAL EXPENSES here and below it
|
||||||
|
// on the automobile face; blocks are keyed by where the labels land.
|
||||||
|
const c = byRisk(p);
|
||||||
|
expect(c["CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES"]?.insuredAmount).toBe(0);
|
||||||
|
expect(c["LEGAL AID"]?.premium).toBe(30);
|
||||||
|
expect(c["ROADSIDE ASSISTANCE"]?.premium).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the excluded-risk sentence that defines the product", () => {
|
||||||
|
expect(p.notes.join(" | ")).toMatch(/riesgos excluidos: Collision, overtuning/);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,100 @@
|
|||||||
|
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||||
|
import type { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||||
|
|
||||||
|
function parsed(over: Partial<ParsedPolicy> = {}): ParsedPolicy {
|
||||||
|
return {
|
||||||
|
provider: "GMX",
|
||||||
|
policyNumber: null,
|
||||||
|
insuredName: null,
|
||||||
|
notes: [],
|
||||||
|
coverages: [],
|
||||||
|
vehicles: [],
|
||||||
|
drivers: [],
|
||||||
|
...over,
|
||||||
|
} as unknown as ParsedPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prismaStub(policies: unknown[], customers: { id: string; name: string }[]) {
|
||||||
|
const findManyPolicy = jest.fn().mockResolvedValue(policies);
|
||||||
|
const findManyCustomer = jest.fn().mockResolvedValue(customers);
|
||||||
|
return {
|
||||||
|
prisma: {
|
||||||
|
policy: { findMany: findManyPolicy },
|
||||||
|
customer: { findMany: findManyCustomer },
|
||||||
|
} as unknown as PrismaService,
|
||||||
|
findManyPolicy,
|
||||||
|
findManyCustomer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BOOK = [
|
||||||
|
{ id: "cust-1", name: "WAGONER, PAMELA" },
|
||||||
|
{ id: "cust-2", name: "SMITH, JOHN" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("PolicyMatcherService name suggestions", () => {
|
||||||
|
it("suggests a customer when the policy number is new", async () => {
|
||||||
|
const { prisma } = prismaStub([], BOOK);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
const r = await svc.match(
|
||||||
|
parsed({ policyNumber: "P-999", insuredName: "PAMELA DENISE WAGONER" } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.customerSuggestions).toEqual([
|
||||||
|
expect.objectContaining({ customerId: "cust-1", tier: "PARTIAL" }),
|
||||||
|
]);
|
||||||
|
// The suggestion is surfaced, never applied.
|
||||||
|
expect(r.customerId).toBeNull();
|
||||||
|
expect(r.confident).toBe(false);
|
||||||
|
expect(r.note).toContain("posibles clientes por nombre: WAGONER, PAMELA");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suggests when the policy number could not be read at all", async () => {
|
||||||
|
const { prisma } = prismaStub([], BOOK);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
const r = await svc.match(parsed({ insuredName: "PAMELA WAGONER" } as never));
|
||||||
|
|
||||||
|
expect(r.customerSuggestions[0]).toMatchObject({ customerId: "cust-1", tier: "EXACT" });
|
||||||
|
expect(r.customerId).toBeNull();
|
||||||
|
expect(r.note).toBe(
|
||||||
|
"no se pudo leer el número de póliza; posible cliente por nombre: WAGONER, PAMELA",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not touch the book when the policy number hits", async () => {
|
||||||
|
const { prisma, findManyCustomer } = prismaStub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "pol-1",
|
||||||
|
policyNumber: "P-1",
|
||||||
|
customerId: "cust-2",
|
||||||
|
customer: { name: "SMITH, JOHN" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
BOOK,
|
||||||
|
);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
const r = await svc.match(
|
||||||
|
parsed({ policyNumber: "P-1", insuredName: "PAMELA WAGONER" } as never),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(r.confident).toBe(true);
|
||||||
|
expect(r.customerId).toBe("cust-2");
|
||||||
|
expect(r.customerSuggestions).toEqual([]);
|
||||||
|
expect(findManyCustomer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the customer book once across a batch", async () => {
|
||||||
|
const { prisma, findManyCustomer } = prismaStub([], BOOK);
|
||||||
|
const svc = new PolicyMatcherService(prisma);
|
||||||
|
|
||||||
|
await svc.match(parsed({ policyNumber: "A", insuredName: "PAMELA WAGONER" } as never));
|
||||||
|
await svc.match(parsed({ policyNumber: "B", insuredName: "JOHN SMITH" } as never));
|
||||||
|
|
||||||
|
expect(findManyCustomer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import type { ParsedPolicy } from "./parsers/policy-parser";
|
import type { ParsedPolicy } from "./parsers/policy-parser";
|
||||||
|
import {
|
||||||
|
suggestCustomersByName,
|
||||||
|
suggestionNote,
|
||||||
|
type CustomerNameRow,
|
||||||
|
type CustomerNameSuggestion,
|
||||||
|
} from "./name-matcher";
|
||||||
|
|
||||||
export interface MatchResult {
|
export interface MatchResult {
|
||||||
policyId: string | null;
|
policyId: string | null;
|
||||||
@@ -14,8 +20,24 @@ export interface MatchResult {
|
|||||||
* the policy number is shared across customers and a human must pick.
|
* the policy number is shared across customers and a human must pick.
|
||||||
*/
|
*/
|
||||||
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
|
||||||
|
/**
|
||||||
|
* Customers whose name resembles the printed insured name. Populated only
|
||||||
|
* when the policy number resolved to nothing, and never used to set
|
||||||
|
* `customerId` or `confident` — see the class comment.
|
||||||
|
*/
|
||||||
|
customerSuggestions: CustomerNameSuggestion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the customer book is reused across documents in a batch.
|
||||||
|
*
|
||||||
|
* A twenty-page batch would otherwise read all 1536 rows twenty times. The
|
||||||
|
* only cost of the staleness is that a customer created in the last minute
|
||||||
|
* is not suggested — the picker still finds them, so nothing is lost that a
|
||||||
|
* reviewer cannot do in one click.
|
||||||
|
*/
|
||||||
|
const BOOK_TTL_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
* Resolves a parsed policy page to an existing Policy (and its customer) the
|
||||||
* office already holds.
|
* office already holds.
|
||||||
@@ -33,14 +55,29 @@ export interface MatchResult {
|
|||||||
* policy numbers across customers do occur (same group policy bound by two
|
* policy numbers across customers do occur (same group policy bound by two
|
||||||
* related parties), and picking one arbitrarily would silently book the
|
* related parties), and picking one arbitrarily would silently book the
|
||||||
* wrong coverage.
|
* wrong coverage.
|
||||||
|
*
|
||||||
|
* On that zero-hit path only, the printed name is used to *rank the picker*
|
||||||
|
* — see `name-matcher.ts`. That is not a walk-back of the rule above: the
|
||||||
|
* suggestion never reaches `customerId` or `confident`, a human still picks,
|
||||||
|
* and the ranking exists because the office writes names surname-first
|
||||||
|
* ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
|
||||||
|
* DENISE WAGONER"), so the reviewer is retyping a name the machine could
|
||||||
|
* have offered.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PolicyMatcherService {
|
export class PolicyMatcherService {
|
||||||
|
private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
async match(parsed: ParsedPolicy): Promise<MatchResult> {
|
||||||
if (!parsed.policyNumber) {
|
if (!parsed.policyNumber) {
|
||||||
return this.unmatched("no se pudo leer el número de póliza");
|
// No number to search on, so the page goes to review with a picker —
|
||||||
|
// the same place the name suggestions help.
|
||||||
|
return this.unmatched(
|
||||||
|
"no se pudo leer el número de póliza",
|
||||||
|
await this.suggestByName(parsed.insuredName),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await this.prisma.policy.findMany({
|
const rows = await this.prisma.policy.findMany({
|
||||||
@@ -61,22 +98,33 @@ export class PolicyMatcherService {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
|
const suggestions = await this.suggestByName(parsed.insuredName);
|
||||||
|
const hint = suggestionNote(suggestions);
|
||||||
return {
|
return {
|
||||||
policyId: null,
|
policyId: null,
|
||||||
customerId: null,
|
customerId: null,
|
||||||
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
note: [
|
||||||
|
`no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
|
||||||
|
hint,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("; "),
|
||||||
confident: false,
|
confident: false,
|
||||||
candidates: [],
|
candidates: [],
|
||||||
|
customerSuggestions: suggestions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length > 1) {
|
if (rows.length > 1) {
|
||||||
|
// The policy number did find rows; the reviewer picks among those, and
|
||||||
|
// adding name guesses on top would only add noise.
|
||||||
return {
|
return {
|
||||||
policyId: null,
|
policyId: null,
|
||||||
customerId: null,
|
customerId: null,
|
||||||
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
|
||||||
confident: false,
|
confident: false,
|
||||||
candidates,
|
candidates,
|
||||||
|
customerSuggestions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +134,47 @@ export class PolicyMatcherService {
|
|||||||
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
|
||||||
confident: true,
|
confident: true,
|
||||||
candidates,
|
candidates,
|
||||||
|
customerSuggestions: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private unmatched(note: string): MatchResult {
|
private async suggestByName(
|
||||||
|
insuredName: string | null | undefined,
|
||||||
|
): Promise<CustomerNameSuggestion[]> {
|
||||||
|
if (!insuredName) return [];
|
||||||
|
return suggestCustomersByName(insuredName, await this.customerBook());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole customer book, held briefly. 1536 rows of `{id, name}` is a
|
||||||
|
* few hundred kilobytes and the comparison is pure token-set work, so
|
||||||
|
* scanning it beats any SQL approximation — and a `LIKE` search would in
|
||||||
|
* any case have to guess which token is the surname, which is the one
|
||||||
|
* thing the office's own data does not agree on.
|
||||||
|
*/
|
||||||
|
private async customerBook(): Promise<CustomerNameRow[]> {
|
||||||
|
if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
|
||||||
|
return this.book.rows;
|
||||||
|
}
|
||||||
|
const rows = await this.prisma.customer.findMany({
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
this.book = { rows, loadedAt: Date.now() };
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unmatched(
|
||||||
|
note: string,
|
||||||
|
customerSuggestions: CustomerNameSuggestion[] = [],
|
||||||
|
): MatchResult {
|
||||||
|
const hint = suggestionNote(customerSuggestions);
|
||||||
return {
|
return {
|
||||||
policyId: null,
|
policyId: null,
|
||||||
customerId: null,
|
customerId: null,
|
||||||
note,
|
note: [note, hint].filter(Boolean).join("; "),
|
||||||
confident: false,
|
confident: false,
|
||||||
candidates: [],
|
candidates: [],
|
||||||
|
customerSuggestions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,10 +3,13 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsObject,
|
IsObject,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
@@ -19,6 +22,11 @@ export class ConfirmPolicyDocumentDto {
|
|||||||
|
|
||||||
/** Required when creating a new Policy; ignored if `policyId` is set. */
|
/** Required when creating a new Policy; ignored if `policyId` is set. */
|
||||||
@IsOptional() @IsString() customerId?: string;
|
@IsOptional() @IsString() customerId?: string;
|
||||||
|
/** Reviewer's explicit lookup picks. Both beat the parsed name; omitted,
|
||||||
|
* the service resolves `policy_types` / `insurance_providers` by name and
|
||||||
|
* leaves the FK null when there is no such row. */
|
||||||
|
@IsOptional() @IsString() policyTypeId?: string;
|
||||||
|
@IsOptional() @IsString() insuranceProviderId?: string;
|
||||||
/** Set when the document matched an existing Policy. */
|
/** Set when the document matched an existing Policy. */
|
||||||
@IsOptional() @IsString() policyId?: string;
|
@IsOptional() @IsString() policyId?: string;
|
||||||
|
|
||||||
@@ -35,8 +43,12 @@ export class ConfirmPolicyDocumentDto {
|
|||||||
@IsOptional() @IsNumber() netPremium?: number;
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
@IsOptional() @IsNumber() policyFee?: number;
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
@IsOptional() @IsString() premiumPayment?: string;
|
@IsOptional() @IsString() premiumPayment?: string;
|
||||||
|
/** Printed term in days. Omitted leaves the parsed value (or the schema's
|
||||||
|
* 365 default) in place; ANA sells 3- and 4-day tourist policies. */
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||||
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
|
/** Coverages parsed off the PDF, passed through verbatim to Policy.coveragesJson. */
|
||||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||||
|
|
||||||
@@ -68,8 +80,10 @@ export class ReviewPolicyDocumentDto {
|
|||||||
@IsOptional() @IsNumber() netPremium?: number;
|
@IsOptional() @IsNumber() netPremium?: number;
|
||||||
@IsOptional() @IsNumber() policyFee?: number;
|
@IsOptional() @IsNumber() policyFee?: number;
|
||||||
@IsOptional() @IsNumber() brokerFee?: number;
|
@IsOptional() @IsNumber() brokerFee?: number;
|
||||||
|
@IsOptional() @IsNumber() tax?: number;
|
||||||
@IsOptional() @IsNumber() total?: number;
|
@IsOptional() @IsNumber() total?: number;
|
||||||
@IsOptional() @IsString() premiumPayment?: string;
|
@IsOptional() @IsString() premiumPayment?: string;
|
||||||
|
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
|
||||||
@IsOptional() @IsObject() coveragesJson?: unknown;
|
@IsOptional() @IsObject() coveragesJson?: unknown;
|
||||||
|
|
||||||
/** Set by the reviewer when the document matched an existing Policy. */
|
/** Set by the reviewer when the document matched an existing Policy. */
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { PrismaService } from "../prisma/prisma.service";
|
|||||||
import { StorageService } from "../storage/storage.service";
|
import { StorageService } from "../storage/storage.service";
|
||||||
import type { UploadedFileLike } from "../storage/upload-file";
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider";
|
import { OCR_PROVIDER, type OcrPage, type OcrProvider } from "../statements/ocr/ocr.provider";
|
||||||
import { parsePolicy } from "./parsers/policy-parser";
|
import { parsePolicy, type ParsedDriver, type ParsedVehicle } from "./parsers/policy-parser";
|
||||||
import { PolicyMatcherService } from "./policy-matcher.service";
|
import { PolicyMatcherService } from "./policy-matcher.service";
|
||||||
import type {
|
import type {
|
||||||
ConfirmPolicyBatchDto,
|
ConfirmPolicyBatchDto,
|
||||||
@@ -69,8 +69,11 @@ export class PolicyOcrService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The provider is not asked of the uploader and not assumed: `process`
|
||||||
|
// sets it from what the parsers actually claimed, so the batch label can
|
||||||
|
// never contradict its own documents. Until then it says so.
|
||||||
const batch = await this.prisma.policyOcrBatch.create({
|
const batch = await this.prisma.policyOcrBatch.create({
|
||||||
data: { provider: "GMX", uploadedById, label, fileCount: files.length },
|
data: { provider: "por detectar", uploadedById, label, fileCount: files.length },
|
||||||
});
|
});
|
||||||
|
|
||||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||||
@@ -112,6 +115,7 @@ export class PolicyOcrService {
|
|||||||
|
|
||||||
let fileOrdinal = 0;
|
let fileOrdinal = 0;
|
||||||
let globalPageOrdinal = 0;
|
let globalPageOrdinal = 0;
|
||||||
|
const providersSeen = new Set<string>();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
fileOrdinal += 1;
|
fileOrdinal += 1;
|
||||||
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
|
const sourceKey = `policy-ocr/${batchId}/source-${fileOrdinal}.pdf`;
|
||||||
@@ -155,6 +159,7 @@ export class PolicyOcrService {
|
|||||||
if (parsed.provider === "") {
|
if (parsed.provider === "") {
|
||||||
throw new Error("no se reconoció el proveedor");
|
throw new Error("no se reconoció el proveedor");
|
||||||
}
|
}
|
||||||
|
providersSeen.add(parsed.provider);
|
||||||
const match = await this.matcher.match(parsed);
|
const match = await this.matcher.match(parsed);
|
||||||
const notes = [...parsed.notes, match.note].filter(Boolean);
|
const notes = [...parsed.notes, match.note].filter(Boolean);
|
||||||
// Confident when exactly one Policy carries the printed number —
|
// Confident when exactly one Policy carries the printed number —
|
||||||
@@ -187,18 +192,31 @@ export class PolicyOcrService {
|
|||||||
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
|
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
|
||||||
extractedBrokerFee:
|
extractedBrokerFee:
|
||||||
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
|
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
|
||||||
|
extractedTax:
|
||||||
|
parsed.tax != null ? new Prisma.Decimal(parsed.tax) : null,
|
||||||
extractedTotal:
|
extractedTotal:
|
||||||
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
|
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
|
||||||
extractedCoveragesJson: parsed.coverages.length
|
extractedCoveragesJson: parsed.coverages.length
|
||||||
? (parsed.coverages as unknown as Prisma.InputJsonValue)
|
? (parsed.coverages as unknown as Prisma.InputJsonValue)
|
||||||
: Prisma.DbNull,
|
: Prisma.DbNull,
|
||||||
extractedPremiumPayment: parsed.premiumPayment,
|
extractedPremiumPayment: parsed.premiumPayment,
|
||||||
|
extractedCoveragePeriodDays: parsed.coveragePeriodDays,
|
||||||
|
extractedVehiclesJson: parsed.vehicles.length
|
||||||
|
? (parsed.vehicles as unknown as Prisma.InputJsonValue)
|
||||||
|
: Prisma.DbNull,
|
||||||
|
extractedDriversJson: parsed.drivers.length
|
||||||
|
? (parsed.drivers as unknown as Prisma.InputJsonValue)
|
||||||
|
: Prisma.DbNull,
|
||||||
|
extractedPolicyTypeName: parsed.policyTypeName,
|
||||||
matchedPolicyId: match.policyId,
|
matchedPolicyId: match.policyId,
|
||||||
matchedCustomerId: match.customerId,
|
matchedCustomerId: match.customerId,
|
||||||
matchCandidates: match.candidates.length
|
matchCandidates: match.candidates.length
|
||||||
? (match.candidates as unknown as Prisma.InputJsonValue)
|
? (match.candidates as unknown as Prisma.InputJsonValue)
|
||||||
: Prisma.DbNull,
|
: Prisma.DbNull,
|
||||||
matchNote: notes.join("; ").slice(0, 190),
|
customerSuggestions: match.customerSuggestions.length
|
||||||
|
? (match.customerSuggestions as unknown as Prisma.InputJsonValue)
|
||||||
|
: Prisma.DbNull,
|
||||||
|
matchNote: notes.join("; "),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -211,7 +229,7 @@ export class PolicyOcrService {
|
|||||||
pageNumber: fileOrdinal,
|
pageNumber: fileOrdinal,
|
||||||
storageKey: sourceKey,
|
storageKey: sourceKey,
|
||||||
status: "OCR_FAILED",
|
status: "OCR_FAILED",
|
||||||
matchNote: (err as Error).message.slice(0, 190),
|
matchNote: (err as Error).message,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -219,7 +237,13 @@ export class PolicyOcrService {
|
|||||||
|
|
||||||
await this.prisma.policyOcrBatch.update({
|
await this.prisma.policyOcrBatch.update({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
data: { status: "READY_FOR_REVIEW" },
|
data: {
|
||||||
|
status: "READY_FOR_REVIEW",
|
||||||
|
// Whatever the parsers claimed. A mixed upload is labelled as mixed
|
||||||
|
// rather than as whichever provider happened to come first — the
|
||||||
|
// review header is the only place staff see what they dropped in.
|
||||||
|
provider: [...providersSeen].sort().join(" + ") || "desconocido",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,12 +367,14 @@ export class PolicyOcrService {
|
|||||||
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
|
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
|
||||||
extractedBrokerFee:
|
extractedBrokerFee:
|
||||||
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
|
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
|
||||||
|
extractedTax: dto.tax != null ? new Prisma.Decimal(dto.tax) : undefined,
|
||||||
extractedTotal:
|
extractedTotal:
|
||||||
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
|
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
|
||||||
extractedCoveragesJson: dto.coveragesJson
|
extractedCoveragesJson: dto.coveragesJson
|
||||||
? (dto.coveragesJson as Prisma.InputJsonValue)
|
? (dto.coveragesJson as Prisma.InputJsonValue)
|
||||||
: undefined,
|
: undefined,
|
||||||
extractedPremiumPayment: dto.premiumPayment ?? undefined,
|
extractedPremiumPayment: dto.premiumPayment ?? undefined,
|
||||||
|
extractedCoveragePeriodDays: dto.coveragePeriodDays ?? undefined,
|
||||||
matchedPolicyId,
|
matchedPolicyId,
|
||||||
matchedCustomerId,
|
matchedCustomerId,
|
||||||
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
|
status: dto.forceConfirm ? "CONFIRMED" : "MATCHED",
|
||||||
@@ -447,14 +473,18 @@ export class PolicyOcrService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Resolve target Policy (create or update). Field selection: every
|
// 1. Resolve the lookup rows the parser can only name. The reviewer's
|
||||||
|
// explicit pick always wins; the parsed name is the fallback.
|
||||||
|
const lookups = await this.resolveLookups(item, doc);
|
||||||
|
|
||||||
|
// 2. Resolve target Policy (create or update). Field selection: every
|
||||||
// non-null `extracted*` on the doc (post-review) is written. Null is
|
// non-null `extracted*` on the doc (post-review) is written. Null is
|
||||||
// preserved — never overwrite an existing Policy's `netPremium` with
|
// preserved — never overwrite an existing Policy's `netPremium` with
|
||||||
// null because the certificate page didn't carry one.
|
// null because the certificate page didn't carry one.
|
||||||
let policyId = item.policyId ?? null;
|
let policyId = item.policyId ?? null;
|
||||||
|
|
||||||
if (policyId) {
|
if (policyId) {
|
||||||
const updateData = buildPolicyUpdateFromDoc(item, doc);
|
const updateData = buildPolicyUpdateFromDoc(item, doc, lookups);
|
||||||
await this.prisma.policy.update({
|
await this.prisma.policy.update({
|
||||||
where: { id: policyId },
|
where: { id: policyId },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
@@ -467,21 +497,25 @@ export class PolicyOcrService {
|
|||||||
`Documento página ${doc.pageNumber}: falta número de póliza.`,
|
`Documento página ${doc.pageNumber}: falta número de póliza.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const createData = buildPolicyCreateFromDoc(item, doc, item.customerId!);
|
const createData = buildPolicyCreateFromDoc(item, doc, item.customerId!, lookups);
|
||||||
const created = await this.prisma.policy.create({
|
const created = await this.prisma.policy.create({
|
||||||
data: createData,
|
data: createData,
|
||||||
});
|
});
|
||||||
policyId = created.id;
|
policyId = created.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Attach the source PDF as a PolicyDocument. `doc.storageKey`
|
// 3. Vehicles and named drivers, for the providers whose face carries
|
||||||
|
// them (ANA's automobile and driver's policies; never GMX Hogar).
|
||||||
|
await this.applyVehiclesAndDrivers(doc, policyId);
|
||||||
|
|
||||||
|
// 4. Attach the source PDF as a PolicyDocument. `doc.storageKey`
|
||||||
// already points at the exact upload (`policy-ocr/{batchId}/source-N.pdf`)
|
// already points at the exact upload (`policy-ocr/{batchId}/source-N.pdf`)
|
||||||
// so the attach is just a stream copy into the policy's namespace —
|
// so the attach is just a stream copy into the policy's namespace —
|
||||||
// the previous per-page "which file did this page come from" walk is
|
// the previous per-page "which file did this page come from" walk is
|
||||||
// gone because one PDF = one doc now.
|
// gone because one PDF = one doc now.
|
||||||
await this.attachSourcePdf(doc.storageKey, policyId);
|
await this.attachSourcePdf(doc.storageKey, policyId, doc.provider);
|
||||||
|
|
||||||
// 3. Optionally post the premium to the ledger. Only when staff
|
// 5. Optionally post the premium to the ledger. Only when staff
|
||||||
// explicitly asked (`postPremium` true) and netPremium parses — without
|
// explicitly asked (`postPremium` true) and netPremium parses — without
|
||||||
// that gate a missing premium would silently book $0.
|
// that gate a missing premium would silently book $0.
|
||||||
let postedTransactionId: string | null = null;
|
let postedTransactionId: string | null = null;
|
||||||
@@ -539,13 +573,149 @@ export class PolicyOcrService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn the two things the parser can only NAME into foreign keys.
|
||||||
|
*
|
||||||
|
* The parser is a pure function over text and never touches the database,
|
||||||
|
* so it emits `policyTypeName` ("AUTO") and `provider` ("ANA"). Resolving
|
||||||
|
* them here keeps that boundary and means a renamed lookup row is a data
|
||||||
|
* change rather than a parser change.
|
||||||
|
*
|
||||||
|
* **Resolve, never create.** A missing `policy_types` row is a signal that
|
||||||
|
* a human deleted it (that is exactly how M_EMPR disappeared), and silently
|
||||||
|
* recreating it would undo that decision with no record. The field stays
|
||||||
|
* null and the reviewer can add the row through the lookups screen.
|
||||||
|
*
|
||||||
|
* An explicit pick from the reviewer always beats the parsed name.
|
||||||
|
*/
|
||||||
|
private async resolveLookups(
|
||||||
|
item: ConfirmPolicyDocumentDto,
|
||||||
|
doc: { extractedPolicyTypeName: string | null; provider: string | null },
|
||||||
|
): Promise<{ policyTypeId?: string; insuranceProviderId?: string }> {
|
||||||
|
const out: { policyTypeId?: string; insuranceProviderId?: string } = {};
|
||||||
|
|
||||||
|
if (item.policyTypeId) {
|
||||||
|
out.policyTypeId = item.policyTypeId;
|
||||||
|
} else if (doc.extractedPolicyTypeName) {
|
||||||
|
const row = await this.prisma.policyType.findUnique({
|
||||||
|
where: { name: doc.extractedPolicyTypeName },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (row) out.policyTypeId = row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.insuranceProviderId) {
|
||||||
|
out.insuranceProviderId = item.insuranceProviderId;
|
||||||
|
} else if (doc.provider) {
|
||||||
|
const name = PROVIDER_ROW_NAME[doc.provider] ?? doc.provider;
|
||||||
|
const row = await this.prisma.insuranceProvider.findFirst({
|
||||||
|
where: { name },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (row) out.insuranceProviderId = row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the parsed `Vehicle` and `InsuredDriver` rows onto the policy.
|
||||||
|
*
|
||||||
|
* Both inserts are skipped when an equivalent row is already on the policy.
|
||||||
|
* The reason is `confirmBatch` applying to an EXISTING policy: the office
|
||||||
|
* uploads a renewal for a car already on file, and a blind insert would
|
||||||
|
* leave the customer with the same VIN listed twice with no way to tell
|
||||||
|
* which row the renewal belongs to. Matching is on the identifier the
|
||||||
|
* document actually prints — the VIN for a vehicle (falling back to the
|
||||||
|
* plate, since ANA's TRAILER/TOWING slots have no VIN), the licence number
|
||||||
|
* for a driver (falling back to the name).
|
||||||
|
*
|
||||||
|
* Nothing is ever updated or deleted here. A vehicle whose plate changed
|
||||||
|
* lands as a second row for a human to reconcile, which is the safe half
|
||||||
|
* of the mistake: an over-write would destroy the only record of what was
|
||||||
|
* insured last term.
|
||||||
|
*/
|
||||||
|
private async applyVehiclesAndDrivers(
|
||||||
|
doc: { extractedVehiclesJson: Prisma.JsonValue | null; extractedDriversJson: Prisma.JsonValue | null },
|
||||||
|
policyId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const vehicles = asArray<ParsedVehicle>(doc.extractedVehiclesJson);
|
||||||
|
const drivers = asArray<ParsedDriver>(doc.extractedDriversJson);
|
||||||
|
if (vehicles.length === 0 && drivers.length === 0) return;
|
||||||
|
|
||||||
|
const policy = await this.prisma.policy.findUnique({
|
||||||
|
where: { id: policyId },
|
||||||
|
select: { customerId: true },
|
||||||
|
});
|
||||||
|
if (!policy) return;
|
||||||
|
|
||||||
|
if (vehicles.length) {
|
||||||
|
const existing = await this.prisma.vehicle.findMany({
|
||||||
|
where: { policyId },
|
||||||
|
select: { vinNumber: true, licensePlate: true },
|
||||||
|
});
|
||||||
|
const seen = new Set(
|
||||||
|
existing.flatMap((v) =>
|
||||||
|
[v.vinNumber, v.licensePlate].filter((k): k is string => !!k).map(norm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const v of vehicles) {
|
||||||
|
const key = norm(v.vinNumber ?? v.licensePlate ?? "");
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
await this.prisma.vehicle.create({
|
||||||
|
data: {
|
||||||
|
policyId,
|
||||||
|
customerId: policy.customerId,
|
||||||
|
make: v.make,
|
||||||
|
// ANA prints one BODY cell, not separate model/body columns, so
|
||||||
|
// it lands on `bodyType`; `model` stays null rather than being
|
||||||
|
// guessed out of the same string.
|
||||||
|
bodyType: v.bodyType,
|
||||||
|
modelYear: v.modelYear,
|
||||||
|
vinNumber: v.vinNumber,
|
||||||
|
licensePlate: v.licensePlate,
|
||||||
|
// "VEHICLE" / "TRAILER" / "TOWING" — the printed slot, which is
|
||||||
|
// the difference between the insured car and the trailer behind
|
||||||
|
// it and has no column of its own.
|
||||||
|
notes: v.item && v.item !== "VEHICLE" ? v.item : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drivers.length) {
|
||||||
|
const existing = await this.prisma.insuredDriver.findMany({
|
||||||
|
where: { policyId },
|
||||||
|
select: { licenseNumber: true, fullName: true },
|
||||||
|
});
|
||||||
|
const seen = new Set(
|
||||||
|
existing.flatMap((d) =>
|
||||||
|
[d.licenseNumber, d.fullName].filter((k): k is string => !!k).map(norm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const d of drivers) {
|
||||||
|
const key = norm(d.licenseNumber ?? d.fullName ?? "");
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
await this.prisma.insuredDriver.create({
|
||||||
|
data: { policyId, fullName: d.fullName, licenseNumber: d.licenseNumber },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream the source PDF (`sourceKey`, set by `process` on the doc row)
|
* Stream the source PDF (`sourceKey`, set by `process` on the doc row)
|
||||||
* into the policy's storage namespace and create a `PolicyDocument`
|
* into the policy's storage namespace and create a `PolicyDocument`
|
||||||
* pointer. Trivial now that the doc row holds the exact source key —
|
* pointer. Trivial now that the doc row holds the exact source key —
|
||||||
* the old per-page "which file did this page come from" walk is gone.
|
* the old per-page "which file did this page come from" walk is gone.
|
||||||
*/
|
*/
|
||||||
private async attachSourcePdf(sourceKey: string, policyId: string): Promise<void> {
|
private async attachSourcePdf(
|
||||||
|
sourceKey: string,
|
||||||
|
policyId: string,
|
||||||
|
provider: string | null,
|
||||||
|
): Promise<void> {
|
||||||
const got = await this.storage.getStream(sourceKey);
|
const got = await this.storage.getStream(sourceKey);
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const c of got.stream) chunks.push(c as Buffer);
|
for await (const c of got.stream) chunks.push(c as Buffer);
|
||||||
@@ -556,7 +726,10 @@ export class PolicyOcrService {
|
|||||||
await this.prisma.policyDocument.create({
|
await this.prisma.policyDocument.create({
|
||||||
data: {
|
data: {
|
||||||
policyId,
|
policyId,
|
||||||
documentType: "GMX_POLICY",
|
// Named after whichever parser claimed the page. Was hardcoded
|
||||||
|
// `GMX_POLICY`, which mislabelled every ANA upload as a GMX
|
||||||
|
// document in the policy's file list.
|
||||||
|
documentType: `${provider ?? "OCR"}_POLICY`,
|
||||||
storageKey: newKey,
|
storageKey: newKey,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -588,6 +761,27 @@ export class PolicyOcrService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parser's provider code is not the carrier's row name in
|
||||||
|
* `insurance_providers`, and the two namespaces are allowed to differ.
|
||||||
|
*
|
||||||
|
* ANA is the case that forces this: the office's book is filed under
|
||||||
|
* "ANA SEGUROS" (738 policies). A bare "ANA" row also existed with 1 policy
|
||||||
|
* and is merged away by `20260815160000_policy_type_repair`, so an exact-name
|
||||||
|
* lookup on the parser's "ANA" would find nothing at all after that migration.
|
||||||
|
*
|
||||||
|
* Anything not listed resolves by its own name.
|
||||||
|
*/
|
||||||
|
const PROVIDER_ROW_NAME: Record<string, string> = {
|
||||||
|
ANA: "ANA SEGUROS",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The lookup FKs resolved for one document, absent when unresolvable. */
|
||||||
|
interface ResolvedLookups {
|
||||||
|
policyTypeId?: string;
|
||||||
|
insuranceProviderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Map a (post-review) doc + final confirmed fields onto a `Policy.update`
|
/** Map a (post-review) doc + final confirmed fields onto a `Policy.update`
|
||||||
* payload. Every field that is null in both inputs is omitted so we never
|
* payload. Every field that is null in both inputs is omitted so we never
|
||||||
* write null over a value the Policy already carries (the GMX certificate
|
* write null over a value the Policy already carries (the GMX certificate
|
||||||
@@ -608,10 +802,13 @@ function buildPolicyUpdateFromDoc(
|
|||||||
extractedNetPremium: Prisma.Decimal | null;
|
extractedNetPremium: Prisma.Decimal | null;
|
||||||
extractedPolicyFee: Prisma.Decimal | null;
|
extractedPolicyFee: Prisma.Decimal | null;
|
||||||
extractedBrokerFee: Prisma.Decimal | null;
|
extractedBrokerFee: Prisma.Decimal | null;
|
||||||
|
extractedTax: Prisma.Decimal | null;
|
||||||
extractedTotal: Prisma.Decimal | null;
|
extractedTotal: Prisma.Decimal | null;
|
||||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||||
extractedPremiumPayment: string | null;
|
extractedPremiumPayment: string | null;
|
||||||
|
extractedCoveragePeriodDays: number | null;
|
||||||
},
|
},
|
||||||
|
lookups: ResolvedLookups,
|
||||||
): Prisma.PolicyUpdateInput {
|
): Prisma.PolicyUpdateInput {
|
||||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||||
if (a != null) return new Prisma.Decimal(a);
|
if (a != null) return new Prisma.Decimal(a);
|
||||||
@@ -631,14 +828,31 @@ function buildPolicyUpdateFromDoc(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
policyNumber: strOrUndef(item.policyNumber, doc.extractedPolicyNumber),
|
policyNumber: strOrUndef(item.policyNumber, doc.extractedPolicyNumber),
|
||||||
|
// `connect` rather than a raw id: this is the CHECKED update input. Left
|
||||||
|
// undefined when unresolved, so an existing Policy never loses a type or
|
||||||
|
// carrier it already had because this document could not name one.
|
||||||
|
policyType: lookups.policyTypeId ? { connect: { id: lookups.policyTypeId } } : undefined,
|
||||||
|
insuranceProvider: lookups.insuranceProviderId
|
||||||
|
? { connect: { id: lookups.insuranceProviderId } }
|
||||||
|
: undefined,
|
||||||
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
||||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
||||||
|
// Left undefined when the document didn't print a term, so the schema
|
||||||
|
// default (365) stands for GMX. ANA's by-the-day policies DO print one,
|
||||||
|
// and the default would otherwise turn a 4-day tourist policy into an
|
||||||
|
// annual one on the renewals screen.
|
||||||
|
coveragePeriodDays:
|
||||||
|
item.coveragePeriodDays ?? doc.extractedCoveragePeriodDays ?? undefined,
|
||||||
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||||
|
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
|
||||||
|
// the rate, and back-dividing it would mint a rate the document never
|
||||||
|
// stated — the policy form resolves one from the line of business instead.
|
||||||
|
tax: numOrUndef(item.tax, doc.extractedTax),
|
||||||
total: numOrUndef(item.total, doc.extractedTotal),
|
total: numOrUndef(item.total, doc.extractedTotal),
|
||||||
// coveragesJson / observations: freeform, keep the GMX data when present.
|
// coveragesJson / observations: freeform, keep the GMX data when present.
|
||||||
coveragesJson:
|
coveragesJson:
|
||||||
@@ -680,11 +894,14 @@ function buildPolicyCreateFromDoc(
|
|||||||
extractedNetPremium: Prisma.Decimal | null;
|
extractedNetPremium: Prisma.Decimal | null;
|
||||||
extractedPolicyFee: Prisma.Decimal | null;
|
extractedPolicyFee: Prisma.Decimal | null;
|
||||||
extractedBrokerFee: Prisma.Decimal | null;
|
extractedBrokerFee: Prisma.Decimal | null;
|
||||||
|
extractedTax: Prisma.Decimal | null;
|
||||||
extractedTotal: Prisma.Decimal | null;
|
extractedTotal: Prisma.Decimal | null;
|
||||||
extractedCoveragesJson: Prisma.JsonValue | null;
|
extractedCoveragesJson: Prisma.JsonValue | null;
|
||||||
extractedPremiumPayment: string | null;
|
extractedPremiumPayment: string | null;
|
||||||
|
extractedCoveragePeriodDays: number | null;
|
||||||
},
|
},
|
||||||
customerId: string,
|
customerId: string,
|
||||||
|
lookups: ResolvedLookups,
|
||||||
): Prisma.PolicyUncheckedCreateInput {
|
): Prisma.PolicyUncheckedCreateInput {
|
||||||
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
const numOrUndef = (a: number | undefined, b: Prisma.Decimal | null): Prisma.Decimal | undefined => {
|
||||||
if (a != null) return new Prisma.Decimal(a);
|
if (a != null) return new Prisma.Decimal(a);
|
||||||
@@ -712,14 +929,26 @@ function buildPolicyCreateFromDoc(
|
|||||||
return {
|
return {
|
||||||
policyNumber,
|
policyNumber,
|
||||||
customerId,
|
customerId,
|
||||||
|
policyTypeId: lookups.policyTypeId,
|
||||||
|
insuranceProviderId: lookups.insuranceProviderId,
|
||||||
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
agentName: strOrUndef(item.agentName, doc.extractedAgentName),
|
||||||
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
policyFrom: dateOrUndef(item.policyFrom, doc.extractedPolicyFrom),
|
||||||
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
policyTo: dateOrUndef(item.policyTo, doc.extractedPolicyTo),
|
||||||
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
policyDate: dateOrUndef(item.policyDate, doc.extractedPolicyDate),
|
||||||
|
// Left undefined when the document didn't print a term, so the schema
|
||||||
|
// default (365) stands for GMX. ANA's by-the-day policies DO print one,
|
||||||
|
// and the default would otherwise turn a 4-day tourist policy into an
|
||||||
|
// annual one on the renewals screen.
|
||||||
|
coveragePeriodDays:
|
||||||
|
item.coveragePeriodDays ?? doc.extractedCoveragePeriodDays ?? undefined,
|
||||||
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
currency: strOrUndef(item.currency, doc.extractedCurrency) as Currency | undefined,
|
||||||
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
|
||||||
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
|
||||||
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
|
||||||
|
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
|
||||||
|
// the rate, and back-dividing it would mint a rate the document never
|
||||||
|
// stated — the policy form resolves one from the line of business instead.
|
||||||
|
tax: numOrUndef(item.tax, doc.extractedTax),
|
||||||
total: numOrUndef(item.total, doc.extractedTotal),
|
total: numOrUndef(item.total, doc.extractedTotal),
|
||||||
coveragesJson:
|
coveragesJson:
|
||||||
item.coveragesJson !== undefined
|
item.coveragesJson !== undefined
|
||||||
@@ -764,4 +993,18 @@ function strOrUndefDb(a: string | undefined, b: string | null): string | undefin
|
|||||||
if (a != null && a !== "") return a;
|
if (a != null && a !== "") return a;
|
||||||
if (b != null && b !== "") return b;
|
if (b != null && b !== "") return b;
|
||||||
return undefined;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A JSON column the parser wrote as an array, read back as one. Anything
|
||||||
|
* else (null, DbNull, a legacy object shape) is an empty list rather than a
|
||||||
|
* crash — these columns are only ever populated by the parser, so a
|
||||||
|
* surprise shape means old data, not a caller to reject. */
|
||||||
|
function asArray<T>(value: Prisma.JsonValue | null): T[] {
|
||||||
|
return Array.isArray(value) ? (value as unknown as T[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare identifiers the way a person would: case- and space-insensitive.
|
||||||
|
* VINs and plates are printed inconsistently ("8BPX206" vs "8BPX 206"). */
|
||||||
|
function norm(s: string): string {
|
||||||
|
return s.replace(/\s+/g, "").toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
|
|||||||
expect(result.html).toContain("Calle Uno 123");
|
expect(result.html).toContain("Calle Uno 123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits the premium when the sender did not ask for it", () => {
|
||||||
|
// The unattended sweep quotes no amount: the premium can still be
|
||||||
|
// re-rated at renewal, and a number a robot mailed out is one the office
|
||||||
|
// has to walk back.
|
||||||
|
const result = renderRenewalEmail(letter(), { includePremium: false });
|
||||||
|
|
||||||
|
expect(result.html).not.toContain("Prima");
|
||||||
|
expect(result.html).not.toContain("1,392.00");
|
||||||
|
// Everything else the customer needs is still there.
|
||||||
|
expect(result.html).toContain("POL-123");
|
||||||
|
expect(result.html).toContain("01/09/2026");
|
||||||
|
expect(result.html).toContain("Ana Pérez");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses overdue wording for generation three", () => {
|
it("uses overdue wording for generation three", () => {
|
||||||
const result = renderRenewalEmail(letter({ generation: 3 }));
|
const result = renderRenewalEmail(letter({ generation: 3 }));
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,23 @@ function row(label: string, value: string): string {
|
|||||||
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRenewalEmail(letter: RenewalLetterRow): {
|
/**
|
||||||
|
* Render one renewal letter.
|
||||||
|
*
|
||||||
|
* `includePremium` decides whether the "Prima" row appears. The unattended
|
||||||
|
* sweep sends without it — an amount quoted by a robot, on a premium that may
|
||||||
|
* still be re-rated at renewal, is a number the office has to walk back — and
|
||||||
|
* every staff-triggered send (the manual barrido and the per-row "Enviar
|
||||||
|
* aviso") keeps it, because a person chose to quote it.
|
||||||
|
*/
|
||||||
|
export function renderRenewalEmail(
|
||||||
|
letter: RenewalLetterRow,
|
||||||
|
options: { includePremium?: boolean } = {},
|
||||||
|
): {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
} {
|
} {
|
||||||
|
const includePremium = options.includePremium !== false;
|
||||||
const expired = letter.generation === 3;
|
const expired = letter.generation === 3;
|
||||||
const subject = expired
|
const subject = expired
|
||||||
? `Póliza vencida: ${letter.policyNumber}`
|
? `Póliza vencida: ${letter.policyNumber}`
|
||||||
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
|
|||||||
row("Tipo de póliza", letter.policyType),
|
row("Tipo de póliza", letter.policyType),
|
||||||
row("Aseguradora", letter.provider),
|
row("Aseguradora", letter.provider),
|
||||||
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
row("Fecha de vencimiento", displayDate(letter.policyTo)),
|
||||||
row("Prima", money(premium, letter.currency)),
|
...(includePremium ? [row("Prima", money(premium, letter.currency))] : []),
|
||||||
row("Cliente", letter.customerName),
|
row("Cliente", letter.customerName),
|
||||||
row("Correo", letter.customerEmail ?? "No disponible"),
|
row("Correo", letter.customerEmail ?? "No disponible"),
|
||||||
row("Teléfono", phone),
|
row("Teléfono", phone),
|
||||||
|
|||||||
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
|
|||||||
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => {
|
||||||
|
const manual = build({});
|
||||||
|
await manual.service.sweep("user-1");
|
||||||
|
expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima");
|
||||||
|
|
||||||
|
const automatic = build({});
|
||||||
|
await automatic.service.scheduledSweep();
|
||||||
|
const body = automatic.record.mock.calls[0][0].bodySnapshot;
|
||||||
|
// The snapshot has to match the mail that actually went out, or the
|
||||||
|
// office reads a letter the customer never received.
|
||||||
|
expect(body).not.toContain("Prima");
|
||||||
|
expect(body).toContain("700442181");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => {
|
||||||
|
const { service, prisma, send } = build({});
|
||||||
|
|
||||||
|
const result = await service.sweep("user-1", { providerId: "gmx-id" });
|
||||||
|
|
||||||
|
expect(result.sent).toBe(1);
|
||||||
|
expect(result.providerId).toBe("gmx-id");
|
||||||
|
expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({
|
||||||
|
insuranceProviderId: "gmx-id",
|
||||||
|
});
|
||||||
|
expect(send).toHaveBeenCalledTimes(1);
|
||||||
|
// Only one carrier was mailed, so the days this run covered are still owed
|
||||||
|
// to every other carrier: advancing `lastSuccessfulAt` would move them out
|
||||||
|
// of tomorrow's window and they would never be sent.
|
||||||
|
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||||
|
expect(release.data.lastSuccessfulAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances the catch-up window on a clean unfiltered sweep", async () => {
|
||||||
|
const { service, prisma } = build({});
|
||||||
|
|
||||||
|
await service.sweep("user-1");
|
||||||
|
|
||||||
|
expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty(
|
||||||
|
"insuranceProviderId",
|
||||||
|
);
|
||||||
|
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
|
||||||
|
expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not fail a delivered notice when the log write throws", async () => {
|
it("does not fail a delivered notice when the log write throws", async () => {
|
||||||
const { service, record } = build({});
|
const { service, record } = build({});
|
||||||
record.mockRejectedValue(new Error("log table gone"));
|
record.mockRejectedValue(new Error("log table gone"));
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SweepRenewalsDto extends RenewalFlagsDto {
|
||||||
|
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
providerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
class SendRenewalDto extends RenewalFlagsDto {
|
class SendRenewalDto extends RenewalFlagsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
policyId!: string;
|
policyId!: string;
|
||||||
@@ -43,17 +50,22 @@ export class RenewalsController {
|
|||||||
constructor(private readonly renewals: RenewalsService) {}
|
constructor(private readonly renewals: RenewalsService) {}
|
||||||
|
|
||||||
@Get("pending")
|
@Get("pending")
|
||||||
pending(@Query("days") days?: string) {
|
pending(
|
||||||
|
@Query("days") days?: string,
|
||||||
|
@Query("providerId") providerId?: string,
|
||||||
|
) {
|
||||||
return this.renewals.pending(
|
return this.renewals.pending(
|
||||||
Math.min(365, Math.max(1, Number(days) || 30)),
|
Math.min(365, Math.max(1, Number(days) || 30)),
|
||||||
|
providerId?.trim() || undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("sweep")
|
@Post("sweep")
|
||||||
@RequireAbility("renewal:send")
|
@RequireAbility("renewal:send")
|
||||||
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) {
|
sweep(@Body() dto: SweepRenewalsDto, @Req() req: Request) {
|
||||||
return this.renewals.sweep((req.user as { id: string }).id, {
|
return this.renewals.sweep((req.user as { id: string }).id, {
|
||||||
debug: dto?.debug,
|
debug: dto?.debug,
|
||||||
|
providerId: dto?.providerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,10 +84,14 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
|
|
||||||
/** The unattended run always sends for real: `debug` is a per-click switch
|
/** The unattended run always sends for real: `debug` is a per-click switch
|
||||||
* in the UI, never persisted, so the schedule cannot inherit a forgotten
|
* in the UI, never persisted, so the schedule cannot inherit a forgotten
|
||||||
* test toggle and silently stop mailing customers. */
|
* test toggle and silently stop mailing customers.
|
||||||
|
*
|
||||||
|
* `automatic` is what drops the premium from the letter — see
|
||||||
|
* `renderRenewalEmail`. It is set here and nowhere else, so every sweep a
|
||||||
|
* person clicks still quotes the amount. */
|
||||||
async scheduledSweep(): Promise<void> {
|
async scheduledSweep(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.sweep();
|
await this.sweep(undefined, { automatic: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
`Falló el barrido de renovaciones: ${(error as Error).message}`,
|
||||||
@@ -95,7 +99,10 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async pending(days = 30) {
|
/** @param providerId Restrict to one aseguradora. The list has to agree
|
||||||
|
* with what a sweep would send, or the carrier-scoped barrido shows rows it
|
||||||
|
* will not mail. */
|
||||||
|
async pending(days = 30, providerId?: string) {
|
||||||
const today = dateInTimeZone(new Date());
|
const today = dateInTimeZone(new Date());
|
||||||
const state = await this.prisma.scheduledJobState.findUnique({
|
const state = await this.prisma.scheduledJobState.findUnique({
|
||||||
where: { name: JOB_NAME },
|
where: { name: JOB_NAME },
|
||||||
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
item,
|
item,
|
||||||
today,
|
today,
|
||||||
state?.lastSuccessfulAt ?? null,
|
state?.lastSuccessfulAt ?? null,
|
||||||
|
providerId,
|
||||||
),
|
),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -122,8 +130,20 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sweep(userId?: string, flags: { debug?: boolean } = {}) {
|
/**
|
||||||
|
* @param flags.providerId Sweep only one aseguradora. GMX and ANA are worked
|
||||||
|
* as separate batches by the office, so mixing them in one run is what this
|
||||||
|
* exists to prevent.
|
||||||
|
* @param flags.automatic Set only by the scheduler. Drops the premium from
|
||||||
|
* the letter.
|
||||||
|
*/
|
||||||
|
async sweep(
|
||||||
|
userId?: string,
|
||||||
|
flags: { debug?: boolean; providerId?: string; automatic?: boolean } = {},
|
||||||
|
) {
|
||||||
const debug = !!flags.debug;
|
const debug = !!flags.debug;
|
||||||
|
const providerId = flags.providerId?.trim() || undefined;
|
||||||
|
const includePremium = !flags.automatic;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const state = await this.acquireLock(now);
|
const state = await this.acquireLock(now);
|
||||||
|
|
||||||
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
cadence,
|
cadence,
|
||||||
today,
|
today,
|
||||||
state.lastSuccessfulAt,
|
state.lastSuccessfulAt,
|
||||||
|
providerId,
|
||||||
);
|
);
|
||||||
eligible += policies.length;
|
eligible += policies.length;
|
||||||
|
|
||||||
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
await this.recordLog(policy, cadence.generation, "", {
|
await this.recordLog(policy, cadence.generation, "", {
|
||||||
status: "SKIPPED_NO_EMAIL",
|
status: "SKIPPED_NO_EMAIL",
|
||||||
debug,
|
debug,
|
||||||
|
includePremium,
|
||||||
});
|
});
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.deliver(policy, cadence.generation, to, userId, debug);
|
await this.deliver(policy, cadence.generation, to, userId, {
|
||||||
|
debug,
|
||||||
|
includePremium,
|
||||||
|
});
|
||||||
sent++;
|
sent++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push({
|
failures.push({
|
||||||
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
failed: failures.length,
|
failed: failures.length,
|
||||||
failures,
|
failures,
|
||||||
debug,
|
debug,
|
||||||
|
providerId: providerId ?? null,
|
||||||
};
|
};
|
||||||
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
// A debug run must not advance `lastSuccessfulAt`: it wrote no
|
||||||
// RenewalNotice rows, so the days it "covered" are still owed, and
|
// RenewalNotice rows, so the days it "covered" are still owed, and
|
||||||
// narrowing tomorrow's window back to a single day would drop them.
|
// narrowing tomorrow's window back to a single day would drop them.
|
||||||
await this.releaseLock(!debug && failures.length === 0 ? now : null);
|
//
|
||||||
|
// A carrier-scoped run must not advance it either, for the same reason
|
||||||
|
// one step out: it looked at the whole window but only mailed one
|
||||||
|
// aseguradora, so every other carrier's letters in those days would fall
|
||||||
|
// outside tomorrow's window and never be sent at all.
|
||||||
|
const complete = !debug && !providerId && failures.length === 0;
|
||||||
|
await this.releaseLock(complete ? now : null);
|
||||||
void this.audit.log(userId, "renewalNotice.sweep", result);
|
void this.audit.log(userId, "renewalNotice.sweep", result);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
throw new BadRequestException("El cliente no tiene correo registrado.");
|
throw new BadRequestException("El cliente no tiene correo registrado.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A person clicked this, so the premium stays in the letter — only the
|
||||||
|
// scheduler's unattended run omits it.
|
||||||
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
const { sentAt, providerMessageId, addressedTo } = await this.deliver(
|
||||||
policy,
|
policy,
|
||||||
generation,
|
generation,
|
||||||
to,
|
to,
|
||||||
userId,
|
userId,
|
||||||
debug,
|
{ debug, includePremium: true },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
policyId,
|
policyId,
|
||||||
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
generation: number,
|
generation: number,
|
||||||
to: string,
|
to: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
debug = false,
|
options: { debug?: boolean; includePremium?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
|
const debug = !!options.debug;
|
||||||
|
const includePremium = options.includePremium !== false;
|
||||||
const letter = toRenewalLetterRow(policy, generation);
|
const letter = toRenewalLetterRow(policy, generation);
|
||||||
const message = renderRenewalEmail(letter);
|
const message = renderRenewalEmail(letter, { includePremium });
|
||||||
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
const addressedTo = debug ? DEBUG_RECIPIENT : to;
|
||||||
|
|
||||||
let result: Awaited<ReturnType<MailService["send"]>>;
|
let result: Awaited<ReturnType<MailService["send"]>>;
|
||||||
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
status: "FAILED",
|
status: "FAILED",
|
||||||
error: detail,
|
error: detail,
|
||||||
debug,
|
debug,
|
||||||
|
includePremium,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
providerResponse: result.response || undefined,
|
providerResponse: result.response || undefined,
|
||||||
sendDate: sentAt,
|
sendDate: sentAt,
|
||||||
debug,
|
debug,
|
||||||
|
includePremium,
|
||||||
});
|
});
|
||||||
void this.audit.log(userId, "renewalNotice.send", {
|
void this.audit.log(userId, "renewalNotice.send", {
|
||||||
policyId: policy.id,
|
policyId: policy.id,
|
||||||
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
error?: string;
|
error?: string;
|
||||||
sendDate?: Date;
|
sendDate?: Date;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
|
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
|
||||||
|
* office a letter the customer never received. */
|
||||||
|
includePremium?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const letter = toRenewalLetterRow(policy, generation);
|
const letter = toRenewalLetterRow(policy, generation);
|
||||||
const message = renderRenewalEmail(letter);
|
const message = renderRenewalEmail(letter, {
|
||||||
|
includePremium: outcome.includePremium,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await this.notificationLog.record({
|
await this.notificationLog.record({
|
||||||
notificationType: "RENEWAL_NOTICE",
|
notificationType: "RENEWAL_NOTICE",
|
||||||
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
|
|||||||
cadence: (typeof RENEWAL_CADENCE)[number],
|
cadence: (typeof RENEWAL_CADENCE)[number],
|
||||||
today: Date,
|
today: Date,
|
||||||
lastSuccessfulAt: Date | null,
|
lastSuccessfulAt: Date | null,
|
||||||
|
providerId?: string,
|
||||||
) {
|
) {
|
||||||
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
|
||||||
return this.prisma.policy.findMany({
|
return this.prisma.policy.findMany({
|
||||||
where: {
|
where: {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
...(providerId && { insuranceProviderId: providerId }),
|
||||||
policyTo: { gte: window.from, lte: window.to },
|
policyTo: { gte: window.from, lte: window.to },
|
||||||
customer: {
|
customer: {
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface CompanyInfo {
|
|||||||
addressLine2: string;
|
addressLine2: string;
|
||||||
/** "City, State, ZIP, Country" — single line. */
|
/** "City, State, ZIP, Country" — single line. */
|
||||||
cityState: string;
|
cityState: string;
|
||||||
|
/** US postal address, printed after the office address. Optional. */
|
||||||
|
mailingAddress: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
email: string;
|
email: string;
|
||||||
/** Mexican tax ID ("RFC"). Optional. */
|
/** Mexican tax ID ("RFC"). Optional. */
|
||||||
@@ -83,19 +85,23 @@ export function getCompanyInfo(): CompanyInfo {
|
|||||||
}
|
}
|
||||||
cached = {
|
cached = {
|
||||||
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
name: envOr("COMPANY_NAME", "Jorge Cuadros & Asociados"),
|
||||||
addressLine1: envOr(
|
// The office's real locator, as published on jorgecuadros.com. Prod sets
|
||||||
"COMPANY_ADDRESS_LINE1",
|
// no COMPANY_* variables, so these defaults ARE what every printed
|
||||||
"Av. Revolución 1234, Int. 5",
|
// statement shows — they were placeholders until 2026-10.
|
||||||
),
|
addressLine1: envOr("COMPANY_ADDRESS_LINE1", "Quinta Plaza Ste 3-B"),
|
||||||
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
addressLine2: envOr("COMPANY_ADDRESS_LINE2", ""),
|
||||||
cityState: envOr(
|
cityState: envOr(
|
||||||
"COMPANY_CITY_STATE",
|
"COMPANY_CITY_STATE",
|
||||||
"Tijuana, Baja California 22000, México",
|
"Rosarito, Baja California, México",
|
||||||
),
|
),
|
||||||
phone: envOr("COMPANY_PHONE", "(664) 000-0000"),
|
mailingAddress: envOr(
|
||||||
email: envOr("COMPANY_EMAIL", "contacto@jorgecuadros.local"),
|
"COMPANY_MAILING_ADDRESS",
|
||||||
|
"US: 416 W. San Ysidro Blvd L #3108, San Ysidro, CA 92173",
|
||||||
|
),
|
||||||
|
phone: envOr("COMPANY_PHONE", "011-52 (661) 612-1295"),
|
||||||
|
email: envOr("COMPANY_EMAIL", "info@jorgecuadros.com"),
|
||||||
taxId: envOr("COMPANY_TAX_ID", ""),
|
taxId: envOr("COMPANY_TAX_ID", ""),
|
||||||
website: envOr("COMPANY_WEBSITE", "jorgecuadros.local"),
|
website: envOr("COMPANY_WEBSITE", "jorgecuadros.com"),
|
||||||
logoPath,
|
logoPath,
|
||||||
logo,
|
logo,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export function renderPdf(
|
|||||||
company.addressLine1,
|
company.addressLine1,
|
||||||
company.addressLine2,
|
company.addressLine2,
|
||||||
[company.cityState].filter(Boolean).join(" · "),
|
[company.cityState].filter(Boolean).join(" · "),
|
||||||
|
company.mailingAddress,
|
||||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@@ -363,6 +364,7 @@ export function renderPrintHtml(
|
|||||||
company.addressLine1,
|
company.addressLine1,
|
||||||
company.addressLine2,
|
company.addressLine2,
|
||||||
company.cityState,
|
company.cityState,
|
||||||
|
company.mailingAddress,
|
||||||
[company.phone, company.email].filter(Boolean).join(" · "),
|
[company.phone, company.email].filter(Boolean).join(" · "),
|
||||||
company.taxId ? `RFC: ${company.taxId}` : "",
|
company.taxId ? `RFC: ${company.taxId}` : "",
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|||||||
@@ -14,8 +14,20 @@
|
|||||||
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
|
* the EFECTIVO* set OR `checkNumber` null + amount > 0 (true cash).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Prisma } from "@jorgecuadros/database";
|
import { Prisma, type ServiceKind } from "@jorgecuadros/database";
|
||||||
import {
|
import {
|
||||||
|
BALANCE_FLOOR_JOIN,
|
||||||
|
BALANCE_FORWARD_TYPE,
|
||||||
|
balanceScopeSql,
|
||||||
|
NOT_SUPERSEDED,
|
||||||
|
notCashJournal,
|
||||||
|
periodSourceTable,
|
||||||
|
} from "../billing/billing.service";
|
||||||
|
import { UTILITIES_TABLE } from "../customers/numid.service";
|
||||||
|
import type { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CONCEPT_BY_KIND } from "../statements/statements.service";
|
||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
intParam,
|
intParam,
|
||||||
NOT_VOIDED,
|
NOT_VOIDED,
|
||||||
parseDate,
|
parseDate,
|
||||||
@@ -32,19 +44,84 @@ function nameOf(c: { name: string; nameMissing: boolean }): string {
|
|||||||
return c.nameMissing ? "(sin nombre)" : c.name;
|
return c.nameMissing ? "(sin nombre)" : c.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isoDay = (d: Date | null | undefined) =>
|
||||||
|
d ? d.toISOString().slice(0, 10) : "—";
|
||||||
|
|
||||||
|
/** Every report leads with the customer's shelf folder number (2026-10 meeting). */
|
||||||
|
const FOLDER_COLUMN: ColumnDef = { key: "folder", label: "Carpeta", type: "text" };
|
||||||
|
/** Policy reports add the insurance file number beside it. */
|
||||||
|
const INSURANCE_NO_COLUMN: ColumnDef = {
|
||||||
|
key: "insuranceNo",
|
||||||
|
label: "No. seguros",
|
||||||
|
type: "text",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer numbers for a page of report rows. The office files, captures and
|
||||||
|
* talks in folder numbers, not platform ids.
|
||||||
|
*
|
||||||
|
* The insurance file number is the legacy insurance/DATGRAL id, a separate id
|
||||||
|
* space under the same table name as the utilities NUMid; 16 customers hold
|
||||||
|
* more than one.
|
||||||
|
*/
|
||||||
|
async function legacyNumbers(prisma: PrismaService, customerIds: string[]) {
|
||||||
|
const ids = [...new Set(customerIds)];
|
||||||
|
const [customers, refs] = ids.length
|
||||||
|
? await Promise.all([
|
||||||
|
prisma.customer.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: { id: true, folderNumber: true },
|
||||||
|
}),
|
||||||
|
prisma.customerLegacyRef.findMany({
|
||||||
|
where: {
|
||||||
|
customerId: { in: ids },
|
||||||
|
sourceTable: UTILITIES_TABLE,
|
||||||
|
sourceSystem: "insurance",
|
||||||
|
},
|
||||||
|
select: { customerId: true, legacyId: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [[], []];
|
||||||
|
const folders = new Map(customers.map((c) => [c.id, c.folderNumber]));
|
||||||
|
const insurance = new Map<string, string[]>();
|
||||||
|
for (const r of refs) {
|
||||||
|
insurance.set(r.customerId, [...(insurance.get(r.customerId) ?? []), r.legacyId]);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
folder: (id: string) => String(folders.get(id) ?? "—"),
|
||||||
|
insurance: (id: string) =>
|
||||||
|
insurance
|
||||||
|
.get(id)
|
||||||
|
?.sort((a, b) => Number(a) - Number(b))
|
||||||
|
.join(", ") || "—",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ reports */
|
/* ------------------------------------------------------------------ reports */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LISTADO EN ROJO — overdue customers worklist.
|
* LISTADO EN ROJO — overdue customers worklist.
|
||||||
* Same data as the receivables worklist with balance=owing, but presented
|
*
|
||||||
* as a printable report rather than a paginated browser.
|
* Legacy printed it from `ALFABETICO EN ROJO`: `SUM(CHARGECREDIT) < 0` over
|
||||||
|
* DATOS2, by name. That sum was only honest because DATOS2 held one year plus
|
||||||
|
* its BALANCE FORWARD. The platform keeps every year, so the balance here is
|
||||||
|
* the statement's own — the floor, the cash journal and the archive window via
|
||||||
|
* the same fragments `BillingService.balances()` uses. Summed raw, as this
|
||||||
|
* report used to, 195 customers read in the red against 22 real ones: every
|
||||||
|
* closed account whose history the opening balance had already settled.
|
||||||
|
*
|
||||||
|
* Ordered by the most recent real movement (Jorge, 2026-10): the oldest
|
||||||
|
* debtors are the accounts that closed, the ones worth chasing are recent.
|
||||||
|
* Inactive customers (`status = 0` — the office's own and internal accounts,
|
||||||
|
* e.g. TRASPASOS PAYPAL at -12M) stay out unless asked for.
|
||||||
*/
|
*/
|
||||||
const listadoEnRojo: ReportDef = {
|
const listadoEnRojo: ReportDef = {
|
||||||
slug: "listado-en-rojo",
|
slug: "listado-en-rojo",
|
||||||
title: "Clientes en rojo",
|
title: "Clientes en rojo",
|
||||||
description:
|
description:
|
||||||
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada, " +
|
"Cartera vencida: clientes con saldo deudor en la moneda seleccionada — " +
|
||||||
"ordenados del más antiguo al más reciente.",
|
"el mismo saldo de su estado de cuenta — ordenados por el movimiento " +
|
||||||
|
"más reciente. Los clientes inactivos quedan fuera salvo que se pidan.",
|
||||||
domain: "estado-cuenta",
|
domain: "estado-cuenta",
|
||||||
legacyName: "LISTADO EN ROJO",
|
legacyName: "LISTADO EN ROJO",
|
||||||
format: "tabular",
|
format: "tabular",
|
||||||
@@ -59,6 +136,45 @@ const listadoEnRojo: ReportDef = {
|
|||||||
],
|
],
|
||||||
defaultValue: "MXN",
|
defaultValue: "MXN",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "domain",
|
||||||
|
label: "Ramo",
|
||||||
|
kind: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "ALL", label: "Todos" },
|
||||||
|
{ value: "UTILITY", label: "Servicios" },
|
||||||
|
{ value: "INSURANCE", label: "Seguros" },
|
||||||
|
],
|
||||||
|
defaultValue: "ALL",
|
||||||
|
},
|
||||||
|
{ key: "since", label: "Con movimiento desde", kind: "date" },
|
||||||
|
{
|
||||||
|
key: "minOwed",
|
||||||
|
label: "Adeudo mínimo",
|
||||||
|
kind: "number",
|
||||||
|
placeholder: "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "inactive",
|
||||||
|
label: "Clientes inactivos",
|
||||||
|
kind: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "no", label: "Excluir" },
|
||||||
|
{ value: "yes", label: "Incluir" },
|
||||||
|
],
|
||||||
|
defaultValue: "no",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sort",
|
||||||
|
label: "Orden",
|
||||||
|
kind: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "recent", label: "Movimiento más reciente" },
|
||||||
|
{ value: "debt", label: "Mayor adeudo" },
|
||||||
|
{ value: "name", label: "Nombre" },
|
||||||
|
],
|
||||||
|
defaultValue: "recent",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "query",
|
key: "query",
|
||||||
label: "Buscar (nombre o ciudad)",
|
label: "Buscar (nombre o ciudad)",
|
||||||
@@ -67,24 +183,48 @@ const listadoEnRojo: ReportDef = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "id", label: "#", type: "text" },
|
FOLDER_COLUMN,
|
||||||
{ key: "name", label: "Cliente", type: "text" },
|
{ key: "name", label: "Cliente", type: "text" },
|
||||||
{ key: "city", label: "Ciudad", type: "text" },
|
{ key: "city", label: "Ciudad", type: "text" },
|
||||||
{ key: "movements", label: "Movs.", type: "number", align: "right" },
|
|
||||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
|
||||||
{ key: "lastMovement", label: "Último movimiento", type: "date" },
|
{ key: "lastMovement", label: "Último movimiento", type: "date" },
|
||||||
|
{ key: "lastPayment", label: "Último abono", type: "date" },
|
||||||
|
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
||||||
],
|
],
|
||||||
async run(prisma, p) {
|
async run(prisma, p) {
|
||||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
||||||
const q = p.query?.trim();
|
const q = p.query?.trim();
|
||||||
const nameFilter = q
|
const since = parseDate(p.since);
|
||||||
? Prisma.sql`AND (c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`
|
const minOwed = Math.max(Number(p.minOwed) || 0, 0.005);
|
||||||
|
|
||||||
|
const filters: Prisma.Sql[] = [];
|
||||||
|
if (q) {
|
||||||
|
filters.push(Prisma.sql`(c.name LIKE ${`%${q}%`} OR c.city LIKE ${`%${q}%`})`);
|
||||||
|
}
|
||||||
|
if (p.domain === "UTILITY" || p.domain === "INSURANCE") {
|
||||||
|
filters.push(Prisma.sql`t.domain = ${p.domain}`);
|
||||||
|
}
|
||||||
|
if (p.inactive !== "yes") {
|
||||||
|
filters.push(Prisma.sql`c.status = 1 AND c.archivedAt IS NULL`);
|
||||||
|
}
|
||||||
|
const where = filters.length
|
||||||
|
? Prisma.sql`AND ${Prisma.join(filters, " AND ")}`
|
||||||
: Prisma.empty;
|
: Prisma.empty;
|
||||||
|
|
||||||
const bal =
|
const bal =
|
||||||
currency === "USD"
|
currency === "USD"
|
||||||
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
|
? Prisma.sql`SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END)`
|
||||||
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
|
: Prisma.sql`SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END)`;
|
||||||
|
// The Jan 1 BALANCE FORWARD is not a movement — every customer has one, so
|
||||||
|
// counting it would date every closed account to this year.
|
||||||
|
const real = Prisma.sql`(tt.nameEn IS NULL OR tt.nameEn <> ${BALANCE_FORWARD_TYPE})`;
|
||||||
|
const lastMovement = Prisma.sql`MAX(CASE WHEN ${real} THEN t.transactionDate END)`;
|
||||||
|
|
||||||
|
const orderBy =
|
||||||
|
p.sort === "debt"
|
||||||
|
? Prisma.sql`ORDER BY balance ASC`
|
||||||
|
: p.sort === "name"
|
||||||
|
? Prisma.sql`ORDER BY c.nameMissing ASC, c.name ASC`
|
||||||
|
: Prisma.sql`ORDER BY lastMovement DESC, balance ASC`;
|
||||||
|
|
||||||
const rows = await prisma.$queryRaw<
|
const rows = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
@@ -92,38 +232,39 @@ const listadoEnRojo: ReportDef = {
|
|||||||
name: string;
|
name: string;
|
||||||
nameMissing: boolean;
|
nameMissing: boolean;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
movements: bigint | number | string;
|
|
||||||
balance: Prisma.Decimal | null;
|
balance: Prisma.Decimal | null;
|
||||||
lastMovement: Date | null;
|
lastMovement: Date | null;
|
||||||
|
lastPayment: Date | null;
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
SELECT c.id, c.name, c.nameMissing, c.city,
|
SELECT c.id, c.name, c.nameMissing, c.city,
|
||||||
COUNT(*) AS movements,
|
|
||||||
${bal} AS balance,
|
${bal} AS balance,
|
||||||
MAX(t.transactionDate) AS lastMovement
|
${lastMovement} AS lastMovement,
|
||||||
|
MAX(CASE WHEN ${real} AND t.amount > 0 THEN t.transactionDate END) AS lastPayment
|
||||||
FROM customers c
|
FROM customers c
|
||||||
JOIN transactions t ON t.customerId = c.id
|
JOIN transactions t ON t.customerId = c.id
|
||||||
WHERE t.voidedAt IS NULL ${nameFilter}
|
LEFT JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
|
${BALANCE_FLOOR_JOIN}
|
||||||
|
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED}
|
||||||
|
${balanceScopeSql()} ${where}
|
||||||
GROUP BY c.id, c.name, c.nameMissing, c.city
|
GROUP BY c.id, c.name, c.nameMissing, c.city
|
||||||
HAVING ${bal} < -0.005
|
HAVING ${bal} < ${-minOwed}
|
||||||
ORDER BY MAX(t.transactionDate) ASC, c.nameMissing ASC, c.name ASC
|
${since ? Prisma.sql`AND ${lastMovement} >= ${since}` : Prisma.empty}
|
||||||
|
${orderBy}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.id));
|
||||||
let totalBalance = new Prisma.Decimal(0);
|
let totalBalance = new Prisma.Decimal(0);
|
||||||
let totalMovs = 0;
|
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
const b = r.balance ?? new Prisma.Decimal(0);
|
const b = r.balance ?? new Prisma.Decimal(0);
|
||||||
totalBalance = totalBalance.plus(b);
|
totalBalance = totalBalance.plus(b);
|
||||||
totalMovs += Number(r.movements);
|
|
||||||
return {
|
return {
|
||||||
id: r.id.slice(0, 8),
|
folder: nums.folder(r.id),
|
||||||
name: nameOf(r),
|
name: nameOf(r),
|
||||||
city: r.city ?? "—",
|
city: r.city ?? "—",
|
||||||
movements: Number(r.movements),
|
lastMovement: isoDay(r.lastMovement),
|
||||||
|
lastPayment: isoDay(r.lastPayment),
|
||||||
balance: b.toFixed(2),
|
balance: b.toFixed(2),
|
||||||
lastMovement: r.lastMovement
|
|
||||||
? r.lastMovement.toISOString().slice(0, 10)
|
|
||||||
: "—",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,31 +272,58 @@ const listadoEnRojo: ReportDef = {
|
|||||||
rows: out,
|
rows: out,
|
||||||
totals: {
|
totals: {
|
||||||
customers: out.length,
|
customers: out.length,
|
||||||
movements: totalMovs,
|
|
||||||
balance: totalBalance.toFixed(2),
|
balance: totalBalance.toFixed(2),
|
||||||
currency,
|
currency,
|
||||||
},
|
},
|
||||||
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor`,
|
subtitle: `Moneda: ${currency} · ${out.length} clientes con saldo deudor${
|
||||||
|
p.inactive === "yes" ? " · incluye inactivos" : ""
|
||||||
|
}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The services whose bill the office pays and books as a ledger concept. */
|
||||||
|
const SERVICE_OPTIONS: { value: ServiceKind; label: string }[] = [
|
||||||
|
{ value: "WATER", label: "Agua" },
|
||||||
|
{ value: "ELECTRIC", label: "Luz" },
|
||||||
|
{ value: "TELEPHONE", label: "Teléfono" },
|
||||||
|
{ value: "GAS", label: "Gas" },
|
||||||
|
{ value: "PROPERTY_TAX", label: "Predial" },
|
||||||
|
{ value: "FEDERAL_ZONE", label: "Zona federal" },
|
||||||
|
{ value: "CABLE", label: "Cable" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceLabel = (kind: string) =>
|
||||||
|
SERVICE_OPTIONS.find((o) => o.value === kind)?.label ?? kind;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL).
|
* PAGOS NO EFECTUADOS (AGUA / LUZ / TEL / …).
|
||||||
* Customers enrolled in a service (by PropertyService.kind) with no
|
*
|
||||||
* related ledger charge in the last N days. Heuristic: any non-voided
|
* Legacy `PAGO AGUA/LUZ/TEL NO`: every customer with the service, LEFT JOIN
|
||||||
* charge-type transaction in the window counts as "they paid". The
|
* `LOCALIZA PAGO *` — the rows of THAT service's concept dated in the period
|
||||||
* report groups by property so the same customer with two water meters
|
* the operator typed — where nothing was found. So: "whose water did we not
|
||||||
* appears once per property.
|
* pay this month". The first port asked a different question ("no charge of
|
||||||
|
* any kind in N days"), which is why customers who were up to date kept
|
||||||
|
* showing: one IZZI or annual-fee row hid a missing water bill, and the
|
||||||
|
* reverse. It also passed `ELECTRICITY`/`PHONE`, which are not ServiceKind
|
||||||
|
* values, so Luz and Teléfono never returned a row at all.
|
||||||
|
*
|
||||||
|
* Matched per customer, as legacy did. A charge row carries no link to the
|
||||||
|
* service it paid, and its reference only matches the stored account for
|
||||||
|
* water and phone (813/822, 501/521) — CFE bills post under a reference that
|
||||||
|
* is not the RPU (5 of 1109) — so matching on the reference would list every
|
||||||
|
* electric customer as unpaid.
|
||||||
|
*
|
||||||
|
* A row captured "sin fondos" in the window is not a payment: the customer is
|
||||||
|
* listed with that status, which is the follow-up the office actually owes.
|
||||||
*/
|
*/
|
||||||
const pagosNoEfectuados: ReportDef = {
|
const pagosNoEfectuados: ReportDef = {
|
||||||
slug: "pagos-no-efectuados",
|
slug: "pagos-no-efectuados",
|
||||||
title: "Pagos no efectuados",
|
title: "Pagos no efectuados",
|
||||||
description:
|
description:
|
||||||
"Clientes con un servicio contratado (agua, luz o teléfono) sin " +
|
"Clientes con el servicio contratado sin un pago de ese servicio " +
|
||||||
"movimientos de cargo en los últimos N días. Heurística basada en el " +
|
"registrado en el periodo. Sin fechas, revisa un ciclo hacia atrás " +
|
||||||
"servicio registrado en la propiedad y la ausencia de cargos en el " +
|
"desde hoy: 31 días, o 62 para luz (bimestral).",
|
||||||
"periodo seleccionado.",
|
|
||||||
domain: "servicios",
|
domain: "servicios",
|
||||||
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
legacyName: "PAGOS NO EFECTUADOS AGUA/LUZ/TEL",
|
||||||
format: "tabular",
|
format: "tabular",
|
||||||
@@ -164,139 +332,143 @@ const pagosNoEfectuados: ReportDef = {
|
|||||||
key: "serviceKind",
|
key: "serviceKind",
|
||||||
label: "Servicio",
|
label: "Servicio",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
options: [
|
options: SERVICE_OPTIONS,
|
||||||
{ value: "WATER", label: "Agua" },
|
|
||||||
{ value: "ELECTRICITY", label: "Luz" },
|
|
||||||
{ value: "PHONE", label: "Teléfono" },
|
|
||||||
],
|
|
||||||
defaultValue: "WATER",
|
defaultValue: "WATER",
|
||||||
},
|
},
|
||||||
|
{ key: "from", label: "Desde (por defecto, un ciclo atrás)", kind: "date" },
|
||||||
|
{ key: "to", label: "Hasta (por defecto, hoy)", kind: "date", endOfDay: true },
|
||||||
{
|
{
|
||||||
key: "days",
|
key: "inactive",
|
||||||
label: "Días sin movimiento",
|
label: "Clientes inactivos",
|
||||||
kind: "number",
|
|
||||||
defaultValue: "60",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "currency",
|
|
||||||
label: "Moneda",
|
|
||||||
kind: "select",
|
kind: "select",
|
||||||
options: [
|
options: [
|
||||||
{ value: "MXN", label: "MXN" },
|
{ value: "no", label: "Excluir" },
|
||||||
{ value: "USD", label: "USD" },
|
{ value: "yes", label: "Incluir" },
|
||||||
],
|
],
|
||||||
defaultValue: "MXN",
|
defaultValue: "no",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||||
{ key: "accountNumber", label: "Cuenta / Medidor", type: "text" },
|
{ key: "accountNumber", label: "Cuenta", type: "text" },
|
||||||
{ key: "lastCharge", label: "Último cargo", type: "date" },
|
{ key: "status", label: "Estado", type: "text" },
|
||||||
{ key: "balance", label: "Saldo", type: "money", align: "right" },
|
{ key: "lastPaid", label: "Último pago del servicio", type: "date" },
|
||||||
],
|
],
|
||||||
async run(prisma, p) {
|
async run(prisma, p) {
|
||||||
const kind = (p.serviceKind ?? "WATER") as
|
const kind = (
|
||||||
| "WATER"
|
SERVICE_OPTIONS.some((o) => o.value === p.serviceKind)
|
||||||
| "ELECTRICITY"
|
? p.serviceKind
|
||||||
| "PHONE";
|
: "WATER"
|
||||||
const days = intParam(p, "days", 60, 1, 365);
|
) as ServiceKind;
|
||||||
const currency = (p.currency === "USD" ? "USD" : "MXN") as "MXN" | "USD";
|
const concept = CONCEPT_BY_KIND[kind]!;
|
||||||
const cutoff = new Date(Date.now() - days * 86400000);
|
// No period given: one billing cycle back from the end date, so the
|
||||||
|
// report means the same on the 2nd as on the 28th. "This month" listed
|
||||||
|
// nearly everyone early in the month — the bills are captured ~20th.
|
||||||
|
const to = parseDate(p.to, true) ?? new Date();
|
||||||
|
const cycleDays = kind === "ELECTRIC" ? 62 : 31;
|
||||||
|
const from =
|
||||||
|
parseDate(p.from) ?? new Date(to.getTime() - cycleDays * 86400000);
|
||||||
|
const inactive =
|
||||||
|
p.inactive === "yes"
|
||||||
|
? Prisma.empty
|
||||||
|
: Prisma.sql`AND c.status = 1`;
|
||||||
|
|
||||||
|
// One concept row for the customer, not voided, in the window.
|
||||||
|
const conceptRow = (outstanding: 0 | 1) => Prisma.sql`
|
||||||
|
SELECT 1 FROM transactions t
|
||||||
|
JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||||
|
AND t.outstanding = ${outstanding} AND tt.nameEn = ${concept}
|
||||||
|
AND t.transactionDate >= ${from} AND t.transactionDate <= ${to}`;
|
||||||
|
|
||||||
// Customers enrolled in the service on a non-archived property, with no
|
|
||||||
// charge-type transaction in the window. The subquery picks up
|
|
||||||
// *anything* the customer paid (any domain, any type) — close enough
|
|
||||||
// for the staff's "who's overdue" view; the precise per-service match
|
|
||||||
// would need a per-service typeId taxonomy that doesn't exist in the
|
|
||||||
// legacy data.
|
|
||||||
const rows = await prisma.$queryRaw<
|
const rows = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
customerId: string;
|
customerId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
nameMissing: boolean;
|
nameMissing: boolean;
|
||||||
propertyId: string;
|
addresses: string | null;
|
||||||
propertyAddress: string | null;
|
accounts: string | null;
|
||||||
accountNumber: string | null;
|
unfunded: bigint | number;
|
||||||
lastCharge: Date | null;
|
lastPaid: Date | null;
|
||||||
balance: Prisma.Decimal | null;
|
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
c.id AS customerId,
|
c.id AS customerId,
|
||||||
c.name AS customerName,
|
c.name AS customerName,
|
||||||
c.nameMissing AS nameMissing,
|
c.nameMissing AS nameMissing,
|
||||||
pr.id AS propertyId,
|
GROUP_CONCAT(DISTINCT pr.addressLine1 ORDER BY pr.addressLine1 SEPARATOR ' / ') AS addresses,
|
||||||
pr.addressLine1 AS propertyAddress,
|
GROUP_CONCAT(DISTINCT NULLIF(ps.accountNumber, '') SEPARATOR ', ') AS accounts,
|
||||||
ps.accountNumber AS accountNumber,
|
EXISTS(${conceptRow(1)}) AS unfunded,
|
||||||
(SELECT MAX(t.transactionDate) FROM transactions t
|
(SELECT MAX(t.transactionDate) FROM transactions t
|
||||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
JOIN type_transactions tt ON tt.id = t.typeId
|
||||||
AND t.amount < 0
|
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
||||||
AND t.transactionDate >= ${cutoff}) AS lastCharge,
|
AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
|
||||||
(SELECT SUM(t.amount) FROM transactions t
|
|
||||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
|
||||||
AND t.currency = ${currency}) AS balance
|
|
||||||
FROM property_services ps
|
FROM property_services ps
|
||||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
WHERE ps.kind = ${kind} AND ps.active = 1 ${inactive}
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (${conceptRow(0)})
|
||||||
SELECT 1 FROM transactions t
|
GROUP BY c.id, c.name, c.nameMissing
|
||||||
WHERE t.customerId = c.id AND t.voidedAt IS NULL
|
ORDER BY c.nameMissing ASC, c.name ASC
|
||||||
AND t.amount < 0 AND t.transactionDate >= ${cutoff}
|
|
||||||
)
|
|
||||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let totalBalance = new Prisma.Decimal(0);
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
|
||||||
|
let unfunded = 0;
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
const b = r.balance ?? new Prisma.Decimal(0);
|
if (Number(r.unfunded)) unfunded++;
|
||||||
totalBalance = totalBalance.plus(b);
|
|
||||||
return {
|
return {
|
||||||
customerId: r.customerId.slice(0, 8),
|
folder: nums.folder(r.customerId),
|
||||||
customerName: nameOf({
|
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||||
name: r.customerName,
|
propertyAddress: r.addresses ?? "—",
|
||||||
nameMissing: r.nameMissing,
|
accountNumber: r.accounts ?? "—",
|
||||||
}),
|
status: Number(r.unfunded) ? "Sin fondos" : "Sin pago",
|
||||||
propertyAddress: r.propertyAddress ?? "—",
|
lastPaid: isoDay(r.lastPaid),
|
||||||
accountNumber: r.accountNumber ?? "—",
|
|
||||||
lastCharge: r.lastCharge
|
|
||||||
? r.lastCharge.toISOString().slice(0, 10)
|
|
||||||
: "—",
|
|
||||||
balance: b.toFixed(2),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: out,
|
rows: out,
|
||||||
totals: {
|
totals: {
|
||||||
rows: out.length,
|
clientes: out.length,
|
||||||
balance: totalBalance.toFixed(2),
|
"sin pago": out.length - unfunded,
|
||||||
currency,
|
"sin fondos": unfunded,
|
||||||
},
|
},
|
||||||
subtitle: `Servicio: ${
|
subtitle: `Servicio: ${serviceLabel(kind)} · ${isoDay(from)} a ${isoDay(to)} · ${out.length} clientes sin pago registrado`,
|
||||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
|
||||||
} · ${days} días · ${out.length} propiedades sin cargo reciente`,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FALTANTES DE (AGUA / LUZ / TEL).
|
* What each service must have on file to be paid and matched — the columns
|
||||||
* Data-quality report: properties enrolled in a service that are missing
|
* legacy's FALTANTES DE AGUA/LUZ/TEL read (DATMEX.AGUA; MEDIDOR + RPU;
|
||||||
* the key identifier the legacy system required (account/meter/route).
|
* TELEFONO), in the field `scopedRefField` stores each one in. The first port
|
||||||
* Different `faltante` per service kind in the legacy because the
|
* demanded account AND meter AND route of every kind; water and phone have no
|
||||||
* service's identifier fields differ; here we flag any of the three
|
* route, so every one of them was flagged.
|
||||||
* common identifiers being blank.
|
*/
|
||||||
|
const REQUIRED_FIELDS: Partial<
|
||||||
|
Record<ServiceKind, Array<["accountNumber" | "meterNumber", string]>>
|
||||||
|
> = {
|
||||||
|
WATER: [["accountNumber", "Sin número de cuenta"]],
|
||||||
|
ELECTRIC: [
|
||||||
|
["accountNumber", "Sin RPU"],
|
||||||
|
["meterNumber", "Sin medidor"],
|
||||||
|
],
|
||||||
|
TELEPHONE: [["accountNumber", "Sin número de teléfono"]],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FALTANTES DE (AGUA / LUZ / TEL) — data quality: active services missing the
|
||||||
|
* identifier the office needs to pay and match the bill.
|
||||||
*/
|
*/
|
||||||
const faltantes: ReportDef = {
|
const faltantes: ReportDef = {
|
||||||
slug: "faltantes",
|
slug: "faltantes",
|
||||||
title: "Faltantes de datos por servicio",
|
title: "Faltantes de datos por servicio",
|
||||||
description:
|
description:
|
||||||
"Calidad de datos: propiedades con un servicio contratado que no " +
|
"Calidad de datos: servicios activos sin el dato que se necesita para " +
|
||||||
"tienen número de cuenta, medidor o ruta registrado. El reporte que " +
|
"pagar el recibo — agua: número de cuenta; luz: RPU y medidor; " +
|
||||||
"en la legacy corría como FALTANTES DE AGUA / LUZ / TEL.",
|
"teléfono: número.",
|
||||||
domain: "servicios",
|
domain: "servicios",
|
||||||
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
legacyName: "FALTANTES DE AGUA/LUZ/TEL",
|
||||||
format: "tabular",
|
format: "tabular",
|
||||||
@@ -305,82 +477,65 @@ const faltantes: ReportDef = {
|
|||||||
key: "serviceKind",
|
key: "serviceKind",
|
||||||
label: "Servicio",
|
label: "Servicio",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
options: [
|
options: SERVICE_OPTIONS.filter((o) => REQUIRED_FIELDS[o.value]),
|
||||||
{ value: "WATER", label: "Agua" },
|
|
||||||
{ value: "ELECTRICITY", label: "Luz" },
|
|
||||||
{ value: "PHONE", label: "Teléfono" },
|
|
||||||
],
|
|
||||||
defaultValue: "WATER",
|
defaultValue: "WATER",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "customerId", label: "Cliente #", type: "text" },
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
{ key: "propertyAddress", label: "Dirección", type: "text" },
|
||||||
{ key: "missing", label: "Faltante", type: "text" },
|
{ key: "missing", label: "Faltante", type: "text" },
|
||||||
{ key: "dueDay", label: "Día de vencimiento", type: "text" },
|
|
||||||
],
|
],
|
||||||
async run(prisma, p) {
|
async run(prisma, p) {
|
||||||
const kind = (p.serviceKind ?? "WATER") as
|
const kind = (
|
||||||
| "WATER"
|
p.serviceKind && REQUIRED_FIELDS[p.serviceKind as ServiceKind]
|
||||||
| "ELECTRICITY"
|
? p.serviceKind
|
||||||
| "PHONE";
|
: "WATER"
|
||||||
|
) as ServiceKind;
|
||||||
|
const required = REQUIRED_FIELDS[kind]!;
|
||||||
|
|
||||||
|
// Column names come from the constant above, never from the request.
|
||||||
|
const blank = (field: string) =>
|
||||||
|
Prisma.sql`(ps.${Prisma.raw(field)} IS NULL OR TRIM(ps.${Prisma.raw(field)}) = '')`;
|
||||||
|
|
||||||
// A row per (property, missing field). The "missing" string describes
|
|
||||||
// what's blank so the report is self-explanatory when printed.
|
|
||||||
const rows = await prisma.$queryRaw<
|
const rows = await prisma.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
customerId: string;
|
customerId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
nameMissing: boolean;
|
nameMissing: boolean;
|
||||||
propertyId: string;
|
|
||||||
propertyAddress: string | null;
|
propertyAddress: string | null;
|
||||||
dueDay: string | null;
|
|
||||||
missing: string;
|
missing: string;
|
||||||
}>
|
}>
|
||||||
>`
|
>`
|
||||||
SELECT
|
SELECT
|
||||||
c.id AS customerId,
|
c.id AS customerId,
|
||||||
c.name AS customerName,
|
c.name AS customerName,
|
||||||
c.nameMissing AS nameMissing,
|
c.nameMissing AS nameMissing,
|
||||||
pr.id AS propertyId,
|
|
||||||
pr.addressLine1 AS propertyAddress,
|
pr.addressLine1 AS propertyAddress,
|
||||||
ps.dueDay AS dueDay,
|
CONCAT_WS(', ', ${Prisma.join(
|
||||||
CASE
|
required.map(([field, label]) => Prisma.sql`IF(${blank(field)}, ${label}, NULL)`),
|
||||||
WHEN ps.accountNumber IS NULL OR ps.accountNumber = '' THEN 'Sin número de cuenta'
|
)}) AS missing
|
||||||
WHEN ps.meterNumber IS NULL OR ps.meterNumber = '' THEN 'Sin número de medidor'
|
|
||||||
WHEN ps.route IS NULL OR ps.route = '' THEN 'Sin ruta'
|
|
||||||
ELSE ''
|
|
||||||
END AS missing
|
|
||||||
FROM property_services ps
|
FROM property_services ps
|
||||||
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
JOIN properties pr ON pr.id = ps.propertyId AND pr.archivedAt IS NULL
|
||||||
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
JOIN customers c ON c.id = pr.customerId AND c.archivedAt IS NULL
|
||||||
WHERE ps.kind = ${kind} AND ps.active = 1
|
WHERE ps.kind = ${kind} AND ps.active = 1 AND c.status = 1
|
||||||
AND (
|
AND (${Prisma.join(required.map(([field]) => blank(field)), " OR ")})
|
||||||
ps.accountNumber IS NULL OR ps.accountNumber = ''
|
|
||||||
OR ps.meterNumber IS NULL OR ps.meterNumber = ''
|
|
||||||
OR ps.route IS NULL OR ps.route = ''
|
|
||||||
)
|
|
||||||
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
ORDER BY c.nameMissing ASC, c.name ASC, pr.addressLine1 ASC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customerId));
|
||||||
const out = rows.map((r) => ({
|
const out = rows.map((r) => ({
|
||||||
customerId: r.customerId.slice(0, 8),
|
folder: nums.folder(r.customerId),
|
||||||
customerName: nameOf({
|
customerName: nameOf({ name: r.customerName, nameMissing: r.nameMissing }),
|
||||||
name: r.customerName,
|
|
||||||
nameMissing: r.nameMissing,
|
|
||||||
}),
|
|
||||||
propertyAddress: r.propertyAddress ?? "—",
|
propertyAddress: r.propertyAddress ?? "—",
|
||||||
missing: r.missing,
|
missing: r.missing,
|
||||||
dueDay: r.dueDay ?? "—",
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: out,
|
rows: out,
|
||||||
totals: { rows: out.length },
|
totals: { rows: out.length },
|
||||||
subtitle: `Servicio: ${
|
subtitle: `Servicio: ${serviceLabel(kind)} · ${out.length} servicios con datos faltantes`,
|
||||||
kind === "WATER" ? "Agua" : kind === "ELECTRICITY" ? "Luz" : "Teléfono"
|
|
||||||
} · ${out.length} propiedades con datos faltantes`,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -395,8 +550,8 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
slug: "reporte-de-efectivo",
|
slug: "reporte-de-efectivo",
|
||||||
title: "Reporte de efectivo",
|
title: "Reporte de efectivo",
|
||||||
description:
|
description:
|
||||||
"Recibos de efectivo en el periodo seleccionado. Cubre los abonos " +
|
"Recibos de efectivo en el periodo seleccionado: los recibos EFE de la " +
|
||||||
"provenientes de las tablas legacy EFECTIVO* y los créditos sin " +
|
"plataforma, los de las tablas legacy EFECTIVO* y los créditos sin " +
|
||||||
"número de cheque (efectivo real). El match del reporte original.",
|
"número de cheque (efectivo real). El match del reporte original.",
|
||||||
domain: "chequera",
|
domain: "chequera",
|
||||||
legacyName: "REPORTE DE EFECTIVO",
|
legacyName: "REPORTE DE EFECTIVO",
|
||||||
@@ -417,6 +572,7 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "date", label: "Fecha", type: "date" },
|
{ key: "date", label: "Fecha", type: "date" },
|
||||||
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "concept", label: "Concepto", type: "text" },
|
{ key: "concept", label: "Concepto", type: "text" },
|
||||||
{ key: "source", label: "Origen", type: "text" },
|
{ key: "source", label: "Origen", type: "text" },
|
||||||
@@ -431,8 +587,13 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
NOT_VOIDED,
|
NOT_VOIDED,
|
||||||
{ amount: { gt: 0 } },
|
{ amount: { gt: 0 } },
|
||||||
{ currency },
|
{ currency },
|
||||||
|
// A credit opening balance has no check number but is not cash.
|
||||||
|
{ OR: [{ typeId: null }, { type: { nameEn: { not: BALANCE_FORWARD_TYPE } } }] },
|
||||||
{
|
{
|
||||||
OR: [
|
OR: [
|
||||||
|
// A platform receipt posts one row (its C<folio> credit, under a
|
||||||
|
// CASH… pseudo check), so it is matched by the receipt, not the check.
|
||||||
|
{ cashReceipt: { isNot: null } },
|
||||||
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
{ legacySourceTable: { in: ["EFECTIVO", "EFECTIVO_BACKUP"] } },
|
||||||
{
|
{
|
||||||
AND: [
|
AND: [
|
||||||
@@ -462,19 +623,22 @@ const reporteDeEfectivo: ReportDef = {
|
|||||||
checkNumber: true,
|
checkNumber: true,
|
||||||
message: true,
|
message: true,
|
||||||
legacySourceTable: true,
|
legacySourceTable: true,
|
||||||
|
cashReceipt: { select: { folio: true } },
|
||||||
type: { select: { nameEs: true, nameEn: true } },
|
type: { select: { nameEs: true, nameEn: true } },
|
||||||
customer: { select: { name: true, nameMissing: true } },
|
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
let total = new Prisma.Decimal(0);
|
let total = new Prisma.Decimal(0);
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
total = total.plus(r.amount);
|
total = total.plus(r.amount);
|
||||||
return {
|
return {
|
||||||
date: r.transactionDate.toISOString().slice(0, 10),
|
date: r.transactionDate.toISOString().slice(0, 10),
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
concept: r.message ?? r.type?.nameEs ?? r.type?.nameEn ?? "—",
|
||||||
source: r.legacySourceTable ?? "—",
|
source: r.cashReceipt ? `EFE ${r.cashReceipt.folio}` : r.legacySourceTable ?? "—",
|
||||||
amount: r.amount.toFixed(2),
|
amount: r.amount.toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -536,6 +700,8 @@ const vigente: ReportDef = {
|
|||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||||
|
FOLDER_COLUMN,
|
||||||
|
INSURANCE_NO_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||||
{ key: "agent", label: "Agente", type: "text" },
|
{ key: "agent", label: "Agente", type: "text" },
|
||||||
@@ -566,11 +732,12 @@ const vigente: ReportDef = {
|
|||||||
policyTo: true,
|
policyTo: true,
|
||||||
netPremium: true,
|
netPremium: true,
|
||||||
agentName: true,
|
agentName: true,
|
||||||
customer: { select: { name: true, nameMissing: true } },
|
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||||
insuranceProvider: { select: { name: true } },
|
insuranceProvider: { select: { name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
let totalPremium = new Prisma.Decimal(0);
|
let totalPremium = new Prisma.Decimal(0);
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
const daysTo = r.policyTo
|
const daysTo = r.policyTo
|
||||||
@@ -581,6 +748,8 @@ const vigente: ReportDef = {
|
|||||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||||
return {
|
return {
|
||||||
policyNumber: r.policyNumber,
|
policyNumber: r.policyNumber,
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
|
insuranceNo: nums.insurance(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
provider: r.insuranceProvider?.name ?? "—",
|
provider: r.insuranceProvider?.name ?? "—",
|
||||||
agent: r.agentName ?? "—",
|
agent: r.agentName ?? "—",
|
||||||
@@ -684,6 +853,8 @@ const avisoRenovacion: ReportDef = {
|
|||||||
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
// ReportRunner.tsx) — same trade-off edoCuentaDatos makes for "statement".
|
||||||
columns: [
|
columns: [
|
||||||
{ key: "policyNumber", label: "Póliza", type: "text" },
|
{ key: "policyNumber", label: "Póliza", type: "text" },
|
||||||
|
FOLDER_COLUMN,
|
||||||
|
INSURANCE_NO_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "provider", label: "Aseguradora", type: "text" },
|
{ key: "provider", label: "Aseguradora", type: "text" },
|
||||||
{ key: "policyTo", label: "Vence", type: "date" },
|
{ key: "policyTo", label: "Vence", type: "date" },
|
||||||
@@ -715,11 +886,16 @@ const avisoRenovacion: ReportDef = {
|
|||||||
select: renewalLetterSelect(generation),
|
select: renewalLetterSelect(generation),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
let totalPremium = new Prisma.Decimal(0);
|
let totalPremium = new Prisma.Decimal(0);
|
||||||
let sentCount = 0;
|
let sentCount = 0;
|
||||||
const out = rows.map((r) => {
|
const out = rows.map((r) => {
|
||||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||||
const letter = toRenewalLetterRow(r, generation);
|
const letter = {
|
||||||
|
...toRenewalLetterRow(r, generation),
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
|
insuranceNo: nums.insurance(r.customer.id),
|
||||||
|
};
|
||||||
if (letter.sentAt) sentCount++;
|
if (letter.sentAt) sentCount++;
|
||||||
return letter;
|
return letter;
|
||||||
});
|
});
|
||||||
@@ -751,13 +927,22 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
title: "Estado de cuenta",
|
title: "Estado de cuenta",
|
||||||
description:
|
description:
|
||||||
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
"Estado de cuenta de un cliente: saldos por moneda, desglose por " +
|
||||||
"ramo y concepto, y el historial completo de movimientos con saldo " +
|
"ramo y concepto, y los movimientos del año en curso con saldo " +
|
||||||
"corrido. El reporte del cliente final.",
|
"corrido, abriendo con el saldo anterior. El reporte del cliente final.",
|
||||||
domain: "estado-cuenta",
|
domain: "estado-cuenta",
|
||||||
legacyName: "EDO CUENTA DATOS",
|
legacyName: "EDO CUENTA DATOS",
|
||||||
format: "statement",
|
format: "statement",
|
||||||
params: [
|
params: [
|
||||||
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
{ key: "customerId", label: "Cliente", kind: "customer-picker" },
|
||||||
|
// Which period to print. Blank means the year in progress; an earlier year
|
||||||
|
// prints from its imported archive, the same source the on-screen
|
||||||
|
// statement reads.
|
||||||
|
{
|
||||||
|
key: "year",
|
||||||
|
label: "Periodo (año)",
|
||||||
|
kind: "number",
|
||||||
|
placeholder: "año en curso",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
// Statement rows carry synthetic `__kind` discriminators instead of
|
// Statement rows carry synthetic `__kind` discriminators instead of
|
||||||
@@ -788,23 +973,65 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
|
||||||
|
const folder = (await legacyNumbers(prisma, [customer.id])).folder(customer.id);
|
||||||
|
|
||||||
|
// The source-table exclusion, the balance floor and the year scope below
|
||||||
|
// are BillingService.statement's, because this report and
|
||||||
|
// /estado-cuenta/[id] are the same statement — one printable, one on
|
||||||
|
// screen — and a customer holding both must not read two balances.
|
||||||
|
const floor = await prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
customerId,
|
||||||
|
voidedAt: null,
|
||||||
|
type: { nameEn: BALANCE_FORWARD_TYPE },
|
||||||
|
},
|
||||||
|
orderBy: { transactionDate: "desc" },
|
||||||
|
select: { transactionDate: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which period to print. An earlier year comes from its imported archive,
|
||||||
|
// tagged rather than dated, exactly as the on-screen statement reads it.
|
||||||
|
const thisYear = new Date().getUTCFullYear();
|
||||||
|
const askedYear = Number(p.year);
|
||||||
|
const requestedYear =
|
||||||
|
Number.isInteger(askedYear) && askedYear > 0 ? askedYear : thisYear;
|
||||||
|
const isArchive = requestedYear !== thisYear;
|
||||||
|
|
||||||
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter
|
|
||||||
// as BillingService.statement so the numbers match what the customer
|
|
||||||
// already sees in /estado-cuenta/[id].
|
|
||||||
const rows = await prisma.transaction.findMany({
|
const rows = await prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
customerId,
|
customerId,
|
||||||
voidedAt: null,
|
voidedAt: null,
|
||||||
legacySourceTable: {
|
...(isArchive
|
||||||
notIn: [
|
? // The archive is one period's ledger already, so the tag is the
|
||||||
"EFECTIVO",
|
// whole filter and the balance floor must not apply — the floor
|
||||||
"EFECTIVO_BACKUP",
|
// hides exactly the history this period is asking for.
|
||||||
"EFECTIVO FM3",
|
{ legacySourceTable: periodSourceTable(requestedYear) }
|
||||||
"CHEQUE FM3",
|
: {
|
||||||
"IVA 2015",
|
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
|
||||||
],
|
// Archive rows count as history below the year start (that is
|
||||||
},
|
// what `opening` is for, and for a customer floored by an archive
|
||||||
|
// it is the only carry there is) and are dropped at or above it.
|
||||||
|
// Same rule as the on-screen twin — see BillingService.statement.
|
||||||
|
AND: [
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ legacySourceTable: null },
|
||||||
|
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
|
||||||
|
{
|
||||||
|
transactionDate: {
|
||||||
|
lt: new Date(Date.UTC(requestedYear, 0, 1)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// The cash receipt book, which the ledger already carries as
|
||||||
|
// its own `C<folio>` postings. Taken from the shared helper
|
||||||
|
// rather than restated, so the printed statement and the screen
|
||||||
|
// cannot drift apart — and so this keeps the database
|
||||||
|
// qualifier that spares the insurance line's own EFECTIVO.
|
||||||
|
notCashJournal(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
@@ -822,12 +1049,32 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compute running balance per currency, then return newest-first.
|
// Scoped to the calendar year and listed oldest-first, the way the legacy
|
||||||
|
// EDO CUENTA sheet reads. Rows from earlier years still move the running
|
||||||
|
// balance — they are folded into `opening` and printed as a single "saldo
|
||||||
|
// anterior" line, which is what a BALANCE FORWARD row is.
|
||||||
|
// An archive needs no fold: it *is* the period, and its own Jan-1 BALANCE
|
||||||
|
// FORWARD row is the carry, printed like legacy printed it.
|
||||||
|
const yearStart = isArchive
|
||||||
|
? new Date(0)
|
||||||
|
: new Date(Date.UTC(requestedYear, 0, 1));
|
||||||
|
const year = requestedYear;
|
||||||
|
|
||||||
const running = new Map<string, Prisma.Decimal>();
|
const running = new Map<string, Prisma.Decimal>();
|
||||||
const movements = rows.map((r) => {
|
const opening = new Map<string, Prisma.Decimal>();
|
||||||
|
const visible: typeof rows = [];
|
||||||
|
|
||||||
|
const movements = rows.flatMap((r) => {
|
||||||
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
|
||||||
const next = prev.plus(r.amount);
|
const next = prev.plus(r.amount);
|
||||||
running.set(r.currency, next);
|
running.set(r.currency, next);
|
||||||
|
|
||||||
|
if (r.transactionDate < yearStart) {
|
||||||
|
opening.set(r.currency, next);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.push(r);
|
||||||
return {
|
return {
|
||||||
date: r.transactionDate.toISOString().slice(0, 10),
|
date: r.transactionDate.toISOString().slice(0, 10),
|
||||||
domain: r.domain,
|
domain: r.domain,
|
||||||
@@ -840,14 +1087,38 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
balanceAfter: next.toFixed(2),
|
balanceAfter: next.toFixed(2),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
movements.reverse();
|
|
||||||
|
|
||||||
// Per-currency summary + per-domain breakdown.
|
// The carried balance, printed as the statement's first line — same shape
|
||||||
|
// as a movement row so it needs nothing special from the renderer.
|
||||||
|
const carried = [...opening.entries()]
|
||||||
|
.filter(([, amount]) => !amount.isZero())
|
||||||
|
.map(([currency, amount]) => ({
|
||||||
|
date: yearStart.toISOString().slice(0, 10),
|
||||||
|
domain: "UTILITY",
|
||||||
|
currency,
|
||||||
|
reference: "",
|
||||||
|
period: `Al cierre de ${year - 1}`,
|
||||||
|
checkNumber: "",
|
||||||
|
concept: "SALDO ANTERIOR",
|
||||||
|
amount: amount.toFixed(2),
|
||||||
|
balanceAfter: amount.toFixed(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Per-currency summary, seeded with the carried balance so it reconciles
|
||||||
|
// against the last running balance printed below.
|
||||||
const perCurrency = new Map<
|
const perCurrency = new Map<
|
||||||
string,
|
string,
|
||||||
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
|
||||||
>();
|
>();
|
||||||
for (const r of rows) {
|
for (const [currency, amount] of opening) {
|
||||||
|
perCurrency.set(currency, {
|
||||||
|
currency,
|
||||||
|
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
|
||||||
|
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const r of visible) {
|
||||||
const c =
|
const c =
|
||||||
perCurrency.get(r.currency) ??
|
perCurrency.get(r.currency) ??
|
||||||
{
|
{
|
||||||
@@ -867,6 +1138,7 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
{
|
{
|
||||||
__kind: "header",
|
__kind: "header",
|
||||||
name: nameOf(customer),
|
name: nameOf(customer),
|
||||||
|
folder,
|
||||||
address: customer.addressLine1 ?? "",
|
address: customer.addressLine1 ?? "",
|
||||||
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
city: [customer.city, customer.state].filter(Boolean).join(", "),
|
||||||
phone: customer.phone ?? "",
|
phone: customer.phone ?? "",
|
||||||
@@ -881,9 +1153,10 @@ const edoCuentaDatos: ReportDef = {
|
|||||||
count: c.count,
|
count: c.count,
|
||||||
})),
|
})),
|
||||||
{ __kind: "movements-header" },
|
{ __kind: "movements-header" },
|
||||||
|
...carried,
|
||||||
...movements,
|
...movements,
|
||||||
],
|
],
|
||||||
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`,
|
subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -921,6 +1194,7 @@ const chequeCount: ReportDef = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
columns: [
|
columns: [
|
||||||
|
FOLDER_COLUMN,
|
||||||
{ key: "customerName", label: "Cliente", type: "text" },
|
{ key: "customerName", label: "Cliente", type: "text" },
|
||||||
{ key: "reference", label: "Referencia", type: "text" },
|
{ key: "reference", label: "Referencia", type: "text" },
|
||||||
{ key: "period", label: "Periodo", type: "text" },
|
{ key: "period", label: "Periodo", type: "text" },
|
||||||
@@ -950,7 +1224,7 @@ const chequeCount: ReportDef = {
|
|||||||
period: true,
|
period: true,
|
||||||
outstanding: true,
|
outstanding: true,
|
||||||
type: { select: { nameEn: true, nameEs: true } },
|
type: { select: { nameEn: true, nameEs: true } },
|
||||||
customer: { select: { name: true, nameMissing: true } },
|
customer: { select: { id: true, name: true, nameMissing: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -976,8 +1250,10 @@ const chequeCount: ReportDef = {
|
|||||||
}
|
}
|
||||||
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
if (outstandingCount) totalsOut["sin fondos"] = outstandingCount;
|
||||||
|
|
||||||
|
const nums = await legacyNumbers(prisma, rows.map((r) => r.customer.id));
|
||||||
return {
|
return {
|
||||||
rows: rows.map((r) => ({
|
rows: rows.map((r) => ({
|
||||||
|
folder: nums.folder(r.customer.id),
|
||||||
customerName: nameOf(r.customer),
|
customerName: nameOf(r.customer),
|
||||||
reference: r.reference ?? "—",
|
reference: r.reference ?? "—",
|
||||||
period: r.period ?? "—",
|
period: r.period ?? "—",
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const SETTING_KEYS = {
|
|||||||
scheduleServicios: "notification.schedule.servicios",
|
scheduleServicios: "notification.schedule.servicios",
|
||||||
/** JSON cadence of the automatic pólizas renewal sweep. */
|
/** JSON cadence of the automatic pólizas renewal sweep. */
|
||||||
schedulePolizas: "notification.schedule.polizas",
|
schedulePolizas: "notification.schedule.polizas",
|
||||||
|
/** Whether the NUMid allocator may reuse empty portal ids. */
|
||||||
|
numidRecycleEmpty: "numid.recycleEmpty",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Where a resolved value came from. Shown in the UI. */
|
/** Where a resolved value came from. Shown in the UI. */
|
||||||
@@ -169,6 +171,39 @@ export class SettingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the NUMid allocator may reuse empty portal ids instead of only
|
||||||
|
* issuing new ones.
|
||||||
|
*
|
||||||
|
* Defaults to OFF, and the default is the safety property rather than a
|
||||||
|
* preference: while Access remains the utilities master, every reusable id
|
||||||
|
* still exists in DATGRAL, and a `--sync` migration run reassigns the ref back
|
||||||
|
* to its Access owner (transform_customers.py:327). Recycling before utilities
|
||||||
|
* cuts over therefore hands out ids that quietly stop working. No env rung —
|
||||||
|
* this has never been an environment variable and should be flipped
|
||||||
|
* deliberately, in the UI, by someone who knows the cutover happened.
|
||||||
|
*/
|
||||||
|
async numidRecycleEmpty(): Promise<ResolvedSetting<boolean>> {
|
||||||
|
const row = await this.read(SETTING_KEYS.numidRecycleEmpty);
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
value: row.value === "true",
|
||||||
|
source: "db",
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedById: row.updatedById,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { value: false, source: "default", updatedAt: null, updatedById: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNumidRecycleEmpty(
|
||||||
|
enabled: boolean,
|
||||||
|
userId: string,
|
||||||
|
): Promise<ResolvedSetting<boolean>> {
|
||||||
|
await this.write(SETTING_KEYS.numidRecycleEmpty, String(enabled), userId);
|
||||||
|
return this.numidRecycleEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
private read(key: string) {
|
private read(key: string) {
|
||||||
return this.prisma.appSetting.findUnique({ where: { key } });
|
return this.prisma.appSetting.findUnique({ where: { key } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import { parseBboxLayout } from "./tesseract.provider";
|
|||||||
* that grouping is what left `PERIODO FACTURADO` with no value next to it and
|
* that grouping is what left `PERIODO FACTURADO` with no value next to it and
|
||||||
* every period field empty on a batch whose text was perfectly readable.
|
* every period field empty on a batch whose text was perfectly readable.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Boxes are sized from the text, at 6 units a character: the reassembler now
|
||||||
|
* reads the space BETWEEN two boxes, so a fixed width would put a fabricated
|
||||||
|
* gap after every short word and every row would come back column-padded.
|
||||||
|
*/
|
||||||
function word(x: number, y: number, text: string): string {
|
function word(x: number, y: number, text: string): string {
|
||||||
return `<word xMin="${x}" yMin="${y}" xMax="${x + 20}" yMax="${y + 8}">${text}</word>`;
|
return `<word xMin="${x}" yMin="${y}" xMax="${x + text.length * 6}" yMax="${y + 8}">${text}</word>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function doc(...lines: string[]): string {
|
function doc(...lines: string[]): string {
|
||||||
@@ -26,23 +31,57 @@ describe("parseBboxLayout", () => {
|
|||||||
it("rejoins a label with the value printed beside it in another flow", () => {
|
it("rejoins a label with the value printed beside it in another flow", () => {
|
||||||
const [page] = parseBboxLayout(
|
const [page] = parseBboxLayout(
|
||||||
doc(
|
doc(
|
||||||
word(20, 100, "PERIODO") + word(45, 100, "FACTURADO:"),
|
word(20, 100, "PERIODO") + word(68, 100, "FACTURADO:"),
|
||||||
word(300, 100.4, "20260630-20260630"),
|
word(300, 100.4, "20260630-20260630"),
|
||||||
padding(),
|
padding(),
|
||||||
),
|
),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
expect(page!.text).toContain("PERIODO FACTURADO: 20260630-20260630");
|
expect(page!.text).toMatch(/PERIODO FACTURADO:\s+20260630-20260630/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps genuinely separate lines apart", () => {
|
it("keeps genuinely separate lines apart", () => {
|
||||||
const [page] = parseBboxLayout(
|
const [page] = parseBboxLayout(
|
||||||
doc(word(20, 100, "Cuenta:") + word(80, 100, "0900003463"), word(20, 130, "Nombre:"), padding()),
|
doc(word(20, 100, "Cuenta:") + word(68, 100, "0900003463"), word(20, 130, "Nombre:"), padding()),
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
expect(page!.text.split("\n")).toContain("Cuenta: 0900003463");
|
const lines = page!.text.split("\n").map((l) => l.trim());
|
||||||
expect(page!.text.split("\n")).toContain("Nombre:");
|
expect(lines).toContain("Cuenta: 0900003463");
|
||||||
|
expect(lines).toContain("Nombre:");
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The layout is data. A borderless table separates its cells with nothing
|
||||||
|
* but white space, so the parsers read a run of spaces as a cell boundary
|
||||||
|
* (`INSURED\s{2,}`) and a column offset as a column (`SUM INSURED` vs
|
||||||
|
* `PREMIUM`). Both regressed to nothing when this collapsed every gap to a
|
||||||
|
* single space, and the fixtures — taken from `pdftotext -layout`, which
|
||||||
|
* prints the gaps — could not see it.
|
||||||
|
*/
|
||||||
|
it("preserves the gap between two cells of a borderless table", () => {
|
||||||
|
const [page] = parseBboxLayout(
|
||||||
|
doc(word(20, 100, "INSURED") + word(300, 100, "PAMELA") + word(340, 100, "WAGONER"), padding()),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const line = page!.text.split("\n").find((l) => l.includes("INSURED"))!;
|
||||||
|
expect(line).toMatch(/INSURED\s{2,}PAMELA WAGONER/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the blank line between two blocks", () => {
|
||||||
|
const [page] = parseBboxLayout(
|
||||||
|
doc(word(20, 100, "Insured"), word(20, 112, "wraps"), word(20, 200, "Next"), padding()),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
const lines = page!.text.split("\n").map((l) => l.trim());
|
||||||
|
// The wrapped continuation stays attached; the next block is cut off from
|
||||||
|
// it, which is what stops a "join until the cell ends" walk running away.
|
||||||
|
expect(lines.slice(lines.indexOf("Insured"), lines.indexOf("Next") + 1)).toEqual([
|
||||||
|
"Insured",
|
||||||
|
"wraps",
|
||||||
|
"",
|
||||||
|
"Next",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scales point coordinates into the render's pixel space", () => {
|
it("scales point coordinates into the render's pixel space", () => {
|
||||||
|
|||||||
@@ -254,6 +254,23 @@ export function parseBboxLayout(xhtml: string, scale: number): (OcrPage | null)[
|
|||||||
* Rows are cut when a word's vertical centre leaves the band established by
|
* Rows are cut when a word's vertical centre leaves the band established by
|
||||||
* the row's first word, which tolerates the sub-pixel baseline differences
|
* the row's first word, which tolerates the sub-pixel baseline differences
|
||||||
* between fonts on one line without merging two genuinely separate lines.
|
* between fonts on one line without merging two genuinely separate lines.
|
||||||
|
*
|
||||||
|
* Vertical WHITE SPACE is preserved as a blank line. Rows alone are not the
|
||||||
|
* whole layout: on a form, the blank between two blocks is what says where a
|
||||||
|
* cell's wrapped value stops, and dropping it leaves parsers that walk a
|
||||||
|
* block ("keep joining until the cell ends") running to the end of the page.
|
||||||
|
* That is not hypothetical — the GMX PVL especificación read its whole first
|
||||||
|
* page as the insured's name, because the fixtures were taken from
|
||||||
|
* `pdftotext -layout` (which prints the blanks) while the runtime fed it this
|
||||||
|
* function's output (which did not).
|
||||||
|
*
|
||||||
|
* Horizontal white space is preserved the same way, by padding each word out
|
||||||
|
* to its own column. The same fixture mismatch bit here: a run of spaces is
|
||||||
|
* the ONLY thing separating two cells of a borderless table, so ANA's
|
||||||
|
* `INSURED\s{2,}` label matches and its `SUM INSURED` / `PREMIUM` column
|
||||||
|
* split (taken from `head.search()` offsets) both need real offsets. Joining
|
||||||
|
* on one space put every driver's-policy premium in the sum-insured column
|
||||||
|
* and left the phone glued to the insured's name.
|
||||||
*/
|
*/
|
||||||
function toVisualRows(words: OcrWord[]): string {
|
function toVisualRows(words: OcrWord[]): string {
|
||||||
const centre = (w: OcrWord) => w.top + w.height / 2;
|
const centre = (w: OcrWord) => w.top + w.height / 2;
|
||||||
@@ -281,14 +298,85 @@ function toVisualRows(words: OcrWord[]): string {
|
|||||||
}
|
}
|
||||||
if (current.length) rows.push(current);
|
if (current.length) rows.push(current);
|
||||||
|
|
||||||
return rows
|
const charWidth = estimateCharWidth(words);
|
||||||
.map((r) =>
|
const out: string[] = [];
|
||||||
[...r]
|
rows.forEach((r, i) => {
|
||||||
.sort((a, b) => a.left - b.left)
|
if (i > 0 && isBlankBetween(rows[i - 1], r)) out.push("");
|
||||||
.map((w) => w.text)
|
out.push(layoutRow(r, charWidth));
|
||||||
.join(" "),
|
});
|
||||||
)
|
return out.join("\n");
|
||||||
.join("\n");
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row rendered at its printed column offsets.
|
||||||
|
*
|
||||||
|
* Words that merely follow one another inside the same cell are separated by
|
||||||
|
* exactly one space, whatever the column arithmetic says: one `charWidth` for
|
||||||
|
* a page that mixes fonts leaves a rounding error on every word, and letting
|
||||||
|
* that accumulate sprinkles `\s{2,}` runs through ordinary prose — which is
|
||||||
|
* the very thing the parsers read as a cell boundary. Only a gap wide enough
|
||||||
|
* to be deliberate (more than one blank character) is rendered as one, and
|
||||||
|
* only there is the word re-anchored to its true column, so the offsets a
|
||||||
|
* column split depends on stay honest while values stay clean.
|
||||||
|
*/
|
||||||
|
function layoutRow(row: OcrWord[], charWidth: number): string {
|
||||||
|
let line = "";
|
||||||
|
let right = 0;
|
||||||
|
|
||||||
|
for (const w of [...row].sort((a, b) => a.left - b.left)) {
|
||||||
|
const col = Math.round(w.left / charWidth);
|
||||||
|
if (!line.length) {
|
||||||
|
line = " ".repeat(Math.max(0, col));
|
||||||
|
} else if (w.left - right > charWidth * 1.5) {
|
||||||
|
line += " ".repeat(Math.max(2, col - line.length));
|
||||||
|
} else {
|
||||||
|
line += " ";
|
||||||
|
}
|
||||||
|
line += w.text;
|
||||||
|
right = w.left + w.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width of one character, in the same units the word boxes use.
|
||||||
|
*
|
||||||
|
* The median of each word's own width-per-character: robust to the handful of
|
||||||
|
* oversized headings and to the wide-tracked letterhead, both of which would
|
||||||
|
* drag a mean. Only words of 3+ characters vote, since a one-character box is
|
||||||
|
* mostly side bearing. Falls back to a value derived from line height when a
|
||||||
|
* page has nothing long enough to measure.
|
||||||
|
*/
|
||||||
|
function estimateCharWidth(words: OcrWord[]): number {
|
||||||
|
const samples = words
|
||||||
|
.filter((w) => w.text.length >= 3 && w.width > 0)
|
||||||
|
.map((w) => w.width / w.text.length)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
if (samples.length) return samples[Math.floor(samples.length / 2)];
|
||||||
|
const heights = words.map((w) => w.height).filter((h) => h > 0);
|
||||||
|
return heights.length ? Math.max(...heights) / 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does the space between two consecutive rows read as an empty line?
|
||||||
|
*
|
||||||
|
* Measured against the taller of the two rows so a heading and its body text
|
||||||
|
* are judged on their own scale. On the real documents the two populations do
|
||||||
|
* not overlap: consecutive lines of one paragraph sit at 0.3–1.1 line heights
|
||||||
|
* apart, and anything the reader sees as blank-separated starts at 2.1. The
|
||||||
|
* threshold is placed in that empty middle, biased high — a missed blank only
|
||||||
|
* restores today's behaviour, while a spurious one would cut a wrapped value
|
||||||
|
* short.
|
||||||
|
*/
|
||||||
|
function isBlankBetween(prev: OcrWord[], row: OcrWord[]): boolean {
|
||||||
|
const bottom = Math.max(...prev.map((w) => w.top + w.height));
|
||||||
|
const top = Math.min(...row.map((w) => w.top));
|
||||||
|
const unit = Math.max(
|
||||||
|
...prev.map((w) => w.height),
|
||||||
|
...row.map((w) => w.height),
|
||||||
|
);
|
||||||
|
return unit > 0 && top - bottom > unit * 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@jorgecuadros/database";
|
||||||
|
import { StatementsService, assertSplitsSum } from "./statements.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirming a batch is where a receipt becomes ledger rows. What must hold:
|
||||||
|
* a split receipt charges each part to its own customer and nothing to the
|
||||||
|
* matched one, sin fondos on one receipt holds back only that receipt, and a
|
||||||
|
* split that no longer adds up never posts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const D = (v: string) => new Prisma.Decimal(v);
|
||||||
|
|
||||||
|
function build(docs: unknown[]) {
|
||||||
|
const lines: { customerId: string; amount: number; outstanding: boolean }[] = [];
|
||||||
|
let refs: (string | undefined)[] = [];
|
||||||
|
const billing = {
|
||||||
|
createBatch: jest.fn().mockImplementation((dto, opts) => {
|
||||||
|
lines.push(...dto.lines);
|
||||||
|
refs = opts.refs;
|
||||||
|
return Promise.resolve({
|
||||||
|
items: dto.lines.map((_: unknown, i: number) => ({ id: `tx-${i}` })),
|
||||||
|
count: dto.lines.length,
|
||||||
|
total: "0.00",
|
||||||
|
outstandingCount: 0,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const prisma = {
|
||||||
|
statementBatch: {
|
||||||
|
findUnique: jest.fn().mockResolvedValue({ id: "b1", serviceKind: "WATER" }),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
statementDocument: {
|
||||||
|
findMany: jest.fn().mockResolvedValue(docs),
|
||||||
|
update: jest.fn((a) => a),
|
||||||
|
updateMany: jest.fn((a) => a),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
},
|
||||||
|
statementDocumentSplit: { update: jest.fn((a) => a) },
|
||||||
|
typeTransaction: { findFirst: jest.fn().mockResolvedValue({ id: "type-water" }) },
|
||||||
|
propertyService: { updateMany: jest.fn() },
|
||||||
|
$transaction: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const service = new StatementsService(
|
||||||
|
prisma as never,
|
||||||
|
{} as never,
|
||||||
|
billing as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
return { service, prisma, lines, refs: () => refs };
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = (over: Record<string, unknown>) => ({
|
||||||
|
id: "d1",
|
||||||
|
pageNumber: 1,
|
||||||
|
matchedCustomerId: "c-411",
|
||||||
|
matchedPropertyServiceId: null,
|
||||||
|
extractedAmount: D("300.00"),
|
||||||
|
extractedAccountRef: "3014057",
|
||||||
|
extractedPeriod: "ENE-FEB",
|
||||||
|
outstanding: false,
|
||||||
|
splits: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirm = { checkNumber: "8728", transactionDate: "2026-10-08" };
|
||||||
|
|
||||||
|
describe("confirming OCR receipts", () => {
|
||||||
|
it("posts one charge per part of a split receipt", async () => {
|
||||||
|
const { service, prisma, lines, refs } = build([
|
||||||
|
doc({
|
||||||
|
splits: [
|
||||||
|
{ id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") },
|
||||||
|
{ id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.confirmBatch("b1", confirm, "u1");
|
||||||
|
|
||||||
|
expect(lines.map((l) => [l.customerId, l.amount])).toEqual([
|
||||||
|
["c-411", -150],
|
||||||
|
["c-523", -150],
|
||||||
|
]);
|
||||||
|
expect(refs()).toEqual(["d1#1", "d1#2"]);
|
||||||
|
// The rows hang off the parts, not the document.
|
||||||
|
expect(prisma.statementDocumentSplit.update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.statementDocument.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds back only the receipt marked sin fondos", async () => {
|
||||||
|
const { service, lines } = build([
|
||||||
|
doc({ id: "d1", outstanding: true }),
|
||||||
|
doc({ id: "d2", matchedCustomerId: "c-208" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.confirmBatch("b1", confirm, "u1");
|
||||||
|
|
||||||
|
expect(lines.map((l) => l.outstanding)).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a split that no longer adds up to the receipt", async () => {
|
||||||
|
const { service, prisma } = build([
|
||||||
|
doc({
|
||||||
|
extractedAmount: D("320.00"), // edited after the split was saved
|
||||||
|
splits: [
|
||||||
|
{ id: "s1", sequence: 1, customerId: "c-411", amount: D("150.00") },
|
||||||
|
{ id: "s2", sequence: 2, customerId: "c-523", amount: D("150.00") },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.confirmBatch("b1", confirm, "u1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("split sums", () => {
|
||||||
|
it("compares in cents, so float noise is not a mismatch", () => {
|
||||||
|
expect(() => assertSplitsSum([0.1, 0.2], 0.3)).not.toThrow();
|
||||||
|
expect(() => assertSplitsSum([100, 33.33, 33.33], 166.67)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
@@ -6,7 +8,9 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
import { Currency, ServiceKind, StatementDocumentStatus } from "@jorgecuadros/database";
|
||||||
|
|
||||||
export class CreateStatementBatchDto {
|
export class CreateStatementBatchDto {
|
||||||
@@ -27,6 +31,22 @@ export class ReviewDocumentDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(StatementDocumentStatus)
|
@IsEnum(StatementDocumentStatus)
|
||||||
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
status?: Extract<StatementDocumentStatus, "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED">;
|
||||||
|
/** Sin fondos for this receipt alone. */
|
||||||
|
@IsOptional() @IsBoolean() outstanding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SplitPartDto {
|
||||||
|
@IsString() @MinLength(1) customerId!: string;
|
||||||
|
@IsNumber() amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A receipt's split; an empty list removes it. */
|
||||||
|
export class SetSplitsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(20)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SplitPartDto)
|
||||||
|
parts!: SplitPartDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
|
Put,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
@@ -22,7 +23,7 @@ import { RequireAbility } from "../auth/require-ability.decorator";
|
|||||||
import { AuditService } from "../common/audit.service";
|
import { AuditService } from "../common/audit.service";
|
||||||
import type { UploadedFileLike } from "../storage/upload-file";
|
import type { UploadedFileLike } from "../storage/upload-file";
|
||||||
import { StatementsService } from "./statements.service";
|
import { StatementsService } from "./statements.service";
|
||||||
import { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
import { ConfirmBatchDto, ReviewDocumentDto, SetSplitsDto } from "./statement.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
* Statement OCR intake (RECEIPT_CAPTURE_SPEC §2).
|
||||||
@@ -117,6 +118,39 @@ export class StatementsController {
|
|||||||
return batch;
|
return batch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** More scans for a batch kept open for one check. */
|
||||||
|
@Post("batches/:id/files")
|
||||||
|
@RequireAbility("statement:ingest")
|
||||||
|
@UseInterceptors(FilesInterceptor("files", 25, { limits: { fileSize: 50 * 1024 * 1024 } }))
|
||||||
|
async addFiles(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const batch = await this.statements.addFiles(id, files ?? []);
|
||||||
|
void this.audit.log(this.actingId(req), "statement.batch.add-files", {
|
||||||
|
batchId: id,
|
||||||
|
added: files?.length ?? 0,
|
||||||
|
});
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split one receipt across several customers; empty parts undo it. */
|
||||||
|
@Put("documents/:id/splits")
|
||||||
|
@RequireAbility("statement:review")
|
||||||
|
async setSplits(
|
||||||
|
@Param("id") id: string,
|
||||||
|
@Body() dto: SetSplitsDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const doc = await this.statements.setSplits(id, dto.parts, this.actingId(req));
|
||||||
|
void this.audit.log(this.actingId(req), "statement.document.split", {
|
||||||
|
documentId: id,
|
||||||
|
parts: dto.parts.length,
|
||||||
|
});
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
@Patch("documents/:id")
|
@Patch("documents/:id")
|
||||||
@RequireAbility("statement:review")
|
@RequireAbility("statement:review")
|
||||||
async review(
|
async review(
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ import type { UploadedFileLike } from "../storage/upload-file";
|
|||||||
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
import { OCR_PROVIDER, type OcrProvider } from "./ocr/ocr.provider";
|
||||||
import { parseStatement } from "./parsers/statement-parser";
|
import { parseStatement } from "./parsers/statement-parser";
|
||||||
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
|
import { StatementMatcherService, scopedRefField } from "./statement-matcher.service";
|
||||||
import type { ConfirmBatchDto, ReviewDocumentDto } from "./statement.dto";
|
import type { ConfirmBatchDto, ReviewDocumentDto, SplitPartDto } from "./statement.dto";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default ledger concept per service kind. The names are the legacy
|
* Default ledger concept per service kind. The names are the legacy
|
||||||
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
* `TYPE OF TRX` values already in `type_transactions`, resolved by name once
|
||||||
* per confirm rather than hard-coded as ids, which differ per environment.
|
* per confirm rather than hard-coded as ids, which differ per environment.
|
||||||
*/
|
*/
|
||||||
const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
export const CONCEPT_BY_KIND: Partial<Record<ServiceKind, string>> = {
|
||||||
ELECTRIC: "ELECTRIC",
|
ELECTRIC: "ELECTRIC",
|
||||||
WATER: "WATER",
|
WATER: "WATER",
|
||||||
TELEPHONE: "TELEPHONE",
|
TELEPHONE: "TELEPHONE",
|
||||||
@@ -74,6 +74,49 @@ export class StatementsService {
|
|||||||
uploadedById: string,
|
uploadedById: string,
|
||||||
label?: string,
|
label?: string,
|
||||||
) {
|
) {
|
||||||
|
await this.assertCanIngest(files);
|
||||||
|
|
||||||
|
const batch = await this.prisma.statementBatch.create({
|
||||||
|
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
||||||
|
});
|
||||||
|
this.startProcessing(batch.id, files, serviceKind, 0);
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add more scans to a batch already under review — the office keeps one
|
||||||
|
* batch open for one check while the receipts trickle in. New pages are
|
||||||
|
* numbered after the existing ones; a settled (COMPLETED) batch reopens.
|
||||||
|
*/
|
||||||
|
async addFiles(batchId: string, files: UploadedFileLike[]) {
|
||||||
|
await this.assertCanIngest(files);
|
||||||
|
const batch = await this.prisma.statementBatch.findUnique({ where: { id: batchId } });
|
||||||
|
if (!batch) throw new NotFoundException("Lote no encontrado.");
|
||||||
|
if (batch.status === "DISCARDED") {
|
||||||
|
throw new BadRequestException("Este lote fue descartado; crea uno nuevo.");
|
||||||
|
}
|
||||||
|
// Two passes numbering pages at once would collide on (batchId, pageNumber).
|
||||||
|
if (batch.status === "PROCESSING" || batch.status === "UPLOADED") {
|
||||||
|
throw new BadRequestException("El lote todavía se está procesando; espera a que termine.");
|
||||||
|
}
|
||||||
|
const last = await this.prisma.statementDocument.aggregate({
|
||||||
|
where: { batchId },
|
||||||
|
_max: { pageNumber: true },
|
||||||
|
});
|
||||||
|
const updated = await this.prisma.statementBatch.update({
|
||||||
|
where: { id: batchId },
|
||||||
|
data: {
|
||||||
|
status: "UPLOADED",
|
||||||
|
fileCount: { increment: files.length },
|
||||||
|
completedAt: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.startProcessing(batchId, files, batch.serviceKind, last._max.pageNumber ?? 0);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanIngest(files: UploadedFileLike[]) {
|
||||||
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
if (!files?.length) throw new BadRequestException("No se recibió ningún archivo.");
|
||||||
if (!(await this.ocr.available())) {
|
if (!(await this.ocr.available())) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -88,23 +131,24 @@ export class StatementsService {
|
|||||||
"guardar los recibos escaneados.",
|
"guardar los recibos escaneados.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const batch = await this.prisma.statementBatch.create({
|
private startProcessing(
|
||||||
data: { serviceKind, uploadedById, label, fileCount: files.length },
|
batchId: string,
|
||||||
});
|
files: UploadedFileLike[],
|
||||||
|
serviceKind: ServiceKind,
|
||||||
|
afterPage: number,
|
||||||
|
) {
|
||||||
// Buffers are held for the background pass; the request's own copies would
|
// Buffers are held for the background pass; the request's own copies would
|
||||||
// otherwise be garbage once the response is sent.
|
// otherwise be garbage once the response is sent.
|
||||||
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
const copies = files.map((f) => ({ buffer: f.buffer, name: f.originalname }));
|
||||||
void this.process(batch.id, copies, serviceKind).catch(async (err) => {
|
void this.process(batchId, copies, serviceKind, afterPage).catch(async (err) => {
|
||||||
this.logger.error(`Batch ${batch.id} failed: ${(err as Error).message}`);
|
this.logger.error(`Batch ${batchId} failed: ${(err as Error).message}`);
|
||||||
await this.prisma.statementBatch.update({
|
await this.prisma.statementBatch.update({
|
||||||
where: { id: batch.id },
|
where: { id: batchId },
|
||||||
data: { status: "FAILED", error: (err as Error).message },
|
data: { status: "FAILED", error: (err as Error).message },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return batch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render → OCR → parse → match, one document row per page. */
|
/** Render → OCR → parse → match, one document row per page. */
|
||||||
@@ -112,13 +156,14 @@ export class StatementsService {
|
|||||||
batchId: string,
|
batchId: string,
|
||||||
files: { buffer: Buffer; name?: string }[],
|
files: { buffer: Buffer; name?: string }[],
|
||||||
serviceKind: ServiceKind,
|
serviceKind: ServiceKind,
|
||||||
|
afterPage = 0,
|
||||||
) {
|
) {
|
||||||
await this.prisma.statementBatch.update({
|
await this.prisma.statementBatch.update({
|
||||||
where: { id: batchId },
|
where: { id: batchId },
|
||||||
data: { status: "PROCESSING" },
|
data: { status: "PROCESSING" },
|
||||||
});
|
});
|
||||||
|
|
||||||
let pageNumber = 0;
|
let pageNumber = afterPage;
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
// The source PDF is kept as well as the page images: it is the artifact
|
// 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
|
// the office actually received, and the only way to re-run a corrected
|
||||||
@@ -227,11 +272,24 @@ export class StatementsService {
|
|||||||
where: { batchId: id, status: { in: OPEN } },
|
where: { batchId: id, status: { in: OPEN } },
|
||||||
_sum: { extractedAmount: true },
|
_sum: { extractedAmount: true },
|
||||||
});
|
});
|
||||||
|
// The check this batch was last posted against, so the next confirm of a
|
||||||
|
// batch kept open for one check doesn't make staff retype it.
|
||||||
|
const last = await this.prisma.transaction.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ statementDocument: { batchId: id } },
|
||||||
|
{ statementSplit: { document: { batchId: id } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { checkNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...batch,
|
...batch,
|
||||||
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])),
|
||||||
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
pendingTotal: totals._sum.extractedAmount?.toFixed(2) ?? "0.00",
|
||||||
|
lastCheckNumber: last?.checkNumber ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +308,10 @@ export class StatementsService {
|
|||||||
property: { select: { id: true, addressLine1: true } },
|
property: { select: { id: true, addressLine1: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
splits: {
|
||||||
|
orderBy: { sequence: "asc" },
|
||||||
|
include: { customer: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -268,11 +330,17 @@ export class StatementsService {
|
|||||||
|
|
||||||
/** Staff correction of an extracted field or of the match itself. */
|
/** Staff correction of an extracted field or of the match itself. */
|
||||||
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
async review(id: string, dto: ReviewDocumentDto, reviewedById: string) {
|
||||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
const doc = await this.prisma.statementDocument.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { splits: { select: { amount: true } } },
|
||||||
|
});
|
||||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
if (doc.status === "POSTED") {
|
if (doc.status === "POSTED") {
|
||||||
throw new BadRequestException("Este documento ya fue registrado.");
|
throw new BadRequestException("Este documento ya fue registrado.");
|
||||||
}
|
}
|
||||||
|
if (dto.amount != null && doc.splits.length) {
|
||||||
|
assertSplitsSum(doc.splits.map((s) => s.amount), dto.amount);
|
||||||
|
}
|
||||||
|
|
||||||
// Changing the service implies its owner; deriving the customer here rather
|
// 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
|
// than trusting a client-supplied pair is what stops a page being posted to
|
||||||
@@ -329,6 +397,7 @@ export class StatementsService {
|
|||||||
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
extractedDueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
||||||
matchedPropertyServiceId,
|
matchedPropertyServiceId,
|
||||||
matchedCustomerId,
|
matchedCustomerId,
|
||||||
|
outstanding: dto.outstanding ?? undefined,
|
||||||
status: dto.status ?? "MATCHED",
|
status: dto.status ?? "MATCHED",
|
||||||
reviewedById,
|
reviewedById,
|
||||||
reviewedAt: new Date(),
|
reviewedAt: new Date(),
|
||||||
@@ -336,6 +405,62 @@ export class StatementsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a receipt's split. Empty clears it, and the receipt posts whole to
|
||||||
|
* its matched customer again. Otherwise at least two parts, each positive,
|
||||||
|
* summing to the receipt's amount to the cent — a split that doesn't add up
|
||||||
|
* would charge the customers more or less than the bill.
|
||||||
|
*/
|
||||||
|
async setSplits(id: string, parts: SplitPartDto[], reviewedById: string) {
|
||||||
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
|
if (doc.status === "POSTED" || doc.status === "REJECTED") {
|
||||||
|
throw new BadRequestException("Este documento ya no se puede dividir.");
|
||||||
|
}
|
||||||
|
if (parts.length === 1) {
|
||||||
|
throw new BadRequestException("Una división necesita al menos dos partes.");
|
||||||
|
}
|
||||||
|
if (parts.length) {
|
||||||
|
if (doc.extractedAmount == null) {
|
||||||
|
throw new BadRequestException("Captura el importe del recibo antes de dividirlo.");
|
||||||
|
}
|
||||||
|
if (parts.some((p) => !(p.amount > 0))) {
|
||||||
|
throw new BadRequestException("Cada parte debe ser mayor a cero.");
|
||||||
|
}
|
||||||
|
assertSplitsSum(parts.map((p) => p.amount), Number(doc.extractedAmount));
|
||||||
|
const ids = [...new Set(parts.map((p) => p.customerId))];
|
||||||
|
const found = await this.prisma.customer.count({ where: { id: { in: ids } } });
|
||||||
|
if (found !== ids.length) throw new BadRequestException("Cliente no encontrado.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.statementDocumentSplit.deleteMany({ where: { documentId: id } }),
|
||||||
|
...parts.map((p, i) =>
|
||||||
|
this.prisma.statementDocumentSplit.create({
|
||||||
|
data: {
|
||||||
|
documentId: id,
|
||||||
|
sequence: i + 1,
|
||||||
|
customerId: p.customerId,
|
||||||
|
amount: new Prisma.Decimal(p.amount.toFixed(2)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// A split receipt has its customers; it is ready to post like any match.
|
||||||
|
this.prisma.statementDocument.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(parts.length ? { status: "MATCHED" } : {}),
|
||||||
|
reviewedById,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return this.prisma.statementDocument.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { splits: { orderBy: { sequence: "asc" } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async reject(id: string, reviewedById: string) {
|
async reject(id: string, reviewedById: string) {
|
||||||
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
const doc = await this.prisma.statementDocument.findUnique({ where: { id } });
|
||||||
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
if (!doc) throw new NotFoundException("Documento no encontrado.");
|
||||||
@@ -414,9 +539,10 @@ export class StatementsService {
|
|||||||
where: {
|
where: {
|
||||||
batchId,
|
batchId,
|
||||||
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
status: { in: dto.includeReviewed ? ["MATCHED", "CONFIRMED"] : ["MATCHED"] },
|
||||||
matchedCustomerId: { not: null },
|
OR: [{ matchedCustomerId: { not: null } }, { splits: { some: {} } }],
|
||||||
},
|
},
|
||||||
orderBy: { pageNumber: "asc" },
|
orderBy: { pageNumber: "asc" },
|
||||||
|
include: { splits: { orderBy: { sequence: "asc" } } },
|
||||||
});
|
});
|
||||||
if (!docs.length) {
|
if (!docs.length) {
|
||||||
throw new BadRequestException("No hay documentos listos para registrar.");
|
throw new BadRequestException("No hay documentos listos para registrar.");
|
||||||
@@ -430,8 +556,39 @@ export class StatementsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-checked here as well as when saved: the amount may have been edited
|
||||||
|
// after the split was.
|
||||||
|
for (const d of docs) {
|
||||||
|
if (d.splits.length) {
|
||||||
|
assertSplitsSum(d.splits.map((s) => s.amount), Number(d.extractedAmount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
const typeId = dto.typeId ?? (await this.conceptFor(batch.serviceKind));
|
||||||
|
|
||||||
|
// One ledger line per receipt, or per part of a split receipt. `posts`
|
||||||
|
// remembers which record each line came from, so the created rows can be
|
||||||
|
// written back below.
|
||||||
|
const posts = docs.flatMap((d) =>
|
||||||
|
d.splits.length
|
||||||
|
? d.splits.map((s) => ({
|
||||||
|
doc: d,
|
||||||
|
splitId: s.id as string | null,
|
||||||
|
customerId: s.customerId,
|
||||||
|
amount: Number(s.amount),
|
||||||
|
ref: `${d.id}#${s.sequence}`,
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
doc: d,
|
||||||
|
splitId: null,
|
||||||
|
customerId: d.matchedCustomerId!,
|
||||||
|
amount: Number(d.extractedAmount),
|
||||||
|
ref: d.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const result = await this.billing.createBatch(
|
const result = await this.billing.createBatch(
|
||||||
{
|
{
|
||||||
domain: "UTILITY",
|
domain: "UTILITY",
|
||||||
@@ -439,36 +596,44 @@ export class StatementsService {
|
|||||||
checkNumber: dto.checkNumber,
|
checkNumber: dto.checkNumber,
|
||||||
currency: dto.currency ?? "MXN",
|
currency: dto.currency ?? "MXN",
|
||||||
typeId,
|
typeId,
|
||||||
lines: docs.map((d) => ({
|
lines: posts.map((p) => ({
|
||||||
customerId: d.matchedCustomerId!,
|
customerId: p.customerId,
|
||||||
// Charges are negative in this ledger: a negative amount is what the
|
// Charges are negative in this ledger: a negative amount is what the
|
||||||
// customer owes. The parser reads the printed (positive) figure, so
|
// customer owes. The parser reads the printed (positive) figure, so
|
||||||
// the sign is applied here, at the single point where a statement
|
// the sign is applied here, at the single point where a statement
|
||||||
// becomes a ledger row.
|
// becomes a ledger row.
|
||||||
amount: -Math.abs(Number(d.extractedAmount)),
|
amount: -Math.abs(p.amount),
|
||||||
reference: d.extractedAccountRef ?? undefined,
|
reference: p.doc.extractedAccountRef ?? undefined,
|
||||||
period: d.extractedPeriod ?? undefined,
|
period: p.doc.extractedPeriod ?? undefined,
|
||||||
outstanding: dto.outstanding ?? false,
|
// Sin fondos is per check or per receipt: either one holds it back.
|
||||||
|
outstanding: (dto.outstanding ?? false) || p.doc.outstanding,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{ source: "OCR", refs: docs.map((d) => d.id) },
|
{ source: "OCR", refs: posts.map((p) => p.ref) },
|
||||||
);
|
);
|
||||||
|
|
||||||
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
// `items[i]` is positionally parallel to `lines[i]` (seam guarantee 1), so
|
||||||
// the created rows zip straight back onto the documents that produced them.
|
// the created rows zip straight back onto the records that produced them.
|
||||||
await this.prisma.$transaction(
|
// A split receipt's rows hang off its parts; the document itself keeps no
|
||||||
docs.map((d, i) =>
|
// single posted row.
|
||||||
this.prisma.statementDocument.update({
|
const now = new Date();
|
||||||
where: { id: d.id },
|
await this.prisma.$transaction([
|
||||||
data: {
|
...posts.map((p, i) =>
|
||||||
status: "POSTED",
|
p.splitId
|
||||||
postedTransactionId: result.items[i].id,
|
? this.prisma.statementDocumentSplit.update({
|
||||||
reviewedById,
|
where: { id: p.splitId },
|
||||||
reviewedAt: new Date(),
|
data: { postedTransactionId: result.items[i].id },
|
||||||
},
|
})
|
||||||
}),
|
: this.prisma.statementDocument.update({
|
||||||
|
where: { id: p.doc.id },
|
||||||
|
data: { postedTransactionId: result.items[i].id },
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
this.prisma.statementDocument.updateMany({
|
||||||
|
where: { id: { in: docs.map((d) => d.id) } },
|
||||||
|
data: { status: "POSTED", reviewedById, reviewedAt: now },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
// Teach the matcher. When a document was matched by clave catastral or by
|
// 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
|
// hand because the scoped field was blank, writing the reference back means
|
||||||
@@ -479,7 +644,12 @@ export class StatementsService {
|
|||||||
|
|
||||||
await this.closeIfDone(batchId);
|
await this.closeIfDone(batchId);
|
||||||
|
|
||||||
return { posted: result.count, total: result.total, checkNumber: dto.checkNumber };
|
return {
|
||||||
|
posted: result.count,
|
||||||
|
total: result.total,
|
||||||
|
outstanding: result.outstandingCount,
|
||||||
|
checkNumber: dto.checkNumber,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write a confirmed reference onto a service that had none. */
|
/** Write a confirmed reference onto a service that had none. */
|
||||||
@@ -524,3 +694,14 @@ export class StatementsService {
|
|||||||
return row?.id;
|
return row?.id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parts of a split must add up to the receipt, compared in cents. */
|
||||||
|
export function assertSplitsSum(parts: (number | Prisma.Decimal)[], total: number) {
|
||||||
|
const cents = (v: number | Prisma.Decimal) => Math.round(Number(v) * 100);
|
||||||
|
const sum = parts.reduce<number>((s, p) => s + cents(p), 0);
|
||||||
|
if (sum !== cents(total)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`La división suma ${(sum / 100).toFixed(2)} y el recibo es de ${total.toFixed(2)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jorgecuadros/web",
|
"name": "@jorgecuadros/web",
|
||||||
"version": "1.0.14",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4500",
|
"dev": "next dev -p 4500",
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const TYPE: ChildConfig = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ key: "name", label: "Nombre" },
|
{ key: "name", label: "Nombre" },
|
||||||
{ key: "shortDescription", label: "Descripción" },
|
{ key: "shortDescription", label: "Descripción" },
|
||||||
|
// The rate is stored as a fraction, not a percentage, and the label has to
|
||||||
|
// say so: 8 typed here would tax a $600 premium $4,800. The API rejects
|
||||||
|
// anything above 1 rather than trusting the label alone.
|
||||||
|
{ key: "taxRate", label: "IVA (0.08 = 8%)", type: "number", step: "0.0001" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const ADJUSTER: ChildConfig = {
|
const ADJUSTER: ChildConfig = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { ContextReports } from "@/components/ContextReports";
|
|||||||
import {
|
import {
|
||||||
archiveCustomer,
|
archiveCustomer,
|
||||||
getCustomer,
|
getCustomer,
|
||||||
|
grantPortalAccess,
|
||||||
policyDocumentDownloadUrl,
|
policyDocumentDownloadUrl,
|
||||||
propertyDocumentDownloadUrl,
|
propertyDocumentDownloadUrl,
|
||||||
restoreCustomer,
|
restoreCustomer,
|
||||||
@@ -127,6 +128,7 @@ function Detail({ id }: { id: string }) {
|
|||||||
customerId={data.id}
|
customerId={data.id}
|
||||||
summary={data.transactionSummary}
|
summary={data.transactionSummary}
|
||||||
transactions={data.transactions}
|
transactions={data.transactions}
|
||||||
|
year={data.transactionYear}
|
||||||
/>
|
/>
|
||||||
<DocumentosSection data={data} />
|
<DocumentosSection data={data} />
|
||||||
</div>
|
</div>
|
||||||
@@ -151,9 +153,43 @@ function CustomerActions({
|
|||||||
}) {
|
}) {
|
||||||
const canEdit = useCan("customer:update");
|
const canEdit = useCan("customer:update");
|
||||||
const canDelete = useCan("customer:delete");
|
const canDelete = useCan("customer:delete");
|
||||||
|
const canGrantPortal = useCan("customer:portal-access");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const archived = customer.archivedAt != null;
|
const archived = customer.archivedAt != null;
|
||||||
|
|
||||||
|
// The portal NUMid is a legacy ref, not a column: (utilities, DATGRAL) is the
|
||||||
|
// "Security Number" my.jorgecuadros.com asks for. An insurance ref is a
|
||||||
|
// different id space entirely and does not let anyone log in, so both columns
|
||||||
|
// are checked — matching on sourceTable alone would hide the button from
|
||||||
|
// customers who cannot actually reach the portal.
|
||||||
|
const hasPortalId = customer.legacyRefs.some(
|
||||||
|
(r) => r.sourceSystem === "utilities" && r.sourceTable === "DATGRAL",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function grantPortal() {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"¿Asignar un número de portal a este cliente? Con él podrá entrar a " +
|
||||||
|
"my.jorgecuadros.com.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { numid, origin } = await grantPortalAccess(customer.id);
|
||||||
|
window.alert(
|
||||||
|
origin === "existing"
|
||||||
|
? `Este cliente ya tenía el número de portal ${numid}.`
|
||||||
|
: `Número de portal asignado: ${numid}.`,
|
||||||
|
);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
window.alert((e as Error)?.message ?? "No se pudo completar la acción.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleArchive() {
|
async function toggleArchive() {
|
||||||
const verb = archived ? "restaurar" : "archivar";
|
const verb = archived ? "restaurar" : "archivar";
|
||||||
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
if (!window.confirm(`¿Seguro que desea ${verb} este cliente?`)) return;
|
||||||
@@ -169,11 +205,23 @@ function CustomerActions({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!canEdit && !canDelete) return null;
|
const showPortal = canGrantPortal && !hasPortalId && !archived;
|
||||||
|
if (!canEdit && !canDelete && !showPortal) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
{archived && <span className="badge badge-negative">Archivado</span>}
|
{archived && <span className="badge badge-negative">Archivado</span>}
|
||||||
|
{showPortal && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={grantPortal}
|
||||||
|
disabled={busy}
|
||||||
|
title="Asigna el número que el cliente usa para entrar al portal"
|
||||||
|
>
|
||||||
|
Habilitar acceso al portal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
<Link href={`/clientes/${customer.id}/editar`} className="btn btn-outline">
|
||||||
Editar
|
Editar
|
||||||
@@ -208,6 +256,7 @@ function Hero({
|
|||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|
||||||
const facts: { label: string; value: string }[] = [
|
const facts: { label: string; value: string }[] = [
|
||||||
|
{ label: "Carpeta", value: data.folderNumber != null ? String(data.folderNumber) : "—" },
|
||||||
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||||
{
|
{
|
||||||
label: "Cuota",
|
label: "Cuota",
|
||||||
@@ -699,16 +748,18 @@ function EstadoCuentaSection({
|
|||||||
customerId,
|
customerId,
|
||||||
summary,
|
summary,
|
||||||
transactions,
|
transactions,
|
||||||
|
year,
|
||||||
}: {
|
}: {
|
||||||
customerId: string;
|
customerId: string;
|
||||||
summary: TransactionSummaryRow[];
|
summary: TransactionSummaryRow[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
|
year: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
rule="cuenta"
|
rule="cuenta"
|
||||||
title="Estado de cuenta"
|
title={`Estado de cuenta ${year}`}
|
||||||
count={transactions.length}
|
count={transactions.length}
|
||||||
countSuffix="movimientos"
|
countSuffix="movimientos"
|
||||||
/>
|
/>
|
||||||
@@ -735,7 +786,7 @@ function EstadoCuentaSection({
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
{transactions.length === 0 ? (
|
{transactions.length === 0 ? (
|
||||||
<div className="empty-inline">Sin movimientos registrados.</div>
|
<div className="empty-inline">Sin movimientos en {year}.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
<table className="tx-table">
|
<table className="tx-table">
|
||||||
@@ -757,11 +808,11 @@ function EstadoCuentaSection({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{transactions.length >= 100 && (
|
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||||
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
Movimientos de {year}, del más antiguo al más reciente. Los saldos de
|
||||||
Mostrando los 100 movimientos más recientes.
|
arriba son el saldo actual por línea de negocio — los mismos del
|
||||||
</div>
|
estado de cuenta, no la suma del año.
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{transactions.length > 0 && (
|
{transactions.length > 0 && (
|
||||||
<p className="section-note">
|
<p className="section-note">
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { getNextFolderNumber, onboardCustomer } from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatMoney, SERVICE_KIND_LABELS } from "@/lib/labels";
|
||||||
|
import type { Currency, OnboardInput, OnboardResult, ServiceKind } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alta de cliente in one screen (2026-10-08 meeting): the customer, their
|
||||||
|
* property, its services and the opening balance taken as the first payment,
|
||||||
|
* saved together or not at all. Cash issues an EFE receipt with its folio.
|
||||||
|
*/
|
||||||
|
export default function AltaClientePage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Alta />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceRow = { kind: ServiceKind; accountNumber: string; meterNumber: string };
|
||||||
|
|
||||||
|
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
|
||||||
|
const today = () => new Date().toLocaleDateString("en-CA");
|
||||||
|
const t = (v: string) => v.trim() || undefined;
|
||||||
|
|
||||||
|
function Alta() {
|
||||||
|
const allowed = useCan("customer:create");
|
||||||
|
|
||||||
|
const [folder, setFolder] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [phone, setPhone] = useState("");
|
||||||
|
const [mobile, setMobile] = useState("");
|
||||||
|
const [preferredCurrency, setPreferredCurrency] = useState<Currency>("USD");
|
||||||
|
|
||||||
|
const [address1, setAddress1] = useState("");
|
||||||
|
const [address2, setAddress2] = useState("");
|
||||||
|
const [propertyPhone, setPropertyPhone] = useState("");
|
||||||
|
const [services, setServices] = useState<ServiceRow[]>([]);
|
||||||
|
|
||||||
|
const [payAmount, setPayAmount] = useState("");
|
||||||
|
const [payMethod, setPayMethod] = useState<"CASH" | "CHECK">("CASH");
|
||||||
|
const [payCurrency, setPayCurrency] = useState<Currency>("MXN");
|
||||||
|
const [payDate, setPayDate] = useState(today);
|
||||||
|
const [checkNumber, setCheckNumber] = useState("");
|
||||||
|
const [exchangeRate, setExchangeRate] = useState("");
|
||||||
|
const [concept, setConcept] = useState("");
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState<OnboardResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getNextFolderNumber()
|
||||||
|
.then((r) => setFolder((prev) => prev || String(r.folderNumber)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasProperty = !!(address1.trim() || address2.trim() || propertyPhone.trim() || services.length);
|
||||||
|
const paying = Number(payAmount) > 0;
|
||||||
|
|
||||||
|
function setService(i: number, patch: Partial<ServiceRow>) {
|
||||||
|
setServices((prev) => prev.map((s, j) => (j === i ? { ...s, ...patch } : s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const input: OnboardInput = {
|
||||||
|
customer: {
|
||||||
|
name: name.trim(),
|
||||||
|
folderNumber: folder ? Number(folder) : undefined,
|
||||||
|
email: t(email),
|
||||||
|
phone: t(phone),
|
||||||
|
mobile: t(mobile),
|
||||||
|
preferredCurrency,
|
||||||
|
},
|
||||||
|
...(hasProperty && {
|
||||||
|
property: { addressLine1: t(address1), addressLine2: t(address2), phone1: t(propertyPhone) },
|
||||||
|
services: services.map((s) => ({
|
||||||
|
kind: s.kind,
|
||||||
|
accountNumber: t(s.accountNumber),
|
||||||
|
meterNumber: t(s.meterNumber),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
...(paying && {
|
||||||
|
openingPayment: {
|
||||||
|
method: payMethod,
|
||||||
|
amount: Number(payAmount),
|
||||||
|
currency: payCurrency,
|
||||||
|
receivedAt: payDate,
|
||||||
|
checkNumber: payMethod === "CHECK" ? t(checkNumber) : undefined,
|
||||||
|
exchangeRate:
|
||||||
|
payMethod === "CASH" && payCurrency === "USD" && exchangeRate
|
||||||
|
? Number(exchangeRate)
|
||||||
|
: undefined,
|
||||||
|
concept: t(concept),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
setDone(await onboardCustomer(input));
|
||||||
|
} catch (e2) {
|
||||||
|
setError((e2 as Error)?.message ?? "No se pudo dar de alta al cliente.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return <div className="state-box state-error">No tiene permisos para crear clientes.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<h1 className="page-title">Cliente dado de alta</h1>
|
||||||
|
<div className="card" style={{ padding: 20 }}>
|
||||||
|
<p>
|
||||||
|
<strong>{done.customer.name}</strong>
|
||||||
|
{done.customer.folderNumber != null && ` · Carpeta ${done.customer.folderNumber}`}
|
||||||
|
</p>
|
||||||
|
{done.receipt && (
|
||||||
|
<p>
|
||||||
|
Saldo inicial recibido en efectivo, folio {done.receipt.folio}.{" "}
|
||||||
|
<Link href={`/efectivo/${done.receipt.folio}`} target="_blank">
|
||||||
|
Imprimir recibo
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{done.payment && <p>Saldo inicial registrado con el cheque {checkNumber}.</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<Link href={`/clientes/${done.customer.id}`} className="btn btn-primary">
|
||||||
|
Ver cliente
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={() => location.reload()}>
|
||||||
|
Otra alta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="page-head">
|
||||||
|
<Link href="/clientes" className="back-link">← Clientes</Link>
|
||||||
|
<h1 className="page-title">Alta de cliente</h1>
|
||||||
|
</div>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Cliente</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="Carpeta">
|
||||||
|
<input className="input" type="number" min={1} value={folder}
|
||||||
|
onChange={(e) => setFolder(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Nombre *">
|
||||||
|
<input className="input" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Correo">
|
||||||
|
<input className="input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Teléfono">
|
||||||
|
<input className="input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Celular">
|
||||||
|
<input className="input" value={mobile} onChange={(e) => setMobile(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Moneda preferida">
|
||||||
|
<select className="input select" value={preferredCurrency}
|
||||||
|
onChange={(e) => setPreferredCurrency(e.target.value as Currency)}>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="MXN">MXN</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Propiedad y servicios</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="Dirección 1">
|
||||||
|
<input className="input" value={address1} onChange={(e) => setAddress1(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Dirección 2">
|
||||||
|
<input className="input" value={address2} onChange={(e) => setAddress2(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Teléfono de la propiedad">
|
||||||
|
<input className="input" value={propertyPhone} onChange={(e) => setPropertyPhone(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{services.map((s, i) => (
|
||||||
|
<div key={i} className="form-grid" style={{ marginTop: 12, alignItems: "end" }}>
|
||||||
|
<select className="input select" value={s.kind}
|
||||||
|
onChange={(e) => setService(i, { kind: e.target.value as ServiceKind })}>
|
||||||
|
{Object.entries(SERVICE_KIND_LABELS).map(([k, label]) => (
|
||||||
|
<option key={k} value={k}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input className="input" placeholder="Cuenta / referencia" value={s.accountNumber}
|
||||||
|
onChange={(e) => setService(i, { accountNumber: e.target.value })} />
|
||||||
|
<input className="input" placeholder="Medidor" value={s.meterNumber}
|
||||||
|
onChange={(e) => setService(i, { meterNumber: e.target.value })} />
|
||||||
|
<button type="button" className="btn btn-ghost"
|
||||||
|
onClick={() => setServices((prev) => prev.filter((_, j) => j !== i))}>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" className="btn btn-ghost" style={{ marginTop: 12 }}
|
||||||
|
onClick={() => setServices((prev) => [...prev, { kind: "WATER", accountNumber: "", meterNumber: "" }])}>
|
||||||
|
+ Agregar servicio
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Saldo inicial (pago)</h2>
|
||||||
|
<div className="form-grid">
|
||||||
|
<Field label="Monto">
|
||||||
|
<input className="input" type="number" step="0.01" min="0" value={payAmount}
|
||||||
|
placeholder="Vacío = sin saldo inicial"
|
||||||
|
onChange={(e) => setPayAmount(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Moneda">
|
||||||
|
<select className="input select" value={payCurrency}
|
||||||
|
onChange={(e) => setPayCurrency(e.target.value as Currency)}>
|
||||||
|
<option value="MXN">MXN</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Forma de pago">
|
||||||
|
<select className="input select" value={payMethod}
|
||||||
|
onChange={(e) => setPayMethod(e.target.value as "CASH" | "CHECK")}>
|
||||||
|
<option value="CASH">Efectivo (emite recibo EFE)</option>
|
||||||
|
<option value="CHECK">Cheque</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Fecha">
|
||||||
|
<input className="input" type="date" value={payDate} onChange={(e) => setPayDate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
{payMethod === "CHECK" && (
|
||||||
|
<Field label="Número de cheque *">
|
||||||
|
<input className="input" required={paying} value={checkNumber}
|
||||||
|
onChange={(e) => setCheckNumber(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{payMethod === "CASH" && payCurrency === "USD" && (
|
||||||
|
<Field label="Tipo de cambio (abonar en pesos)">
|
||||||
|
<input className="input" type="number" step="0.0001" min="0" value={exchangeRate}
|
||||||
|
placeholder="Vacío = abonar en dólares"
|
||||||
|
onChange={(e) => setExchangeRate(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Concepto">
|
||||||
|
<input className="input" placeholder="SALDO INICIAL" value={concept}
|
||||||
|
onChange={(e) => setConcept(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{paying && (
|
||||||
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||||
|
Se abonará {formatMoney(payAmount, payCurrency)} a la cuenta del cliente
|
||||||
|
{payMethod === "CASH" ? " y se emitirá el recibo de efectivo con el siguiente folio." : "."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={busy || !name.trim()}>
|
||||||
|
{busy ? "Guardando…" : "Dar de alta"}
|
||||||
|
</button>
|
||||||
|
<Link href="/clientes" className="btn btn-outline">Cancelar</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,9 +107,14 @@ function ClientesBrowser() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<Link href="/clientes/nuevo" className="btn btn-primary">
|
<>
|
||||||
+ Nuevo cliente
|
<Link href="/clientes/nuevo" className="btn btn-outline">
|
||||||
</Link>
|
+ Solo cliente
|
||||||
|
</Link>
|
||||||
|
<Link href="/clientes/alta" className="btn btn-primary">
|
||||||
|
+ Alta de cliente
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<StatStrip stats={stats} />
|
<StatStrip stats={stats} />
|
||||||
@@ -125,7 +130,7 @@ function ClientesBrowser() {
|
|||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Buscar por nombre, ciudad, teléfono…"
|
placeholder="Buscar por nombre, carpeta, ciudad, teléfono…"
|
||||||
aria-label="Buscar clientes"
|
aria-label="Buscar clientes"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,6 +266,8 @@ function CustomerRow({ c }: { c: CustomerListItem }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="cust-sub">
|
<div className="cust-sub">
|
||||||
|
{c.folderNumber != null && <span>Carpeta {c.folderNumber}</span>}
|
||||||
|
{c.folderNumber != null && (location || contact) && <span className="sep">·</span>}
|
||||||
{location && <span>{location}</span>}
|
{location && <span>{location}</span>}
|
||||||
{location && contact && <span className="sep">·</span>}
|
{location && contact && <span className="sep">·</span>}
|
||||||
{contact && <span>{contact}</span>}
|
{contact && <span>{contact}</span>}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getCashReceipt } from "@/lib/api";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/labels";
|
||||||
|
import type { CashReceipt, CompanyHeader } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One EFE receipt, laid out to print and hand to the customer. No app shell:
|
||||||
|
* the page is the paper.
|
||||||
|
*/
|
||||||
|
export default function CashReceiptPrint({ params }: { params: { folio: string } }) {
|
||||||
|
const [r, setR] = useState<(CashReceipt & { company: CompanyHeader }) | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getCashReceipt(Number(params.folio))
|
||||||
|
.then(setR)
|
||||||
|
.catch((e) => setError((e as Error)?.message ?? "No se encontró el recibo."));
|
||||||
|
}, [params.folio]);
|
||||||
|
|
||||||
|
if (error) return <div className="state-box state-error">{error}</div>;
|
||||||
|
if (!r) return <div className="state-box">Cargando…</div>;
|
||||||
|
|
||||||
|
const c = r.company;
|
||||||
|
const voided = r.transaction.voidedAt != null;
|
||||||
|
const converted = r.exchangeRate != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 720, margin: "24px auto", padding: 24, fontSize: 14 }}>
|
||||||
|
<style>{"@media print { .no-print { display: none } body { background: #fff } }"}</style>
|
||||||
|
<div className="no-print" style={{ marginBottom: 16 }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => window.print()}>
|
||||||
|
Imprimir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ border: "1px solid #333", padding: 24 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: 18 }}>{c.name}</strong>
|
||||||
|
<div>{c.addressLine1}</div>
|
||||||
|
{c.addressLine2 && <div>{c.addressLine2}</div>}
|
||||||
|
<div>{c.cityState}</div>
|
||||||
|
{c.mailingAddress && <div>{c.mailingAddress}</div>}
|
||||||
|
<div>
|
||||||
|
{c.phone} · {c.email}
|
||||||
|
</div>
|
||||||
|
{c.taxId && <div>RFC {c.taxId}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>RECIBO DE EFECTIVO</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700 }}>Folio {r.folio}</div>
|
||||||
|
<div>{formatDate(r.receivedAt)}</div>
|
||||||
|
{voided && <div style={{ color: "#b00", fontWeight: 700 }}>ANULADO</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style={{ width: "100%", marginTop: 24, borderCollapse: "collapse" }}>
|
||||||
|
<tbody>
|
||||||
|
<Row label="Cliente">
|
||||||
|
{r.customer.name}
|
||||||
|
{r.customer.folderNumber != null && ` · Carpeta ${r.customer.folderNumber}`}
|
||||||
|
</Row>
|
||||||
|
<Row label="Monto">
|
||||||
|
<strong>
|
||||||
|
{formatMoney(r.amount, r.currency)} {r.currency}
|
||||||
|
</strong>
|
||||||
|
{converted &&
|
||||||
|
` (abonado ${formatMoney(r.transaction.amount, r.transaction.currency)} a T.C. ${Number(r.exchangeRate).toFixed(4)})`}
|
||||||
|
</Row>
|
||||||
|
<Row label="Concepto">{r.concept}</Row>
|
||||||
|
<Row label="Elaboró">{r.preparedBy.name}</Row>
|
||||||
|
{r.depositedAt && <Row label="Fecha de depósito">{formatDate(r.depositedAt)}</Row>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 56, display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
|
||||||
|
Recibí
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: "1px solid #333", width: 240, textAlign: "center", paddingTop: 4 }}>
|
||||||
|
Entregó
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td style={{ padding: "6px 12px 6px 0", width: 160, color: "#555", verticalAlign: "top" }}>
|
||||||
|
{label}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "6px 0" }}>{children}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
|
import {
|
||||||
|
createCashReceipt,
|
||||||
|
getNextCashFolio,
|
||||||
|
listCashReceipts,
|
||||||
|
setCashReceiptDeposit,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { useCan } from "@/lib/abilities";
|
||||||
|
import { formatDate, formatMoney } from "@/lib/labels";
|
||||||
|
import type { CashReceipt, Currency } from "@/lib/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The EFE cash receipt book — the legacy EFE form. Each receipt takes the next
|
||||||
|
* folio, prints for the customer, and credits their account as C<folio> in the
|
||||||
|
* same step. Elaboró is whoever is logged in.
|
||||||
|
*/
|
||||||
|
export default function EfectivoPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<CashBook />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The office's calendar day, not UTC's — in the evening UTC is already tomorrow.
|
||||||
|
const today = () => new Date().toLocaleDateString("en-CA");
|
||||||
|
|
||||||
|
function CashBook() {
|
||||||
|
const canCapture = useCan("ledger:create");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [items, setItems] = useState<CashReceipt[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const r = await listCashReceipts({ query: query.trim() || undefined });
|
||||||
|
setItems(r.items);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudieron cargar los recibos.");
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => void load(), 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<header className="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">Recibos de efectivo</h1>
|
||||||
|
<p className="page-sub">
|
||||||
|
Cada recibo toma el siguiente folio y abona la cuenta del cliente como C‹folio›.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{canCapture && <ReceiptForm onIssued={load} />}
|
||||||
|
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
placeholder="Buscar por folio, carpeta o cliente…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||||
|
/>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Folio</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Carpeta</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Monto</th>
|
||||||
|
<th>Concepto</th>
|
||||||
|
<th>Elaboró</th>
|
||||||
|
<th>Depósito</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((r) => (
|
||||||
|
<ReceiptRow key={r.id} r={r} canCapture={canCapture} onChange={load} />
|
||||||
|
))}
|
||||||
|
{!items.length && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="page-sub">Sin recibos.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiptForm({ onIssued }: { onIssued: () => void }) {
|
||||||
|
const [customerId, setCustomerId] = useState("");
|
||||||
|
const [customerName, setCustomerName] = useState("");
|
||||||
|
const [folio, setFolio] = useState("");
|
||||||
|
const [receivedAt, setReceivedAt] = useState(today);
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [currency, setCurrency] = useState<Currency>("MXN");
|
||||||
|
const [exchangeRate, setExchangeRate] = useState("");
|
||||||
|
const [concept, setConcept] = useState("");
|
||||||
|
const [depositedAt, setDepositedAt] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [issued, setIssued] = useState<CashReceipt | null>(null);
|
||||||
|
|
||||||
|
const refreshFolio = useCallback(() => {
|
||||||
|
getNextCashFolio()
|
||||||
|
.then((r) => setFolio(String(r.folio)))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(refreshFolio, [refreshFolio]);
|
||||||
|
|
||||||
|
// The wording the office already writes on every receipt.
|
||||||
|
const suggested = amount
|
||||||
|
? currency === "USD"
|
||||||
|
? `RECEIVED $${amount} DLLS FOR ACCOUNT.`
|
||||||
|
: `RECIBI $${amount} PESOS PARA CUENTA.`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
async function submit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const r = await createCashReceipt({
|
||||||
|
customerId,
|
||||||
|
folio: folio ? Number(folio) : undefined,
|
||||||
|
receivedAt,
|
||||||
|
amount: Number(amount),
|
||||||
|
currency,
|
||||||
|
exchangeRate: currency === "USD" && exchangeRate ? Number(exchangeRate) : undefined,
|
||||||
|
concept: concept.trim() || suggested,
|
||||||
|
depositedAt: depositedAt || undefined,
|
||||||
|
});
|
||||||
|
setIssued(r);
|
||||||
|
setCustomerId("");
|
||||||
|
setCustomerName("");
|
||||||
|
setAmount("");
|
||||||
|
setExchangeRate("");
|
||||||
|
setConcept("");
|
||||||
|
setDepositedAt("");
|
||||||
|
refreshFolio();
|
||||||
|
onIssued();
|
||||||
|
} catch (e2) {
|
||||||
|
setError((e2 as Error)?.message ?? "No se pudo emitir el recibo.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="card" style={{ padding: 16 }} onSubmit={submit}>
|
||||||
|
<h2 className="section-title" style={{ marginTop: 0 }}>Nuevo recibo</h2>
|
||||||
|
{error && <div className="state-box state-error">{error}</div>}
|
||||||
|
{issued && (
|
||||||
|
<div className="state-box" style={{ marginBottom: 12 }}>
|
||||||
|
Folio {issued.folio} emitido a {issued.customer.name} por{" "}
|
||||||
|
{formatMoney(issued.amount, issued.currency)}.{" "}
|
||||||
|
<Link href={`/efectivo/${issued.folio}`} target="_blank">
|
||||||
|
Imprimir
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="form-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Folio</span>
|
||||||
|
<input className="input" type="number" min={1} value={folio}
|
||||||
|
onChange={(e) => setFolio(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="field" style={{ minWidth: 260 }}>
|
||||||
|
<span className="field-label">Cliente *</span>
|
||||||
|
<CustomerPicker
|
||||||
|
value={customerId}
|
||||||
|
valueName={customerName}
|
||||||
|
onPick={(id, name) => {
|
||||||
|
setCustomerId(id);
|
||||||
|
setCustomerName(name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Fecha *</span>
|
||||||
|
<input className="input" type="date" required value={receivedAt}
|
||||||
|
onChange={(e) => setReceivedAt(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Monto *</span>
|
||||||
|
<input className="input" type="number" step="0.01" min="0.01" required value={amount}
|
||||||
|
onChange={(e) => setAmount(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Moneda</span>
|
||||||
|
<select className="input select" value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||||
|
<option value="MXN">MXN</option>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{currency === "USD" && (
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Tipo de cambio (abonar en pesos)</span>
|
||||||
|
<input className="input" type="number" step="0.0001" min="0" value={exchangeRate}
|
||||||
|
placeholder="Vacío = abonar en dólares"
|
||||||
|
onChange={(e) => setExchangeRate(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Fecha de depósito</span>
|
||||||
|
<input className="input" type="date" value={depositedAt}
|
||||||
|
onChange={(e) => setDepositedAt(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field" style={{ marginTop: 12 }}>
|
||||||
|
<span className="field-label">Concepto</span>
|
||||||
|
<input className="input" value={concept} placeholder={suggested}
|
||||||
|
onChange={(e) => setConcept(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn btn-primary"
|
||||||
|
disabled={busy || !customerId || !(Number(amount) > 0)}>
|
||||||
|
{busy ? "Emitiendo…" : "Emitir recibo"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiptRow({
|
||||||
|
r,
|
||||||
|
canCapture,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
r: CashReceipt;
|
||||||
|
canCapture: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const [deposit, setDeposit] = useState("");
|
||||||
|
const voided = r.transaction.voidedAt != null;
|
||||||
|
|
||||||
|
async function saveDeposit() {
|
||||||
|
await setCashReceiptDeposit(r.id, deposit);
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr style={voided ? { textDecoration: "line-through", opacity: 0.6 } : undefined}>
|
||||||
|
<td>{r.folio}</td>
|
||||||
|
<td>{formatDate(r.receivedAt)}</td>
|
||||||
|
<td>{r.customer.folderNumber ?? "—"}</td>
|
||||||
|
<td>
|
||||||
|
<Link href={`/clientes/${r.customer.id}`}>{r.customer.name}</Link>
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "right" }}>{formatMoney(r.amount, r.currency)}</td>
|
||||||
|
<td>{r.concept}</td>
|
||||||
|
<td>{r.preparedBy.name}</td>
|
||||||
|
<td>
|
||||||
|
{r.depositedAt ? (
|
||||||
|
formatDate(r.depositedAt)
|
||||||
|
) : canCapture && !voided ? (
|
||||||
|
<span className="inline-form" style={{ gap: 4 }}>
|
||||||
|
<input className="input" type="date" value={deposit}
|
||||||
|
onChange={(e) => setDeposit(e.target.value)} />
|
||||||
|
<button type="button" className="btn btn-ghost" disabled={!deposit}
|
||||||
|
onClick={saveDeposit}>
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{voided ? "Anulado" : <Link href={`/efectivo/${r.folio}`} target="_blank">Imprimir</Link>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,10 +36,14 @@ import type {
|
|||||||
* charge and an insurance payment finally sit on the same page, under the same
|
* charge and an insurance payment finally sit on the same page, under the same
|
||||||
* person, with a running balance.
|
* person, with a running balance.
|
||||||
*
|
*
|
||||||
* The running balance is per currency (the API accumulates it chronologically
|
* The running balance is per currency, so the movement table is scoped to one
|
||||||
* before handing the list back newest-first), so the movement table is scoped
|
* currency at a time — a column that alternated between pesos and dollars would
|
||||||
* to one currency at a time — a column that alternated between pesos and
|
* be a meaningless number.
|
||||||
* dollars would be a meaningless number.
|
*
|
||||||
|
* Like the legacy EDO CUENTA report, the table covers one calendar year and runs
|
||||||
|
* oldest-first, opening on the balance carried in from before it. The period
|
||||||
|
* selector switches years; earlier ones are served from the imported archive of
|
||||||
|
* that year, which is how legacy kept them — one table per closed year.
|
||||||
*/
|
*/
|
||||||
export default function EstadoCuentaDetailPage({
|
export default function EstadoCuentaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -65,19 +69,27 @@ function StatementView({ id }: { id: string }) {
|
|||||||
|
|
||||||
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
const [currency, setCurrency] = useState<LedgerCurrency | null>(null);
|
||||||
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
const [domain, setDomain] = useState<TransactionDomain | "">("");
|
||||||
|
/** null = the current period; the API decides what that is. */
|
||||||
|
const [year, setYear] = useState<number | null>(null);
|
||||||
|
|
||||||
function reload() {
|
function reload() {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
getStatement(id)
|
getStatement(id, year ?? undefined)
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(d);
|
setData(d);
|
||||||
// Default to the currency the customer actually moves the most in;
|
// Default to the currency the customer actually moves the most in;
|
||||||
// preserve a previously-chosen currency across reloads.
|
// preserve a previously-chosen currency across reloads — but only if
|
||||||
|
// the loaded period still has it. Switching to a year the customer
|
||||||
|
// never moved dollars in would otherwise leave the picker on USD with
|
||||||
|
// no matching option, showing an empty table for a year that has rows.
|
||||||
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
const busiest = [...d.summary].sort((a, b) => b.count - a.count)[0];
|
||||||
setCurrency((prev) => prev ?? busiest?.currency ?? "MXN");
|
const fallback = busiest?.currency ?? "MXN";
|
||||||
|
setCurrency((prev) =>
|
||||||
|
prev && d.summary.some((s) => s.currency === prev) ? prev : fallback,
|
||||||
|
);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -99,7 +111,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
getBillingFacets().then(setFacets).catch(() => setFacets(null));
|
||||||
return cleanup;
|
return cleanup;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id]);
|
}, [id, year]);
|
||||||
|
|
||||||
const movements = useMemo(() => {
|
const movements = useMemo(() => {
|
||||||
if (!data || !currency) return [];
|
if (!data || !currency) return [];
|
||||||
@@ -194,7 +206,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
<section className="section">
|
<section className="section">
|
||||||
<SectionHead
|
<SectionHead
|
||||||
rule="cuenta"
|
rule="cuenta"
|
||||||
title="Movimientos"
|
title={`Movimientos ${data.year}`}
|
||||||
count={movements.length}
|
count={movements.length}
|
||||||
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
|
||||||
right={
|
right={
|
||||||
@@ -227,6 +239,26 @@ function StatementView({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="filter-row">
|
<div className="filter-row">
|
||||||
|
{/* Only the periods this customer has. A year with no archive would
|
||||||
|
render an empty table that reads as "no hubo movimientos" when the
|
||||||
|
truth is that the year was never imported. */}
|
||||||
|
{data.availableYears.length > 1 && (
|
||||||
|
<label className="filter-field">
|
||||||
|
<span className="filter-label">Periodo</span>
|
||||||
|
<select
|
||||||
|
className="input select"
|
||||||
|
value={data.year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{data.availableYears.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
{y === data.availableYears[0] ? " (en curso)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="filter-field">
|
<label className="filter-field">
|
||||||
<span className="filter-label">Moneda</span>
|
<span className="filter-label">Moneda</span>
|
||||||
<select
|
<select
|
||||||
@@ -260,7 +292,7 @@ function StatementView({ id }: { id: string }) {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
{movements.length === 0 ? (
|
{movements.length === 0 ? (
|
||||||
<div className="empty-inline">
|
<div className="empty-inline">
|
||||||
Sin movimientos en {currency}
|
Sin movimientos de {data.year} en {currency}
|
||||||
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
{domain ? ` para ${domainLabel(domain)}` : ""}.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -282,6 +314,26 @@ function StatementView({ id }: { id: string }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
{/*
|
||||||
|
The carried balance, shown the way the legacy report shows
|
||||||
|
it: a BALANCE FORWARD line above the year's movements. It
|
||||||
|
only appears when there is something to carry — when the
|
||||||
|
customer's opening-balance row is itself dated inside this
|
||||||
|
year (the usual case) it is listed as an ordinary movement
|
||||||
|
and this row is zero, so it is left out.
|
||||||
|
|
||||||
|
Suppressed under a business-line filter: the carried balance
|
||||||
|
is the customer's, across both lines, and printing it above
|
||||||
|
one line's rows would read as that line's opening balance.
|
||||||
|
*/}
|
||||||
|
{!domain && Number(active?.opening ?? 0) !== 0 && (
|
||||||
|
<OpeningRow
|
||||||
|
opening={active!.opening}
|
||||||
|
currency={currency}
|
||||||
|
year={data.year}
|
||||||
|
canVoid={canVoid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{movements.map((m) => (
|
{movements.map((m) => (
|
||||||
<StatementRow
|
<StatementRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -481,6 +533,44 @@ function ConceptosSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
|
||||||
|
function OpeningRow({
|
||||||
|
opening,
|
||||||
|
currency,
|
||||||
|
year,
|
||||||
|
canVoid,
|
||||||
|
}: {
|
||||||
|
opening: string;
|
||||||
|
currency: LedgerCurrency;
|
||||||
|
year: number;
|
||||||
|
canVoid: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(`${year}-01-01T00:00:00.000Z`)}
|
||||||
|
</td>
|
||||||
|
<td className="tx-domain-cell">Ambas líneas</td>
|
||||||
|
<td>
|
||||||
|
Saldo anterior
|
||||||
|
<div className="tx-concept">Al cierre de {year - 1}</div>
|
||||||
|
</td>
|
||||||
|
<td className="tx-ref">—</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
|
||||||
|
{formatMoney(opening, currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`bal-running ${balanceTone(opening)}`}>
|
||||||
|
{formatMoney(opening, currency)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{canVoid && <td />}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatementRow({
|
function StatementRow({
|
||||||
m,
|
m,
|
||||||
canVoid,
|
canVoid,
|
||||||
|
|||||||
@@ -926,6 +926,15 @@ button {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
margin-bottom: 0.4375rem;
|
margin-bottom: 0.4375rem;
|
||||||
}
|
}
|
||||||
|
/* Sub-label under an input: the computed figure behind an override field, or
|
||||||
|
why a field is disabled. Quiet enough not to compete with .field-label. */
|
||||||
|
.field-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--muted-2);
|
||||||
|
margin-top: 0.3125rem;
|
||||||
|
}
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -940,6 +949,12 @@ button {
|
|||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
color: var(--muted-2);
|
color: var(--muted-2);
|
||||||
}
|
}
|
||||||
|
.input:disabled,
|
||||||
|
.select:disabled {
|
||||||
|
background: var(--surface-2, var(--surface));
|
||||||
|
color: var(--muted-2);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--brand-600);
|
border-color: var(--brand-600);
|
||||||
@@ -3114,3 +3129,61 @@ button {
|
|||||||
border-color: var(--brand-500);
|
border-color: var(--brand-500);
|
||||||
color: var(--brand-700);
|
color: var(--brand-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Layout + text utilities the screens already assumed
|
||||||
|
Several components were written against these names before any rule
|
||||||
|
defined them, so they rendered as bare inline spans. The visible symptom
|
||||||
|
was the policy OCR review header running together —
|
||||||
|
"Para revisarPágina 1700489616· PAMELA DENISE WAGONERLICENCIASANA" —
|
||||||
|
because JSX drops the newline between sibling elements and the `gap` those
|
||||||
|
call sites pass does nothing without a flex container.
|
||||||
|
========================================================================== */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
/* The muted line under a page title, and the same voice reused inline. Only
|
||||||
|
the block form takes a margin — as a flex child it would shift the item
|
||||||
|
off the row's centre line. */
|
||||||
|
.page-sub {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
p.page-sub {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
}
|
||||||
|
/* A neutral chip. Same shape as `.badge` so the OCR statuses, policy type and
|
||||||
|
carrier read as the labels they are rather than as running prose. */
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.1875rem 0.5625rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: var(--paper-2);
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
}
|
||||||
|
/* The warning sibling of `.state-error`, used where a page needs a human to
|
||||||
|
choose between candidates rather than reporting a failure. */
|
||||||
|
.state-warn {
|
||||||
|
background: var(--servicios-tint);
|
||||||
|
border: 1px solid rgba(154, 106, 18, 0.25);
|
||||||
|
color: var(--servicios-ink);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
@@ -8,19 +8,18 @@ export const metadata = {
|
|||||||
"Plataforma interna unificada de clientes, servicios y seguros.",
|
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The browser talks to the API cross-origin, so it needs the API URL at
|
// API_ORIGIN is an OPTIONAL override, read here on the server per request and
|
||||||
// runtime. NEXT_PUBLIC_* would bake it at build time (one URL per image); we
|
// injected as window.__API_ORIGIN__ (see lib/api.ts). NEXT_PUBLIC_* would bake
|
||||||
// want the URL to come from the deploy .env instead. So read it here on the
|
// it at build time (one URL per image); reading it here keeps one image usable
|
||||||
// server per request and inject it as window.__API_ORIGIN__ (see lib/api.ts).
|
// anywhere. Left unset — the normal case — this injects the empty string and
|
||||||
// force-dynamic guarantees process.env is read at request time, never baked
|
// lib/api.ts derives the origin from window.location instead, so the app
|
||||||
// into a static prerender.
|
// follows the server when it moves without an env edit. force-dynamic
|
||||||
|
// guarantees process.env is read at request time, never baked into a static
|
||||||
|
// prerender.
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
const apiOrigin =
|
const apiOrigin = process.env.API_ORIGIN ?? "";
|
||||||
process.env.API_ORIGIN ??
|
|
||||||
process.env.NEXT_PUBLIC_API_ORIGIN ??
|
|
||||||
"http://localhost:3001";
|
|
||||||
// Same reason as the API origin: read on the server per request so the built
|
// Same reason as the API origin: read on the server per request so the built
|
||||||
// image is not pinned to one build identity in its client bundle.
|
// image is not pinned to one build identity in its client bundle.
|
||||||
const build = readBuildInfoFromEnv();
|
const build = readBuildInfoFromEnv();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
listOpsJobs,
|
listOpsJobs,
|
||||||
startOpsJob,
|
startOpsJob,
|
||||||
uploadIngest,
|
uploadIngest,
|
||||||
|
verifyReplication,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type { UploadProgress } from "@/lib/api";
|
import type { UploadProgress } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
@@ -28,7 +29,9 @@ import type {
|
|||||||
IngestFile,
|
IngestFile,
|
||||||
OpsJob,
|
OpsJob,
|
||||||
OpsJobKind,
|
OpsJobKind,
|
||||||
|
GtidDrift,
|
||||||
ReplicationStatus,
|
ReplicationStatus,
|
||||||
|
VerifyResult,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
const INGEST_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
@@ -59,11 +62,13 @@ function Operaciones() {
|
|||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
const [confirm, setConfirm] = useState<ConfirmState>(null);
|
||||||
const [confirmText, setConfirmText] = useState("");
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [forceFull, setForceFull] = useState(false);
|
||||||
const [uploading, setUploading] = useState<string | null>(null);
|
const [uploading, setUploading] = useState<string | null>(null);
|
||||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
|
|
||||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||||
|
const periodInput = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const refreshLists = useCallback(() => {
|
const refreshLists = useCallback(() => {
|
||||||
listIngest().then(setIngest).catch(() => setIngest([]));
|
listIngest().then(setIngest).catch(() => setIngest([]));
|
||||||
@@ -139,6 +144,20 @@ function Operaciones() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Upload a prior-period archive under its own filename. */
|
||||||
|
async function handleUploadPeriod(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^\d{4}\.accdb$/i.test(file.name)) {
|
||||||
|
setError(
|
||||||
|
`"${file.name}" no es un archivo de periodo. Debe llamarse AAAA.accdb, por ejemplo 2025.accdb.`,
|
||||||
|
);
|
||||||
|
if (periodInput.current) periodInput.current.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleUpload(file.name, file);
|
||||||
|
if (periodInput.current) periodInput.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteIngest(name: string) {
|
async function handleDeleteIngest(name: string) {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -159,12 +178,12 @@ function Operaciones() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(kind: OpsJobKind, file?: string) {
|
async function start(kind: OpsJobKind, file?: string, force?: boolean) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
try {
|
try {
|
||||||
const job = await startOpsJob(kind, file);
|
const job = await startOpsJob(kind, file, force);
|
||||||
setActiveJob(job);
|
setActiveJob(job);
|
||||||
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
setJobs((prev) => (prev ? [job, ...prev] : [job]));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -177,6 +196,9 @@ function Operaciones() {
|
|||||||
function askConfirm(state: ConfirmState) {
|
function askConfirm(state: ConfirmState) {
|
||||||
setConfirm(state);
|
setConfirm(state);
|
||||||
setConfirmText("");
|
setConfirmText("");
|
||||||
|
// Always re-armed: ticking "delete native rows" once must not carry into
|
||||||
|
// the next reimport.
|
||||||
|
setForceFull(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setNotice(null);
|
setNotice(null);
|
||||||
}
|
}
|
||||||
@@ -185,12 +207,16 @@ function Operaciones() {
|
|||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
const c = confirm;
|
const c = confirm;
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
if (c.kind === "REIMPORT") await start("REIMPORT");
|
if (c.kind === "REIMPORT") await start("REIMPORT", undefined, forceFull);
|
||||||
else if (c.kind === "SYNC") await start("SYNC");
|
else if (c.kind === "SYNC") await start("SYNC");
|
||||||
else await start("RESTORE", c.file);
|
else await start("RESTORE", c.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ingestReady = (ingest ?? []).every((f) => f.present);
|
// Only the four fixed Access sources gate a run. Period archives are
|
||||||
|
// optional extras — having none simply means no prior years are available.
|
||||||
|
const ingestReady = (ingest ?? [])
|
||||||
|
.filter((f) => f.periodYear === null)
|
||||||
|
.every((f) => f.present);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -237,8 +263,9 @@ function Operaciones() {
|
|||||||
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
|
||||||
<h2 className="section-title">Carpeta de ingesta</h2>
|
<h2 className="section-title">Carpeta de ingesta</h2>
|
||||||
<p className="inline-form-note">
|
<p className="inline-form-note">
|
||||||
Los cuatro archivos originales de Access. La reimportación y la
|
Los cuatro archivos originales de Access, más los archivos de periodos
|
||||||
sincronización leen de aquí. Tamaño máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
anteriores. La reimportación y la sincronización leen de aquí. Tamaño
|
||||||
|
máximo por archivo: {formatBytes(INGEST_MAX_BYTES)}.
|
||||||
</p>
|
</p>
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
<table className="tx-table">
|
<table className="tx-table">
|
||||||
@@ -255,7 +282,14 @@ function Operaciones() {
|
|||||||
{(ingest ?? []).map((f) => (
|
{(ingest ?? []).map((f) => (
|
||||||
<Fragment key={f.name}>
|
<Fragment key={f.name}>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="mono">{f.name}</td>
|
<td className="mono">
|
||||||
|
{f.name}
|
||||||
|
{f.periodYear !== null && (
|
||||||
|
<span className="badge" style={{ marginLeft: 8 }}>
|
||||||
|
periodo {f.periodYear}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||||
{f.present ? "Presente" : "Falta"}
|
{f.present ? "Presente" : "Falta"}
|
||||||
@@ -306,6 +340,33 @@ function Operaciones() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* A period archive that has never been uploaded has no row to click,
|
||||||
|
so it needs its own entry point. The file names itself: the archive
|
||||||
|
IS `2025.accdb`, and that name is what declares the period, so the
|
||||||
|
control reads it off the chosen file rather than asking twice. */}
|
||||||
|
<div className="row-actions" style={{ marginTop: 16 }}>
|
||||||
|
<input
|
||||||
|
ref={periodInput}
|
||||||
|
type="file"
|
||||||
|
accept=".accdb"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => handleUploadPeriod(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
type="button"
|
||||||
|
disabled={uploading !== null}
|
||||||
|
onClick={() => periodInput.current?.click()}
|
||||||
|
>
|
||||||
|
Agregar periodo anterior…
|
||||||
|
</button>
|
||||||
|
<span className="inline-form-note">
|
||||||
|
Un archivo de Access por año cerrado, nombrado con su periodo:{" "}
|
||||||
|
<span className="mono">2025.accdb</span>. Aporta el año anterior al
|
||||||
|
estado de cuenta; no altera el saldo actual.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operations */}
|
{/* Operations */}
|
||||||
@@ -478,11 +539,24 @@ function Operaciones() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="inline-form-note">
|
<p className="inline-form-note">
|
||||||
{confirm.kind === "REIMPORT"
|
{confirm.kind === "REIMPORT"
|
||||||
? "Esto BORRA todos los datos actuales (incluidos los capturados a mano) y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático."
|
? "Esto BORRA todos los datos actuales y reconstruye desde los archivos de ingesta. Se creará un respaldo previo automático. Si la base contiene registros que sólo existen en la plataforma (clientes creados aquí, números de portal asignados, pólizas capturadas por OCR, movimientos capturados), la operación se detiene y los enumera sin tocar nada."
|
||||||
: confirm.kind === "SYNC"
|
: confirm.kind === "SYNC"
|
||||||
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
? "Se creará un respaldo previo automático. Luego se importarán al sistema los registros nuevos del legado y se eliminarán los del legado que ya no aparezcan en los archivos de ingesta. Los datos capturados a mano NO se borran."
|
||||||
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
: `Esto sobreescribe la base de datos completa con “${confirm.file}”. Se recomienda crear un respaldo antes.`}
|
||||||
</p>
|
</p>
|
||||||
|
{confirm.kind === "REIMPORT" && (
|
||||||
|
<label className="inline-form-note" style={{ display: "block" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceFull}
|
||||||
|
onChange={(e) => setForceFull(e.target.checked)}
|
||||||
|
style={{ marginRight: 8 }}
|
||||||
|
/>
|
||||||
|
Borrar también los registros que sólo existen en la plataforma
|
||||||
|
(ignorar la verificación). Sólo marque esto si de verdad quiere
|
||||||
|
perderlos.
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
<span className="field-label">Escriba CONFIRMAR para continuar</span>
|
||||||
<input
|
<input
|
||||||
@@ -614,6 +688,9 @@ function OpTile({
|
|||||||
function ReplicationCard() {
|
function ReplicationCard() {
|
||||||
const [status, setStatus] = useState<ReplicationStatus | null>(null);
|
const [status, setStatus] = useState<ReplicationStatus | null>(null);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
const [verify, setVerify] = useState<VerifyResult | null>(null);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [verifyError, setVerifyError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
getReplicationStatus()
|
getReplicationStatus()
|
||||||
@@ -624,6 +701,17 @@ function ReplicationCard() {
|
|||||||
.catch(() => setFailed(true));
|
.catch(() => setFailed(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const runVerify = useCallback(() => {
|
||||||
|
setVerifying(true);
|
||||||
|
setVerifyError(null);
|
||||||
|
verifyReplication()
|
||||||
|
.then(setVerify)
|
||||||
|
.catch((e: unknown) =>
|
||||||
|
setVerifyError(e instanceof Error ? e.message : "No se pudo comparar."),
|
||||||
|
)
|
||||||
|
.finally(() => setVerifying(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
const t = setInterval(load, 30_000);
|
const t = setInterval(load, 30_000);
|
||||||
@@ -687,14 +775,144 @@ function ReplicationCard() {
|
|||||||
value={status.secondsBehind === null ? "sin dato" : `${status.secondsBehind} s`}
|
value={status.secondsBehind === null ? "sin dato" : `${status.secondsBehind} s`}
|
||||||
/>
|
/>
|
||||||
<KV label="Pendiente de aplicar" value={backlogLabel(status.apply)} />
|
<KV label="Pendiente de aplicar" value={backlogLabel(status.apply)} />
|
||||||
|
<KV label="Diferencia con el maestro" value={driftLabel(status.drift)} />
|
||||||
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
<KV label="Consultado" value={formatDateTime(status.checkedAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ApplyProgressBar apply={status.apply} />
|
<ApplyProgressBar apply={status.apply} />
|
||||||
|
|
||||||
|
{/* Only worth showing when it is not zero, and even then as a note rather
|
||||||
|
than a warning: these are the seed load's own transactions, and they
|
||||||
|
are inert until someone tries to promote this box. */}
|
||||||
|
{status.drift !== null && status.drift.localTransactions > 0 && (
|
||||||
|
<p className="inline-form-note" style={{ marginTop: 12 }}>
|
||||||
|
La réplica tiene {status.drift.localTransactions.toLocaleString("es-MX")} transacciones
|
||||||
|
propias (de la carga inicial). No se propagan y no afectan la lectura; sólo importarían
|
||||||
|
si este servidor pasara a ser maestro.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VerifyPanel
|
||||||
|
result={verify}
|
||||||
|
running={verifying}
|
||||||
|
error={verifyError}
|
||||||
|
onRun={runVerify}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row-by-row comparison against the master, on demand.
|
||||||
|
*
|
||||||
|
* Separate from the polled fields because it costs a full scan of both servers.
|
||||||
|
* It is the only check here that can catch a row changed on the replica by
|
||||||
|
* something other than replication — the GTID and lag figures would both still
|
||||||
|
* read perfectly healthy in that case.
|
||||||
|
*/
|
||||||
|
function VerifyPanel({
|
||||||
|
result,
|
||||||
|
running,
|
||||||
|
error,
|
||||||
|
onRun,
|
||||||
|
}: {
|
||||||
|
result: VerifyResult | null;
|
||||||
|
running: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRun: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 16, borderTop: "1px solid var(--border)", paddingTop: 12 }}>
|
||||||
|
<div className="row-actions" style={{ justifyContent: "space-between" }}>
|
||||||
|
<span className="inline-form-note" style={{ margin: 0 }}>
|
||||||
|
Compara fila por fila las 8 tablas que lee el sitio de clientes. Recorre ambos
|
||||||
|
servidores por completo, así que tarda.
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={onRun} disabled={running}>
|
||||||
|
{running ? "Comparando…" : "Comparar con el maestro"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result?.problem && (
|
||||||
|
<div className="state-box state-error" style={{ marginTop: 12 }}>
|
||||||
|
{result.problem}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !result.problem && (
|
||||||
|
<>
|
||||||
|
<p style={{ marginTop: 12, marginBottom: 8 }}>
|
||||||
|
<span className={`badge ${result.identical ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{result.identical ? "Idénticas" : "Hay diferencias"}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="inline-form-note">
|
||||||
|
{formatDateTime(result.checkedAt)} · {(result.elapsedMs / 1000).toFixed(1)} s
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tabla</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Maestro</th>
|
||||||
|
<th style={{ textAlign: "right" }}>Réplica</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{result.tables.map((t) => (
|
||||||
|
<tr key={t.table}>
|
||||||
|
<td>{t.table}</td>
|
||||||
|
{/* -1 is the sentinel for "that server did not answer for
|
||||||
|
this table", which is not the same as zero rows. */}
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
{t.masterRows < 0 ? "—" : t.masterRows.toLocaleString("es-MX")}
|
||||||
|
</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
{t.replicaRows < 0 ? "—" : t.replicaRows.toLocaleString("es-MX")}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${t.matches ? "badge-positive" : "badge-negative"}`}>
|
||||||
|
{t.matches
|
||||||
|
? "igual"
|
||||||
|
: t.masterRows !== t.replicaRows
|
||||||
|
? "difieren en filas"
|
||||||
|
: "difieren en contenido"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transactions the master has executed that the replica has not.
|
||||||
|
*
|
||||||
|
* Rendered as its own line rather than folded into the lag figure because the
|
||||||
|
* two disagree in exactly the case that matters: a disconnected I/O thread
|
||||||
|
* reports 0 seconds of lag (no event has arrived to be late) while this number
|
||||||
|
* climbs.
|
||||||
|
*/
|
||||||
|
function driftLabel(drift: GtidDrift | null): string {
|
||||||
|
// Null means the master could not be reached. Saying "al día" here would be a
|
||||||
|
// lie of the worst kind — it is the reading a broken check produces.
|
||||||
|
if (drift === null) return "sin dato";
|
||||||
|
if (drift.missingTransactions === 0) return "al día";
|
||||||
|
return `${drift.missingTransactions.toLocaleString("es-MX")} transacciones atrás`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bytes the replica has fetched but not yet applied.
|
* Bytes the replica has fetched but not yet applied.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ import {
|
|||||||
premiumHeadline,
|
premiumHeadline,
|
||||||
SIN_NOMBRE,
|
SIN_NOMBRE,
|
||||||
} from "@/lib/labels";
|
} from "@/lib/labels";
|
||||||
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
import {
|
||||||
|
PAYMENT_FREQUENCY_LABELS,
|
||||||
|
type AdjusterRow,
|
||||||
|
type Installment,
|
||||||
|
type PolicyDetail,
|
||||||
|
} from "@/lib/types";
|
||||||
|
import { formatRate } from "@/lib/premium";
|
||||||
|
|
||||||
export default function PolizaDetailPage({
|
export default function PolizaDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -186,6 +192,10 @@ function ChildrenEditor({
|
|||||||
const INSTALLMENTS: ChildConfig = {
|
const INSTALLMENTS: ChildConfig = {
|
||||||
apiKind: "installments",
|
apiKind: "installments",
|
||||||
title: "Pagos",
|
title: "Pagos",
|
||||||
|
// A policy paid in several exhibiciones prices each payment on its own, so
|
||||||
|
// the whole premium breakdown repeats per row — that is the two-row money
|
||||||
|
// block on the Access form. `amount` stays what was actually collected and
|
||||||
|
// is deliberately separate from `total`; they differ by rounding.
|
||||||
fields: [
|
fields: [
|
||||||
{ key: "sequence", label: "Sec.", type: "number" },
|
{ key: "sequence", label: "Sec.", type: "number" },
|
||||||
{ key: "amount", label: "Monto", type: "number" },
|
{ key: "amount", label: "Monto", type: "number" },
|
||||||
@@ -195,6 +205,12 @@ function ChildrenEditor({
|
|||||||
{ key: "paidDate", label: "Pagado", type: "date" },
|
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||||
{ key: "checkNumber", label: "Cheque" },
|
{ key: "checkNumber", label: "Cheque" },
|
||||||
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||||
|
{ key: "netPremium", label: "Prima neta", type: "number" },
|
||||||
|
{ key: "surcharge", label: "Recargo", type: "number" },
|
||||||
|
{ key: "policyFee", label: "Derecho", type: "number" },
|
||||||
|
{ key: "tax", label: "IVA", type: "number" },
|
||||||
|
{ key: "total", label: "Prima total", type: "number" },
|
||||||
|
{ key: "commission", label: "Comisión", type: "number" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const VEHICLES: ChildConfig = {
|
const VEHICLES: ChildConfig = {
|
||||||
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
|
|||||||
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<KV
|
||||||
|
label="Forma de pago"
|
||||||
|
value={
|
||||||
|
data.paymentFrequency
|
||||||
|
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||||
|
{/* Only ever set on a policy paid in installments, so showing an
|
||||||
|
empty row on the other 98% would be noise. */}
|
||||||
|
{data.surcharge != null && Number(data.surcharge) !== 0 && (
|
||||||
|
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
|
||||||
|
)}
|
||||||
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
{/* Access never stored IVA — it was a calculated control on the form
|
||||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
— so every migrated policy reads null here until it is edited. */}
|
||||||
|
{data.tax != null && (
|
||||||
|
<KV
|
||||||
|
label={
|
||||||
|
data.taxRate != null
|
||||||
|
? `IVA (${formatRate(Number(data.taxRate))})`
|
||||||
|
: "IVA"
|
||||||
|
}
|
||||||
|
value={formatMoney(data.tax, cur)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||||
only show it when it actually carries a figure. */}
|
only show it when it actually carries a figure. */}
|
||||||
{data.total != null && Number(data.total) > 0 && (
|
{data.total != null && Number(data.total) > 0 && (
|
||||||
<KV label="Total" value={formatMoney(data.total, cur)} />
|
<KV label="Prima total" value={formatMoney(data.total, cur)} />
|
||||||
)}
|
)}
|
||||||
|
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||||
|
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||||
<KV
|
<KV
|
||||||
label="Liquidación"
|
label="Liquidación"
|
||||||
value={
|
value={
|
||||||
@@ -648,10 +689,79 @@ function SiniestrosSection({ data }: { data: PolicyDetail }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------- Coberturas */
|
/* -------------------------------------------------------- Coberturas */
|
||||||
/** The legacy tables carry per-line coverage columns the target schema does
|
/**
|
||||||
* not model; the migration preserved them verbatim in `coveragesJson`. */
|
* `coveragesJson` holds two unrelated shapes and the section renders each on
|
||||||
|
* its own terms:
|
||||||
|
*
|
||||||
|
* - **A Spanish-keyed object** — the legacy per-line coverage columns the
|
||||||
|
* target schema does not model, preserved verbatim by the migration. Every
|
||||||
|
* policy imported from Access carries this one.
|
||||||
|
* - **A `ParsedCoverage[]` array** — written by the policy OCR confirm step
|
||||||
|
* (GMX's coverage table, ANA's numbered risk sections).
|
||||||
|
*
|
||||||
|
* Running the object renderer over the array is what used to happen, and it
|
||||||
|
* produced a row per array index labelled "0", "1", "2" with `[object
|
||||||
|
* Object]` as its value — not a crash, so nothing surfaced it.
|
||||||
|
*/
|
||||||
|
interface StoredCoverage {
|
||||||
|
risk?: string;
|
||||||
|
insuredAmount?: number | null;
|
||||||
|
deductible?: string | null;
|
||||||
|
lossParticipation?: string | null;
|
||||||
|
premium?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
function CoberturasSection({ data }: { data: PolicyDetail }) {
|
||||||
const entries = Object.entries(data.coveragesJson ?? {}).filter(
|
const raw = data.coveragesJson ?? null;
|
||||||
|
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
const rows = (raw as StoredCoverage[]).filter((c) => c && c.risk);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="seguros" title="Coberturas" count={rows.length} />
|
||||||
|
<div className="card">
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Riesgo</th>
|
||||||
|
<th className="num">Suma asegurada</th>
|
||||||
|
<th className="num">Prima</th>
|
||||||
|
<th>Deducible</th>
|
||||||
|
<th>Participación</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((c, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{c.risk}</td>
|
||||||
|
<td className="num">
|
||||||
|
{c.insuredAmount == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.insuredAmount.toString(), data.currency)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{c.premium == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.premium.toString(), data.currency)}
|
||||||
|
</td>
|
||||||
|
<td>{c.deductible ?? "—"}</td>
|
||||||
|
<td>{c.lossParticipation ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="section-note" style={{ padding: "0 22px 18px" }}>
|
||||||
|
Coberturas leídas del PDF de la aseguradora.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Object.entries(raw ?? {}).filter(
|
||||||
([, v]) => v !== null && v !== "" && v !== 0,
|
([, v]) => v !== null && v !== "" && v !== 0,
|
||||||
);
|
);
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
import { PolicyCaptura } from "@/components/PolicyCaptura";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OCR mode of the policy intake screen. Drops the GMX PDF, walks through
|
* OCR mode of the policy intake screen. Drops the GMX or A.N.A. PDF, walks through
|
||||||
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
* per-page review, confirms. Same wrapper as `/polizas/nuevo` (manual)
|
||||||
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
* with `initialMode="auto"`, so the tab strip is identical and swapping
|
||||||
* modes doesn't drop state.
|
* modes doesn't drop state.
|
||||||
|
|||||||
@@ -6,18 +6,21 @@ import { AppShell } from "@/components/AppShell";
|
|||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
import { DiscardBatchCard } from "@/components/DiscardBatchCard";
|
||||||
import {
|
import {
|
||||||
|
addStatementFiles,
|
||||||
confirmStatementBatch,
|
confirmStatementBatch,
|
||||||
discardStatementBatch,
|
discardStatementBatch,
|
||||||
getStatementBatch,
|
getStatementBatch,
|
||||||
listStatementDocuments,
|
listStatementDocuments,
|
||||||
rejectStatementDocument,
|
rejectStatementDocument,
|
||||||
reviewStatementDocument,
|
reviewStatementDocument,
|
||||||
|
setStatementSplits,
|
||||||
statementPageUrl,
|
statementPageUrl,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useCan } from "@/lib/abilities";
|
import { useCan } from "@/lib/abilities";
|
||||||
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
import { formatDate, formatMoney, serviceKindLabel } from "@/lib/labels";
|
||||||
import type {
|
import type {
|
||||||
ConfirmBatchInput,
|
ConfirmBatchInput,
|
||||||
|
SplitPartInput,
|
||||||
StatementBatchDetail,
|
StatementBatchDetail,
|
||||||
StatementDocument,
|
StatementDocument,
|
||||||
StatementDocumentStatus,
|
StatementDocumentStatus,
|
||||||
@@ -64,6 +67,7 @@ export default function RecibosBatchPage({ params }: { params: { id: string } })
|
|||||||
|
|
||||||
function BatchReview({ id }: { id: string }) {
|
function BatchReview({ id }: { id: string }) {
|
||||||
const canReview = useCan("statement:review");
|
const canReview = useCan("statement:review");
|
||||||
|
const canIngest = useCan("statement:ingest");
|
||||||
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
const [batch, setBatch] = useState<StatementBatchDetail | null>(null);
|
||||||
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
const [docs, setDocs] = useState<StatementDocument[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -108,7 +112,7 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const readyCount = docs.filter(
|
const readyCount = docs.filter(
|
||||||
(d) => d.status === "MATCHED" && d.matchedCustomer,
|
(d) => d.status === "MATCHED" && (d.matchedCustomer || d.splits.length > 0),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
async function discard() {
|
async function discard() {
|
||||||
@@ -162,9 +166,14 @@ function BatchReview({ id }: { id: string }) {
|
|||||||
|
|
||||||
<SummaryCard batch={batch} readyCount={readyCount} />
|
<SummaryCard batch={batch} readyCount={readyCount} />
|
||||||
|
|
||||||
|
{canIngest && !processing && batch.status !== "DISCARDED" && (
|
||||||
|
<AddFilesCard batchId={id} onDone={load} setError={setError} />
|
||||||
|
)}
|
||||||
|
|
||||||
{canReview && readyCount > 0 && (
|
{canReview && readyCount > 0 && (
|
||||||
<ConfirmCard
|
<ConfirmCard
|
||||||
batchId={id}
|
batchId={id}
|
||||||
|
lastCheckNumber={batch.lastCheckNumber}
|
||||||
readyCount={readyCount}
|
readyCount={readyCount}
|
||||||
onDone={load}
|
onDone={load}
|
||||||
setError={setError}
|
setError={setError}
|
||||||
@@ -291,16 +300,19 @@ function SummaryCard({
|
|||||||
*/
|
*/
|
||||||
function ConfirmCard({
|
function ConfirmCard({
|
||||||
batchId,
|
batchId,
|
||||||
|
lastCheckNumber,
|
||||||
readyCount,
|
readyCount,
|
||||||
onDone,
|
onDone,
|
||||||
setError,
|
setError,
|
||||||
}: {
|
}: {
|
||||||
batchId: string;
|
batchId: string;
|
||||||
|
lastCheckNumber: string | null;
|
||||||
readyCount: number;
|
readyCount: number;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
setError: (m: string | null) => void;
|
setError: (m: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [checkNumber, setCheckNumber] = useState("");
|
// A batch kept open for one check is posted in rounds against that check.
|
||||||
|
const [checkNumber, setCheckNumber] = useState(lastCheckNumber ?? "");
|
||||||
const [transactionDate, setTransactionDate] = useState(
|
const [transactionDate, setTransactionDate] = useState(
|
||||||
new Date().toISOString().slice(0, 10),
|
new Date().toISOString().slice(0, 10),
|
||||||
);
|
);
|
||||||
@@ -322,9 +334,9 @@ function ConfirmCard({
|
|||||||
};
|
};
|
||||||
const r = await confirmStatementBatch(batchId, input);
|
const r = await confirmStatementBatch(batchId, input);
|
||||||
setResult(
|
setResult(
|
||||||
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}.`,
|
`Se registraron ${r.posted} movimiento(s) por ${formatMoney(r.total, "MXN")} con el cheque ${r.checkNumber}` +
|
||||||
|
(r.outstanding ? `; ${r.outstanding} sin fondos.` : "."),
|
||||||
);
|
);
|
||||||
setCheckNumber("");
|
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
setError((e as Error)?.message ?? "No se pudo registrar el lote.");
|
||||||
@@ -363,7 +375,7 @@ function ConfirmCard({
|
|||||||
checked={outstanding}
|
checked={outstanding}
|
||||||
onChange={(e) => setOutstanding(e.target.checked)}
|
onChange={(e) => setOutstanding(e.target.checked)}
|
||||||
/>{" "}
|
/>{" "}
|
||||||
Sin fondos (queda pendiente)
|
Todo el cheque sin fondos
|
||||||
</label>
|
</label>
|
||||||
<label className="check">
|
<label className="check">
|
||||||
<input
|
<input
|
||||||
@@ -389,12 +401,65 @@ function ConfirmCard({
|
|||||||
)}
|
)}
|
||||||
<p className="page-sub" style={{ marginTop: 12 }}>
|
<p className="page-sub" style={{ marginTop: 12 }}>
|
||||||
Se registran como cargos del cliente, por la misma vía que la captura
|
Se registran como cargos del cliente, por la misma vía que la captura
|
||||||
manual. Un lote registrado dos veces no duplica cobros.
|
manual. Un lote registrado dos veces no duplica cobros. Para dejar
|
||||||
|
pendiente un solo recibo, márcalo «Sin fondos» en el recibo.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** More scans for this batch — it stays open while receipts keep arriving. */
|
||||||
|
function AddFilesCard({
|
||||||
|
batchId,
|
||||||
|
onDone,
|
||||||
|
setError,
|
||||||
|
}: {
|
||||||
|
batchId: string;
|
||||||
|
onDone: () => void;
|
||||||
|
setError: (m: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await addStatementFiles(batchId, files);
|
||||||
|
setFiles([]);
|
||||||
|
onDone();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error)?.message ?? "No se pudieron agregar los archivos.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card" style={{ padding: 16 }}>
|
||||||
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<label>
|
||||||
|
<span className="page-sub">Agregar recibos a este lote</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
multiple
|
||||||
|
onChange={(e) => setFiles(Array.from(e.target.files ?? []))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
disabled={!files.length || busy}
|
||||||
|
onClick={upload}
|
||||||
|
>
|
||||||
|
{busy ? "Subiendo…" : `Agregar ${files.length || ""} archivo(s)`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentRow({
|
function DocumentRow({
|
||||||
doc,
|
doc,
|
||||||
canReview,
|
canReview,
|
||||||
@@ -411,6 +476,7 @@ function DocumentRow({
|
|||||||
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
const [accountRef, setAccountRef] = useState(doc.extractedAccountRef ?? "");
|
||||||
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
const [customerId, setCustomerId] = useState(doc.matchedCustomer?.id ?? "");
|
||||||
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
const [customerName, setCustomerName] = useState(doc.matchedCustomer?.name ?? "");
|
||||||
|
const [outstanding, setOutstanding] = useState(doc.outstanding);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -424,6 +490,7 @@ function DocumentRow({
|
|||||||
accountRef: accountRef.trim() || undefined,
|
accountRef: accountRef.trim() || undefined,
|
||||||
amount: amount ? Number(amount) : undefined,
|
amount: amount ? Number(amount) : undefined,
|
||||||
matchedCustomerId: customerId || undefined,
|
matchedCustomerId: customerId || undefined,
|
||||||
|
outstanding,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
onChange();
|
onChange();
|
||||||
@@ -456,9 +523,21 @@ function DocumentRow({
|
|||||||
<div>
|
<div>
|
||||||
<strong>Página {doc.pageNumber}</strong>{" "}
|
<strong>Página {doc.pageNumber}</strong>{" "}
|
||||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>{" "}
|
||||||
|
{doc.outstanding && <span className="tag">Sin fondos</span>}{" "}
|
||||||
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
{doc.provider && <span className="page-sub">{doc.provider}</span>}
|
||||||
<div className="page-sub" style={{ marginTop: 4 }}>
|
<div className="page-sub" style={{ marginTop: 4 }}>
|
||||||
{doc.matchedCustomer ? (
|
{doc.splits.length > 0 ? (
|
||||||
|
<>
|
||||||
|
Dividido:{" "}
|
||||||
|
{doc.splits.map((s, i) => (
|
||||||
|
<span key={s.id}>
|
||||||
|
{i > 0 && " + "}
|
||||||
|
<Link href={`/clientes/${s.customer.id}`}>{s.customer.name}</Link>{" "}
|
||||||
|
{formatMoney(s.amount, "MXN")}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : doc.matchedCustomer ? (
|
||||||
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
<Link href={`/clientes/${doc.matchedCustomer.id}`}>
|
||||||
{doc.matchedCustomer.name}
|
{doc.matchedCustomer.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -533,10 +612,18 @@ function DocumentRow({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={outstanding}
|
||||||
|
onChange={(e) => setOutstanding(e.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
Sin fondos
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={busy || !customerId}
|
disabled={busy || (!customerId && doc.splits.length === 0)}
|
||||||
onClick={() => save("MATCHED")}
|
onClick={() => save("MATCHED")}
|
||||||
>
|
>
|
||||||
Guardar
|
Guardar
|
||||||
@@ -552,9 +639,164 @@ function DocumentRow({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canReview && !settled && (
|
||||||
|
<SplitEditor doc={doc} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
{err && <div className="state-box state-error">{err}</div>}
|
{err && <div className="state-box state-error">{err}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Part = { customerId: string; customerName: string; amount: string };
|
||||||
|
|
||||||
|
/** Equal parts in cents; the odd cents go to the first parts. */
|
||||||
|
function equalParts(total: number, n: number): string[] {
|
||||||
|
const cents = Math.round(total * 100);
|
||||||
|
const base = Math.floor(cents / n);
|
||||||
|
return Array.from({ length: n }, (_, i) =>
|
||||||
|
((base + (i < cents - base * n ? 1 : 0)) / 100).toFixed(2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One receipt paid by several customers — a water bill two neighbours share.
|
||||||
|
* Amounts are free (the office splits unevenly as often as not) but must add
|
||||||
|
* up to the receipt; "partes iguales" fills them in.
|
||||||
|
*/
|
||||||
|
function SplitEditor({ doc, onChange }: { doc: StatementDocument; onChange: () => void }) {
|
||||||
|
const total = Number(doc.extractedAmount ?? 0);
|
||||||
|
const [parts, setParts] = useState<Part[]>(() =>
|
||||||
|
doc.splits.map((s) => ({
|
||||||
|
customerId: s.customer.id,
|
||||||
|
customerName: s.customer.name,
|
||||||
|
amount: Number(s.amount).toFixed(2),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const sum = parts.reduce((s, p) => s + Math.round(Number(p.amount || 0) * 100), 0) / 100;
|
||||||
|
const balanced = Math.round(sum * 100) === Math.round(total * 100);
|
||||||
|
const complete = parts.length >= 2 && parts.every((p) => p.customerId && Number(p.amount) > 0);
|
||||||
|
|
||||||
|
function setPart(i: number, patch: Partial<Part>) {
|
||||||
|
setParts((prev) => prev.map((p, j) => (j === i ? { ...p, ...patch } : p)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const first = {
|
||||||
|
customerId: doc.matchedCustomer?.id ?? "",
|
||||||
|
customerName: doc.matchedCustomer?.name ?? "",
|
||||||
|
};
|
||||||
|
const [a, b] = equalParts(total, 2);
|
||||||
|
setParts([
|
||||||
|
{ ...first, amount: a },
|
||||||
|
{ customerId: "", customerName: "", amount: b },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evenly() {
|
||||||
|
const amounts = equalParts(total, parts.length);
|
||||||
|
setParts((prev) => prev.map((p, i) => ({ ...p, amount: amounts[i] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(next: SplitPartInput[]) {
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await setStatementSplits(doc.id, next);
|
||||||
|
onChange();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error)?.message ?? "No se pudo guardar la división.");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parts.length) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
disabled={!doc.extractedAmount}
|
||||||
|
title={doc.extractedAmount ? undefined : "Captura el importe primero"}
|
||||||
|
onClick={start}
|
||||||
|
>
|
||||||
|
Dividir entre varios clientes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stack" style={{ gap: 8 }}>
|
||||||
|
<strong>División del recibo ({formatMoney(total, "MXN")})</strong>
|
||||||
|
{parts.map((p, i) => (
|
||||||
|
<div key={i} className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<div style={{ minWidth: 260 }}>
|
||||||
|
<CustomerPicker
|
||||||
|
value={p.customerId}
|
||||||
|
valueName={p.customerName}
|
||||||
|
onPick={(cid, name) => setPart(i, { customerId: cid, customerName: name })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
inputMode="decimal"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={p.amount}
|
||||||
|
onChange={(e) => setPart(i, { amount: e.target.value })}
|
||||||
|
/>
|
||||||
|
{parts.length > 2 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setParts((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => setParts((prev) => [...prev, { customerId: "", customerName: "", amount: "" }])}
|
||||||
|
>
|
||||||
|
Agregar cliente
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={evenly}>
|
||||||
|
Partes iguales
|
||||||
|
</button>
|
||||||
|
<span className={balanced ? "page-sub" : "state-error"}>
|
||||||
|
Suma {formatMoney(sum, "MXN")}
|
||||||
|
{!balanced && ` — diferencia ${formatMoney(total - sum, "MXN")}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy || !balanced || !complete}
|
||||||
|
onClick={() =>
|
||||||
|
save(parts.map((p) => ({ customerId: p.customerId, amount: Number(p.amount) })))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Guardar división
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => (doc.splits.length ? save([]) : setParts([]))}
|
||||||
|
>
|
||||||
|
Quitar división
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{err && <div className="state-box state-error">{err}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,8 @@ const NAV: NavEntry[] = [
|
|||||||
ability: "ledger:create",
|
ability: "ledger:create",
|
||||||
aliases: ["/recibos"],
|
aliases: ["/recibos"],
|
||||||
},
|
},
|
||||||
|
// The EFE cash receipt book (legacy EFE MENU → EFE).
|
||||||
|
{ href: "/efectivo", label: "Recibos de efectivo", ability: "ledger:create" },
|
||||||
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
{ href: "/estado-cuenta", label: "Estado de cuenta" },
|
||||||
{ href: "/banco", label: "Chequera" },
|
{ href: "/banco", label: "Chequera" },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export type FieldDef = {
|
|||||||
type?: "text" | "number" | "date" | "checkbox" | "select";
|
type?: "text" | "number" | "date" | "checkbox" | "select";
|
||||||
options?: { value: string; label: string }[];
|
options?: { value: string; label: string }[];
|
||||||
width?: number;
|
width?: number;
|
||||||
|
/** Numeric granularity. Defaults to money (0.01); a tax rate stored as a
|
||||||
|
* fraction needs finer, or the browser rejects 0.0825 as off-step. */
|
||||||
|
step?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ChildConfig = {
|
export type ChildConfig = {
|
||||||
@@ -158,7 +161,7 @@ export function ChildCollection({
|
|||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
||||||
step={f.type === "number" ? "0.01" : undefined}
|
step={f.type === "number" ? f.step ?? "0.01" : undefined}
|
||||||
value={String(values[f.key] ?? "")}
|
value={String(values[f.key] ?? "")}
|
||||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
|
import type { CustomerDetail, CustomerInput, Currency } from "@/lib/types";
|
||||||
import { createCustomer, updateCustomer } from "@/lib/api";
|
import { createCustomer, getNextFolderNumber, updateCustomer } from "@/lib/api";
|
||||||
|
|
||||||
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
|
/** ISO date (yyyy-mm-dd) for a date input, from an API date string. */
|
||||||
function toDateInput(v: string | null | undefined): string {
|
function toDateInput(v: string | null | undefined): string {
|
||||||
@@ -20,6 +20,7 @@ function numOrUndef(v: string | number | null | undefined): number | undefined {
|
|||||||
|
|
||||||
type Values = {
|
type Values = {
|
||||||
name: string;
|
name: string;
|
||||||
|
folderNumber: string;
|
||||||
addressLine1: string;
|
addressLine1: string;
|
||||||
addressLine2: string;
|
addressLine2: string;
|
||||||
city: string;
|
city: string;
|
||||||
@@ -45,6 +46,7 @@ type Values = {
|
|||||||
function initial(c?: CustomerDetail): Values {
|
function initial(c?: CustomerDetail): Values {
|
||||||
return {
|
return {
|
||||||
name: c?.name ?? "",
|
name: c?.name ?? "",
|
||||||
|
folderNumber: c?.folderNumber != null ? String(c.folderNumber) : "",
|
||||||
addressLine1: c?.addressLine1 ?? "",
|
addressLine1: c?.addressLine1 ?? "",
|
||||||
addressLine2: c?.addressLine2 ?? "",
|
addressLine2: c?.addressLine2 ?? "",
|
||||||
city: c?.city ?? "",
|
city: c?.city ?? "",
|
||||||
@@ -85,6 +87,17 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// A new customer is offered the lowest free folder, the way the office
|
||||||
|
// hands numbers back out; staff can type any other.
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) return;
|
||||||
|
getNextFolderNumber()
|
||||||
|
.then(({ folderNumber }) =>
|
||||||
|
setV((prev) => (prev.folderNumber ? prev : { ...prev, folderNumber: String(folderNumber) })),
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
function set<K extends keyof Values>(key: K, val: Values[K]) {
|
function set<K extends keyof Values>(key: K, val: Values[K]) {
|
||||||
setV((prev) => ({ ...prev, [key]: val }));
|
setV((prev) => ({ ...prev, [key]: val }));
|
||||||
}
|
}
|
||||||
@@ -95,6 +108,8 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const payload: CustomerInput = {
|
const payload: CustomerInput = {
|
||||||
name: v.name.trim(),
|
name: v.name.trim(),
|
||||||
|
// Blank clears it on edit; s() would leave the old number in place.
|
||||||
|
folderNumber: v.folderNumber.trim() === "" ? (editing ? null : undefined) : Number(v.folderNumber),
|
||||||
addressLine1: s(v.addressLine1),
|
addressLine1: s(v.addressLine1),
|
||||||
addressLine2: s(v.addressLine2),
|
addressLine2: s(v.addressLine2),
|
||||||
city: s(v.city),
|
city: s(v.city),
|
||||||
@@ -138,6 +153,10 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
|
|||||||
<input className="input" required value={v.name}
|
<input className="input" required value={v.name}
|
||||||
onChange={(e) => set("name", e.target.value)} />
|
onChange={(e) => set("name", e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Carpeta">
|
||||||
|
<input className="input" type="number" min={1} step={1} value={v.folderNumber}
|
||||||
|
onChange={(e) => set("folderNumber", e.target.value)} />
|
||||||
|
</Field>
|
||||||
<Field label="Correo">
|
<Field label="Correo">
|
||||||
<input className="input" type="email" value={v.email}
|
<input className="input" type="email" value={v.email}
|
||||||
onChange={(e) => set("email", e.target.value)} />
|
onChange={(e) => set("email", e.target.value)} />
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useCan } from "@/lib/abilities";
|
import { useCan } from "@/lib/abilities";
|
||||||
import { formatDate, formatMoney } from "@/lib/labels";
|
import { formatDate, formatMoney } from "@/lib/labels";
|
||||||
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
import { NotificationLogPanel } from "@/components/NotificationLogPanel";
|
||||||
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
import {
|
||||||
|
apiFetch,
|
||||||
|
getLookups,
|
||||||
|
POLIZAS_LOG_SCOPE,
|
||||||
|
type NotificationFlags,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type { ProviderRow } from "@/lib/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which
|
||||||
@@ -22,6 +28,12 @@ import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
|
|||||||
* thing here as it does for servicios: the mail is diverted to the override
|
* thing here as it does for servicios: the mail is diverted to the override
|
||||||
* inbox. It additionally does NOT mark the notice as sent, so a test send
|
* inbox. It additionally does NOT mark the notice as sent, so a test send
|
||||||
* leaves the row exactly where it was — pending.
|
* leaves the row exactly where it was — pending.
|
||||||
|
*
|
||||||
|
* The barrido manual is scoped by aseguradora because the office works GMX and
|
||||||
|
* ANA as separate batches. The selection filters the pending list too, so what
|
||||||
|
* is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped
|
||||||
|
* run deliberately does not advance the sweep's catch-up window — it only
|
||||||
|
* covered one carrier — so the other carriers' letters stay pending.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RenewalLetter {
|
export interface RenewalLetter {
|
||||||
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
|
|||||||
failed: number;
|
failed: number;
|
||||||
failures: { policyId: string; generation: number; error: string }[];
|
failures: { policyId: string; generation: number; error: string }[];
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
/** Echoed back so the confirmation says which carrier actually ran. */
|
||||||
|
providerId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenewalSendResult {
|
export interface RenewalSendResult {
|
||||||
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
const allowed = useCan("renewal:send");
|
const allowed = useCan("renewal:send");
|
||||||
const debug = !!flags.debug;
|
const debug = !!flags.debug;
|
||||||
const [days, setDays] = useState(30);
|
const [days, setDays] = useState(30);
|
||||||
|
/** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers
|
||||||
|
* are renamed in the lookups screen and the filter must survive that. */
|
||||||
|
const [providerId, setProviderId] = useState("");
|
||||||
|
const [providers, setProviders] = useState<ProviderRow[]>([]);
|
||||||
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
|
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
|
||||||
const [pendingError, setPendingError] = useState<string | null>(null);
|
const [pendingError, setPendingError] = useState<string | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
@@ -81,8 +99,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setPendingError(null);
|
setPendingError(null);
|
||||||
try {
|
try {
|
||||||
|
const params = new URLSearchParams({ days: String(days) });
|
||||||
|
if (providerId) params.set("providerId", providerId);
|
||||||
const data = await apiFetch<RenewalLetter[]>(
|
const data = await apiFetch<RenewalLetter[]>(
|
||||||
`/renewals/pending?days=${days}`,
|
`/renewals/pending?${params.toString()}`,
|
||||||
);
|
);
|
||||||
setPending(data);
|
setPending(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
);
|
);
|
||||||
setPending([]);
|
setPending([]);
|
||||||
}
|
}
|
||||||
}, [days]);
|
}, [days, providerId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowed) refresh();
|
if (allowed) refresh();
|
||||||
}, [allowed, refresh]);
|
}, [allowed, refresh]);
|
||||||
|
|
||||||
|
// Carriers come from the same lookups the policy form uses, so a new
|
||||||
|
// aseguradora shows up here without a code change.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!allowed) return;
|
||||||
|
let cancelled = false;
|
||||||
|
getLookups()
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setProviders(data.providers);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// A failed lookup only costs the filter; the unfiltered sweep still
|
||||||
|
// works, so this must not blank the screen.
|
||||||
|
if (!cancelled) setProviders([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [allowed]);
|
||||||
|
|
||||||
|
const providerLabel =
|
||||||
|
providers.find((item) => item.id === providerId)?.name ?? "todas las compañías";
|
||||||
|
|
||||||
async function handleSweep() {
|
async function handleSweep() {
|
||||||
// Only worth confirming when debug is off — that is the case where real
|
// Only worth confirming when debug is off — that is the case where real
|
||||||
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
|
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
|
||||||
|
// The carrier is named in the prompt: running GMX when ANA was meant is
|
||||||
|
// exactly the mistake this filter exists to prevent, and it is not
|
||||||
|
// reversible once the mail is out.
|
||||||
if (!debug) {
|
if (!debug) {
|
||||||
const ok = window.confirm(
|
const ok = window.confirm(
|
||||||
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?",
|
`debug está desactivado: los avisos irán a los correos reales de los clientes. ` +
|
||||||
|
`¿Ejecutar el barrido de ${providerLabel}?`,
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
try {
|
try {
|
||||||
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ debug }),
|
body: JSON.stringify({ debug, providerId: providerId || undefined }),
|
||||||
});
|
});
|
||||||
setNotice(
|
setNotice(
|
||||||
`Enviados ${result.sent} avisos (${result.failed} con error).` +
|
`Enviados ${result.sent} avisos de ${providerLabel} ` +
|
||||||
|
`(${result.failed} con error).` +
|
||||||
(result.debug
|
(result.debug
|
||||||
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
|
? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
|
||||||
: ""),
|
: ""),
|
||||||
@@ -199,7 +246,9 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
<h2 className="section-title">Barrido manual</h2>
|
<h2 className="section-title">Barrido manual</h2>
|
||||||
<p className="muted small" style={{ marginTop: 4 }}>
|
<p className="muted small" style={{ marginTop: 4 }}>
|
||||||
Usa la fecha actual del servidor como referencia para seleccionar
|
Usa la fecha actual del servidor como referencia para seleccionar
|
||||||
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
|
avisos vencidos a 30 y 15 días, y vencidos hace 7 días. La
|
||||||
|
compañía elegida filtra también la lista de abajo: se envía
|
||||||
|
exactamente lo que está en pantalla.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -211,18 +260,38 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
|
|||||||
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
{sweeping ? "Enviando…" : "Ejecutar barrido"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}>
|
<div
|
||||||
<span className="field-label">Ventana (días)</span>
|
className="row-actions"
|
||||||
<input
|
style={{ marginTop: 12, alignItems: "flex-end", gap: 16 }}
|
||||||
className="input"
|
>
|
||||||
type="number"
|
<div className="field" style={{ maxWidth: 180, marginBottom: 0 }}>
|
||||||
min={1}
|
<span className="field-label">Ventana (días)</span>
|
||||||
max={365}
|
<input
|
||||||
value={days}
|
className="input"
|
||||||
onChange={(e) =>
|
type="number"
|
||||||
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
min={1}
|
||||||
}
|
max={365}
|
||||||
/>
|
value={days}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ maxWidth: 260, marginBottom: 0 }}>
|
||||||
|
<span className="field-label">Compañía</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={providerId}
|
||||||
|
onChange={(e) => setProviderId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Todas las compañías</option>
|
||||||
|
{providers.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useCan } from "@/lib/abilities";
|
|||||||
* two ways in:
|
* two ways in:
|
||||||
*
|
*
|
||||||
* - **manual** — `PolicyForm` keys every field by hand.
|
* - **manual** — `PolicyForm` keys every field by hand.
|
||||||
* - **auto** — `PolicyOcrIntake` uploads a GMX PDF, OCR proposes the
|
* - **auto** — `PolicyOcrIntake` uploads a GMX or A.N.A. PDF, OCR proposes the
|
||||||
* policy, a human still confirms.
|
* policy, a human still confirms.
|
||||||
*
|
*
|
||||||
* Both end at the same place (a `Policy` row on a customer's file) so they
|
* Both end at the same place (a `Policy` row on a customer's file) so they
|
||||||
@@ -28,7 +28,7 @@ export type PolicyCaptureMode = "manual" | "auto";
|
|||||||
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
const MODE_HINT: Record<PolicyCaptureMode, string> = {
|
||||||
manual:
|
manual:
|
||||||
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
|
"Captura cada campo a mano. Use esta opción cuando la póliza llega en papel, en un correo sin PDF legible, o cuando hay que revisar cada dato.",
|
||||||
auto: "Suelte el PDF descargado del portal de GMX y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
auto: "Suelte el PDF descargado del portal de GMX o de A.N.A. y el sistema propondrá los campos. Nada se registra sin tu confirmación.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
export function PolicyCaptura({ initialMode = "manual" }: { initialMode?: PolicyCaptureMode }) {
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { useEffect, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||||
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
|
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
|
||||||
import type {
|
import {
|
||||||
Currency,
|
PAYMENT_FREQUENCY_LABELS,
|
||||||
LookupsResponse,
|
type Currency,
|
||||||
PolicyDetail,
|
type LookupsResponse,
|
||||||
PolicyInput,
|
type PaymentFrequency,
|
||||||
|
type PolicyDetail,
|
||||||
|
type PolicyInput,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import {
|
||||||
|
computeTax,
|
||||||
|
computeTotal,
|
||||||
|
formatRate,
|
||||||
|
resolveTaxRate,
|
||||||
|
surchargeApplies,
|
||||||
|
taxableBase,
|
||||||
|
} from "@/lib/premium";
|
||||||
|
|
||||||
function toDateInput(v: string | null | undefined): string {
|
function toDateInput(v: string | null | undefined): string {
|
||||||
if (!v) return "";
|
if (!v) return "";
|
||||||
@@ -36,9 +46,16 @@ type V = {
|
|||||||
policyFrom: string;
|
policyFrom: string;
|
||||||
policyTo: string;
|
policyTo: string;
|
||||||
netPremium: string;
|
netPremium: string;
|
||||||
|
surcharge: string;
|
||||||
policyFee: string;
|
policyFee: string;
|
||||||
brokerFee: string;
|
brokerFee: string;
|
||||||
commission: string;
|
commission: string;
|
||||||
|
/** Blank means "use the computed figure". Only ever holds a value once the
|
||||||
|
* operator overrides it, so a later change to prima neta keeps flowing
|
||||||
|
* through instead of being frozen by a value the form itself wrote. */
|
||||||
|
tax: string;
|
||||||
|
total: string;
|
||||||
|
paymentFrequency: PaymentFrequency | "";
|
||||||
currency: Currency;
|
currency: Currency;
|
||||||
liquidated: boolean;
|
liquidated: boolean;
|
||||||
liquidationNumber: string;
|
liquidationNumber: string;
|
||||||
@@ -58,9 +75,13 @@ function initial(p?: PolicyDetail): V {
|
|||||||
policyFrom: toDateInput(p?.policyFrom),
|
policyFrom: toDateInput(p?.policyFrom),
|
||||||
policyTo: toDateInput(p?.policyTo),
|
policyTo: toDateInput(p?.policyTo),
|
||||||
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
|
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
|
||||||
|
surcharge: p?.surcharge != null ? String(p.surcharge) : "",
|
||||||
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
|
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
|
||||||
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
|
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
|
||||||
commission: p?.commission != null ? String(p.commission) : "",
|
commission: p?.commission != null ? String(p.commission) : "",
|
||||||
|
tax: p?.tax != null ? String(p.tax) : "",
|
||||||
|
total: p?.total != null ? String(p.total) : "",
|
||||||
|
paymentFrequency: p?.paymentFrequency ?? "",
|
||||||
currency: (p?.currency as Currency) ?? "MXN",
|
currency: (p?.currency as Currency) ?? "MXN",
|
||||||
liquidated: p?.liquidated ?? false,
|
liquidated: p?.liquidated ?? false,
|
||||||
liquidationNumber: p?.liquidationNumber ?? "",
|
liquidationNumber: p?.liquidationNumber ?? "",
|
||||||
@@ -101,6 +122,27 @@ export function PolicyForm({
|
|||||||
setV((p) => ({ ...p, [k]: val }));
|
setV((p) => ({ ...p, [k]: val }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IVA and Total are the only two figures the form derives. Everything else,
|
||||||
|
// the recargo included, is keyed by hand — the carrier quotes the financing
|
||||||
|
// charge, we do not compute it.
|
||||||
|
const selectedType = lookups?.types.find((t) => t.id === v.policyTypeId);
|
||||||
|
const taxRate = resolveTaxRate(policy?.taxRate, selectedType?.taxRate);
|
||||||
|
const parts = {
|
||||||
|
netPremium: v.netPremium,
|
||||||
|
// A recargo on an annual policy is a data-entry mistake, so it is dropped
|
||||||
|
// from the arithmetic as well as disabled in the UI. Otherwise switching
|
||||||
|
// ANNUAL after typing one would leave it silently inflating the IVA.
|
||||||
|
surcharge: surchargeApplies(v.paymentFrequency || null) ? v.surcharge : "",
|
||||||
|
policyFee: v.policyFee,
|
||||||
|
};
|
||||||
|
const computedTax = computeTax(parts, taxRate);
|
||||||
|
const computedTotal = computeTotal(parts, taxRate);
|
||||||
|
// Blank field = take the computed figure. A typed one wins, so staff can key
|
||||||
|
// the carrier's rounding verbatim when it disagrees with ours by a centavo.
|
||||||
|
const effectiveTax = v.tax.trim() === "" ? computedTax : Number(v.tax);
|
||||||
|
const effectiveTotal = v.total.trim() === "" ? computedTotal : Number(v.total);
|
||||||
|
const showSurcharge = surchargeApplies(v.paymentFrequency || null);
|
||||||
|
|
||||||
async function submit(e: React.FormEvent) {
|
async function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!customerId) {
|
if (!customerId) {
|
||||||
@@ -118,9 +160,17 @@ export function PolicyForm({
|
|||||||
policyFrom: s(v.policyFrom),
|
policyFrom: s(v.policyFrom),
|
||||||
policyTo: s(v.policyTo),
|
policyTo: s(v.policyTo),
|
||||||
netPremium: numOrUndef(v.netPremium),
|
netPremium: numOrUndef(v.netPremium),
|
||||||
|
surcharge: showSurcharge ? numOrUndef(v.surcharge) : undefined,
|
||||||
policyFee: numOrUndef(v.policyFee),
|
policyFee: numOrUndef(v.policyFee),
|
||||||
brokerFee: numOrUndef(v.brokerFee),
|
brokerFee: numOrUndef(v.brokerFee),
|
||||||
commission: numOrUndef(v.commission),
|
commission: numOrUndef(v.commission),
|
||||||
|
// The derived figures are persisted, not recomputed on read: the printed
|
||||||
|
// policy is the record of truth and a later rate change must not silently
|
||||||
|
// restate what was issued. `taxRate` rides along for the same reason.
|
||||||
|
tax: Number.isFinite(effectiveTax) ? effectiveTax : undefined,
|
||||||
|
taxRate,
|
||||||
|
total: Number.isFinite(effectiveTotal) ? effectiveTotal : undefined,
|
||||||
|
paymentFrequency: v.paymentFrequency || undefined,
|
||||||
currency: v.currency,
|
currency: v.currency,
|
||||||
liquidated: v.liquidated,
|
liquidated: v.liquidated,
|
||||||
liquidationNumber: s(v.liquidationNumber),
|
liquidationNumber: s(v.liquidationNumber),
|
||||||
@@ -208,7 +258,7 @@ export function PolicyForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
|
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia</h2>
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">Emisión</span>
|
<span className="field-label">Emisión</span>
|
||||||
@@ -225,21 +275,84 @@ export function PolicyForm({
|
|||||||
<input className="input" type="date" value={v.policyTo}
|
<input className="input" type="date" value={v.policyTo}
|
||||||
onChange={(e) => set("policyTo", e.target.value)} />
|
onChange={(e) => set("policyTo", e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Forma de pago</span>
|
||||||
|
<select className="select" value={v.paymentFrequency}
|
||||||
|
onChange={(e) =>
|
||||||
|
set("paymentFrequency", e.target.value as PaymentFrequency | "")
|
||||||
|
}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{(
|
||||||
|
Object.keys(PAYMENT_FREQUENCY_LABELS) as PaymentFrequency[]
|
||||||
|
).map((f) => (
|
||||||
|
<option key={f} value={f}>{PAYMENT_FREQUENCY_LABELS[f]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||||
|
<h2 className="section-title" style={{ marginBottom: 4 }}>Primas</h2>
|
||||||
|
<p className="muted" style={{ fontSize: 12, marginBottom: 14 }}>
|
||||||
|
IVA y prima total se calculan solos sobre (prima neta + recargo +
|
||||||
|
derecho de póliza). Puede sobrescribirlos si la póliza impresa
|
||||||
|
redondea distinto.
|
||||||
|
</p>
|
||||||
|
<div className="form-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">Prima neta</span>
|
<span className="field-label">Prima neta</span>
|
||||||
<input className="input" type="number" step="0.01" value={v.netPremium}
|
<input className="input" type="number" step="0.01" value={v.netPremium}
|
||||||
onChange={(e) => set("netPremium", e.target.value)} />
|
onChange={(e) => set("netPremium", e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Recargo</span>
|
||||||
|
<input className="input" type="number" step="0.01" value={v.surcharge}
|
||||||
|
disabled={!showSurcharge}
|
||||||
|
onChange={(e) => set("surcharge", e.target.value)} />
|
||||||
|
<span className="field-hint">
|
||||||
|
{showSurcharge
|
||||||
|
? "Lo cotiza la aseguradora — se captura a mano."
|
||||||
|
: "No aplica en pago anual ni de contado."}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">Derecho de póliza</span>
|
<span className="field-label">Derecho de póliza</span>
|
||||||
<input className="input" type="number" step="0.01" value={v.policyFee}
|
<input className="input" type="number" step="0.01" value={v.policyFee}
|
||||||
onChange={(e) => set("policyFee", e.target.value)} />
|
onChange={(e) => set("policyFee", e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">IVA ({formatRate(taxRate)})</span>
|
||||||
|
<input className="input" type="number" step="0.01"
|
||||||
|
placeholder={computedTax.toFixed(2)} value={v.tax}
|
||||||
|
onChange={(e) => set("tax", e.target.value)} />
|
||||||
|
<span className="field-hint">
|
||||||
|
Calculado: {computedTax.toFixed(2)} sobre base{" "}
|
||||||
|
{taxableBase(parts).toFixed(2)}
|
||||||
|
{selectedType?.taxRate == null &&
|
||||||
|
policy?.taxRate == null &&
|
||||||
|
" · tasa por omisión, configúrela en Catálogos"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Prima total</span>
|
||||||
|
<input className="input" type="number" step="0.01"
|
||||||
|
placeholder={computedTotal.toFixed(2)} value={v.total}
|
||||||
|
onChange={(e) => set("total", e.target.value)} />
|
||||||
|
<span className="field-hint">
|
||||||
|
Calculado: {computedTotal.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span className="field-label">Comisión</span>
|
<span className="field-label">Comisión</span>
|
||||||
<input className="input" type="number" step="0.01" value={v.commission}
|
<input className="input" type="number" step="0.01" value={v.commission}
|
||||||
onChange={(e) => set("commission", e.target.value)} />
|
onChange={(e) => set("commission", e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span className="field-label">Honorarios</span>
|
||||||
|
<input className="input" type="number" step="0.01" value={v.brokerFee}
|
||||||
|
onChange={(e) => set("brokerFee", e.target.value)} />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ import type { PolicyOcrBatch, PolicyOcrBatchStatus } from "@/lib/types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
* Insurance OCR intake — mirror of StatementIntake, scoped to the insurance
|
||||||
* side. Today the only provider is GMX; the parser dispatches on a brand
|
* side. GMX and A.N.A. today; the parser dispatches on a brand wordmark
|
||||||
* wordmark (`Grupo Mexicano de Seguros` / `gmx.com.mx` / the GMX letterhead)
|
* (`Grupo Mexicano de Seguros` / `gmx.com.mx`, `A.N.A. Compañía de Seguros` /
|
||||||
* and a new portal only needs a new BRAND entry plus a parser file.
|
* `anaseguros.com.mx`) and a new portal only needs a new BRAND entry plus a
|
||||||
|
* parser file. The uploader is never asked which provider a file came from —
|
||||||
|
* a batch may mix them, and the pipeline labels the batch from what the
|
||||||
|
* parsers actually claimed.
|
||||||
*
|
*
|
||||||
* Lives inside the `Pólizas` page rather than a top-level route because it
|
* Lives inside the `Pólizas` page rather than a top-level route because it
|
||||||
* is one mode of one job (staff uploading whatever PDFs the office has on
|
* is one mode of one job (staff uploading whatever PDFs the office has on
|
||||||
@@ -102,8 +105,8 @@ export function PolicyOcrIntake() {
|
|||||||
<div className="state-box">Cargando…</div>
|
<div className="state-box">Cargando…</div>
|
||||||
) : batches.length === 0 ? (
|
) : batches.length === 0 ? (
|
||||||
<div className="state-box">
|
<div className="state-box">
|
||||||
Todavía no hay lotes de pólizas. Descargue el certificado del portal
|
Todavía no hay lotes de pólizas. Descargue la póliza del portal de
|
||||||
de GMX y suéltelo arriba.
|
GMX o de A.N.A. y suéltela arriba.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="tx-scroll">
|
<div className="tx-scroll">
|
||||||
@@ -183,14 +186,14 @@ function UploadCard({ onDone }: { onDone: () => void }) {
|
|||||||
return (
|
return (
|
||||||
<section className="card" style={{ padding: 16 }}>
|
<section className="card" style={{ padding: 16 }}>
|
||||||
<h2 className="section-title" style={{ marginTop: 0 }}>
|
<h2 className="section-title" style={{ marginTop: 0 }}>
|
||||||
Subir PDFs de pólizas (GMX)
|
Subir PDFs de pólizas (GMX / A.N.A.)
|
||||||
</h2>
|
</h2>
|
||||||
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
<div className="inline-form" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||||
<label>
|
<label>
|
||||||
<span className="page-sub">Referencia (opcional)</span>
|
<span className="page-sub">Referencia (opcional)</span>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
placeholder="ej. GMX julio 2026"
|
placeholder="ej. ANA agosto 2026"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
PolicyOcrBatchDetail,
|
PolicyOcrBatchDetail,
|
||||||
PolicyOcrConfirmDocument,
|
PolicyOcrConfirmDocument,
|
||||||
PolicyOcrCoverage,
|
PolicyOcrCoverage,
|
||||||
|
PolicyOcrCustomerSuggestion,
|
||||||
PolicyOcrDocument,
|
PolicyOcrDocument,
|
||||||
PolicyOcrReviewInput,
|
PolicyOcrReviewInput,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
@@ -180,8 +181,11 @@ export function PolicyOcrReview({ id }: { id: string }) {
|
|||||||
{STATUS_LABEL[batch.status] ?? batch.status}
|
{STATUS_LABEL[batch.status] ?? batch.status}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="btn btn-ghost" href="/polizas">
|
{/* Back to the capture screen this batch was uploaded from, not to
|
||||||
Volver a pólizas
|
the policy list — same as the statement review screen, which
|
||||||
|
returns to /recibos. */}
|
||||||
|
<Link className="btn btn-ghost" href="/polizas/captura">
|
||||||
|
Volver a captura
|
||||||
</Link>
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -272,8 +276,11 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
|
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
|
||||||
currency: doc.extractedCurrency ?? "USD",
|
currency: doc.extractedCurrency ?? "USD",
|
||||||
netPremium: doc.extractedNetPremium ?? "",
|
netPremium: doc.extractedNetPremium ?? "",
|
||||||
|
policyFee: doc.extractedPolicyFee ?? "",
|
||||||
|
tax: doc.extractedTax ?? "",
|
||||||
total: doc.extractedTotal ?? "",
|
total: doc.extractedTotal ?? "",
|
||||||
premiumPayment: doc.extractedPremiumPayment ?? "",
|
premiumPayment: doc.extractedPremiumPayment ?? "",
|
||||||
|
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
|
||||||
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
postPremium: doc.extractedNetPremium != null && Number(doc.extractedNetPremium) > 0,
|
||||||
});
|
});
|
||||||
const [customerId, setCustomerId] = useState(
|
const [customerId, setCustomerId] = useState(
|
||||||
@@ -309,8 +316,11 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
policyDate: v.policyDate || undefined,
|
policyDate: v.policyDate || undefined,
|
||||||
currency,
|
currency,
|
||||||
netPremium: numOrUndef(v.netPremium),
|
netPremium: numOrUndef(v.netPremium),
|
||||||
|
policyFee: numOrUndef(v.policyFee),
|
||||||
|
tax: numOrUndef(v.tax),
|
||||||
total: numOrUndef(v.total),
|
total: numOrUndef(v.total),
|
||||||
premiumPayment: trimOrUndef(v.premiumPayment),
|
premiumPayment: trimOrUndef(v.premiumPayment),
|
||||||
|
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
|
||||||
matchedPolicyId: policyId || undefined,
|
matchedPolicyId: policyId || undefined,
|
||||||
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
matchedCustomerId: !policyId && customerId ? customerId : undefined,
|
||||||
forceConfirm: true,
|
forceConfirm: true,
|
||||||
@@ -330,8 +340,11 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
policyDate: reviewInput.policyDate,
|
policyDate: reviewInput.policyDate,
|
||||||
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
|
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
|
||||||
netPremium: reviewInput.netPremium,
|
netPremium: reviewInput.netPremium,
|
||||||
|
policyFee: reviewInput.policyFee,
|
||||||
|
tax: reviewInput.tax,
|
||||||
total: reviewInput.total,
|
total: reviewInput.total,
|
||||||
premiumPayment: reviewInput.premiumPayment,
|
premiumPayment: reviewInput.premiumPayment,
|
||||||
|
coveragePeriodDays: reviewInput.coveragePeriodDays,
|
||||||
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
coveragesJson: (doc.extractedCoveragesJson ?? undefined) as
|
||||||
| PolicyOcrCoverage[]
|
| PolicyOcrCoverage[]
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -348,18 +361,27 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
|
const locked = doc.status === "POSTED" || doc.status === "REJECTED";
|
||||||
const matchedExisting = !!doc.matchedPolicy;
|
const matchedExisting = !!doc.matchedPolicy;
|
||||||
const candidates = doc.matchCandidates ?? [];
|
const candidates = doc.matchCandidates ?? [];
|
||||||
|
const suggestions: PolicyOcrCustomerSuggestion[] = doc.customerSuggestions ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="card" style={{ padding: 16 }}>
|
<article className="card" style={{ padding: 16 }}>
|
||||||
<header className="row" style={{ gap: 12, alignItems: "center" }}>
|
<header className="row" style={{ gap: 12, alignItems: "center" }}>
|
||||||
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
|
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
|
||||||
<span className="page-sub">Página {doc.pageNumber}</span>
|
<span className="page-sub">Página {doc.pageNumber}</span>
|
||||||
{doc.extractedPolicyNumber && (
|
{/* No hand-rolled separators or margins here: `.row` is a flex
|
||||||
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong>
|
container and its gap does the spacing. A literal "· " would leave
|
||||||
)}
|
a dot floating in that gap. */}
|
||||||
|
{doc.extractedPolicyNumber && <strong>{doc.extractedPolicyNumber}</strong>}
|
||||||
{doc.extractedInsuredName && (
|
{doc.extractedInsuredName && (
|
||||||
<span className="page-sub">· {doc.extractedInsuredName}</span>
|
<span className="page-sub">{doc.extractedInsuredName}</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Read-only: the parser names the type, the confirm step resolves it
|
||||||
|
to a policy_types row. Reassigning it is the policy screen's job,
|
||||||
|
where the full picker already lives. */}
|
||||||
|
{doc.extractedPolicyTypeName && (
|
||||||
|
<span className="tag">{doc.extractedPolicyTypeName}</span>
|
||||||
|
)}
|
||||||
|
{doc.provider && <span className="tag">{doc.provider}</span>}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="doc-detail">
|
<div className="doc-detail">
|
||||||
@@ -454,6 +476,17 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
onChange={(e) => set("policyDate", e.target.value)}
|
onChange={(e) => set("policyDate", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
{/* ANA sells 3- and 4-day tourist policies; left blank the
|
||||||
|
póliza keeps the 365-day default. */}
|
||||||
|
<Field label="Días de vigencia">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={v.coveragePeriodDays}
|
||||||
|
onChange={(e) => set("coveragePeriodDays", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
<Field label="Moneda">
|
<Field label="Moneda">
|
||||||
<select
|
<select
|
||||||
className="input select"
|
className="input select"
|
||||||
@@ -474,7 +507,25 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
onChange={(e) => set("netPremium", e.target.value)}
|
onChange={(e) => set("netPremium", e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Total">
|
<Field label="Derecho de póliza">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={v.policyFee}
|
||||||
|
onChange={(e) => set("policyFee", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="IVA">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={v.tax}
|
||||||
|
onChange={(e) => set("tax", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Prima total">
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -523,6 +574,7 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Riesgo</th>
|
<th>Riesgo</th>
|
||||||
<th className="num">Suma</th>
|
<th className="num">Suma</th>
|
||||||
|
<th className="num">Prima</th>
|
||||||
<th>Deducible</th>
|
<th>Deducible</th>
|
||||||
<th>Participación</th>
|
<th>Participación</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -534,6 +586,14 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
<td className="num">
|
<td className="num">
|
||||||
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
{formatMoney(c.insuredAmount?.toString() ?? null, v.currency)}
|
||||||
</td>
|
</td>
|
||||||
|
{/* ANA's add-on sections print what the coverage COST
|
||||||
|
where the others print what it pays. Kept in its own
|
||||||
|
column so the two are never added together. */}
|
||||||
|
<td className="num">
|
||||||
|
{c.premium == null
|
||||||
|
? "—"
|
||||||
|
: formatMoney(c.premium.toString(), v.currency)}
|
||||||
|
</td>
|
||||||
<td>{c.deductible ?? "—"}</td>
|
<td>{c.deductible ?? "—"}</td>
|
||||||
<td>{c.lossParticipation ?? "—"}</td>
|
<td>{c.lossParticipation ?? "—"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -543,6 +603,66 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Vehicles and named drivers are read-only here: they are written
|
||||||
|
* as their own Vehicle / InsuredDriver rows on confirm, and the
|
||||||
|
* policy screen is where they get edited. Showing them is what
|
||||||
|
* lets a reviewer catch a misread VIN before it is applied.
|
||||||
|
*/}
|
||||||
|
{doc.extractedVehiclesJson && doc.extractedVehiclesJson.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary>Unidades ({doc.extractedVehiclesJson.length})</summary>
|
||||||
|
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Año</th>
|
||||||
|
<th>Marca</th>
|
||||||
|
<th>Carrocería</th>
|
||||||
|
<th>Serie</th>
|
||||||
|
<th>Placas</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{doc.extractedVehiclesJson.map((veh, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{veh.item}</td>
|
||||||
|
<td>{veh.modelYear ?? "—"}</td>
|
||||||
|
<td>{veh.make ?? "—"}</td>
|
||||||
|
<td>{veh.bodyType ?? "—"}</td>
|
||||||
|
<td>{veh.vinNumber ?? "—"}</td>
|
||||||
|
<td>{veh.licensePlate ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{doc.extractedDriversJson && doc.extractedDriversJson.length > 0 && (
|
||||||
|
<details>
|
||||||
|
<summary>Conductores ({doc.extractedDriversJson.length})</summary>
|
||||||
|
<table className="tx-table" style={{ marginTop: 8 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Licencia</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{doc.extractedDriversJson.map((d, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>{d.fullName}</td>
|
||||||
|
<td>{d.licenseNumber ?? "—"}</td>
|
||||||
|
<td>{d.phone ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
{candidates.length > 1 && (
|
{candidates.length > 1 && (
|
||||||
<Field label="Póliza destino">
|
<Field label="Póliza destino">
|
||||||
<select
|
<select
|
||||||
@@ -580,6 +700,34 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
* Name suggestions, never a preselection. The office writes
|
||||||
|
* customers surname-first and carriers print them given-name-first,
|
||||||
|
* so without this the reviewer retypes a name the parser already
|
||||||
|
* read. One click fills the picker above; nothing is chosen until
|
||||||
|
* they click. Kept outside the <Field> label — a label must not
|
||||||
|
* wrap other interactive controls.
|
||||||
|
*/}
|
||||||
|
{!policyId && !customerId && !locked && canReview && suggestions.length > 0 && (
|
||||||
|
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<span className="page-sub">Sugerencias por nombre:</span>
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.customerId}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCustomerId(s.customerId);
|
||||||
|
setCustomerName(s.customerName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.customerName}
|
||||||
|
{s.tier === "PARTIAL" && <span className="page-sub"> · parcial</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -390,6 +390,9 @@ function StatementLayout({ result }: { result: ReportRunResult }) {
|
|||||||
<div className="statement">
|
<div className="statement">
|
||||||
<header className="statement-head">
|
<header className="statement-head">
|
||||||
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
<h2 className="statement-name">{String(header.name ?? "—")}</h2>
|
||||||
|
{header.folder && header.folder !== "—" ? (
|
||||||
|
<p className="muted">Carpeta {String(header.folder)}</p>
|
||||||
|
) : null}
|
||||||
{Boolean(header.address) && (
|
{Boolean(header.address) && (
|
||||||
<p className="muted">{String(header.address)}</p>
|
<p className="muted">{String(header.address)}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+136
-14
@@ -31,6 +31,7 @@ import type {
|
|||||||
UpdateBankAccountInput,
|
UpdateBankAccountInput,
|
||||||
ResolveOutstandingInput,
|
ResolveOutstandingInput,
|
||||||
ReviewDocumentInput,
|
ReviewDocumentInput,
|
||||||
|
SplitPartInput,
|
||||||
StatementBatch,
|
StatementBatch,
|
||||||
StatementBatchDetail,
|
StatementBatchDetail,
|
||||||
StatementDocument,
|
StatementDocument,
|
||||||
@@ -60,6 +61,7 @@ import type {
|
|||||||
OpsJob,
|
OpsJob,
|
||||||
OpsJobKind,
|
OpsJobKind,
|
||||||
ReplicationStatus,
|
ReplicationStatus,
|
||||||
|
VerifyResult,
|
||||||
IngestFile,
|
IngestFile,
|
||||||
BackupFile,
|
BackupFile,
|
||||||
PropertyDetail,
|
PropertyDetail,
|
||||||
@@ -79,17 +81,33 @@ import type {
|
|||||||
TransactionDomain,
|
TransactionDomain,
|
||||||
TrustFilter,
|
TrustFilter,
|
||||||
UserRow,
|
UserRow,
|
||||||
|
CashReceipt,
|
||||||
|
CashReceiptInput,
|
||||||
|
CompanyHeader,
|
||||||
|
OnboardInput,
|
||||||
|
OnboardResult,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
// Resolve the API origin at runtime, not build time. In the browser it comes
|
// Resolve the API origin at runtime, not build time — so one built image serves
|
||||||
// from window.__API_ORIGIN__, injected server-side by the root layout from the
|
// any deployment and the app follows the box when it moves (tailnet today,
|
||||||
// deploy .env (API_ORIGIN) — so one built image serves any deployment. On the
|
// 192.168.1.x office LAN later) with no config change.
|
||||||
// server (SSR) read process.env directly. NEXT_PUBLIC_API_ORIGIN stays as the
|
//
|
||||||
// dev/build fallback.
|
// In the browser, derive the origin from the page's own location, the way a PHP
|
||||||
|
// app would. An explicit API_ORIGIN (injected as window.__API_ORIGIN__ by the
|
||||||
|
// root layout) still wins when a deployment genuinely splits the two hosts.
|
||||||
|
// On the server (SSR) read process.env directly — a derived origin is
|
||||||
|
// browser-only, and "/api" is not fetchable server-side.
|
||||||
function resolveApiOrigin(): string {
|
function resolveApiOrigin(): string {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
|
const injected = (window as { __API_ORIGIN__?: string }).__API_ORIGIN__;
|
||||||
if (injected) return injected;
|
if (injected) return injected;
|
||||||
|
const { protocol, hostname } = window.location;
|
||||||
|
// Over TLS the API must share the page's origin or the browser blocks the
|
||||||
|
// call as mixed active content. The reverse proxy maps /api to the API.
|
||||||
|
if (protocol === "https:") return "/api";
|
||||||
|
// Plain HTTP: same host, API port. 3001 is the port the API container
|
||||||
|
// publishes everywhere (deploy/galactus/jorgecuadros-app.compose.yml).
|
||||||
|
return `http://${hostname}:3001`;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
process.env.API_ORIGIN ??
|
process.env.API_ORIGIN ??
|
||||||
@@ -203,10 +221,23 @@ export function listCustomers(
|
|||||||
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lowest folder number no open account holds — the suggestion for a new customer. */
|
||||||
|
export function getNextFolderNumber(): Promise<{ folderNumber: number }> {
|
||||||
|
return apiFetch<{ folderNumber: number }>("/customers/folder/next");
|
||||||
|
}
|
||||||
|
|
||||||
export function getCustomer(id: string): Promise<CustomerDetail> {
|
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||||
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open an account in one step; see OnboardInput. */
|
||||||
|
export function onboardCustomer(input: OnboardInput): Promise<OnboardResult> {
|
||||||
|
return apiFetch<OnboardResult>("/customers/onboard", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
export function createCustomer(input: CustomerInput): Promise<CustomerDetail> {
|
||||||
return apiFetch<CustomerDetail>("/customers", {
|
return apiFetch<CustomerDetail>("/customers", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -232,6 +263,20 @@ export function restoreCustomer(id: string): Promise<CustomerDetail> {
|
|||||||
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
return apiFetch<CustomerDetail>(`/customers/${id}/restore`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NumidAllocation {
|
||||||
|
numid: string;
|
||||||
|
/** "existing" when the customer already had one — the call is idempotent. */
|
||||||
|
origin: "existing" | "new" | "recycled";
|
||||||
|
previousCustomerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Give a customer the portal NUMid they log in to my.jorgecuadros.com with. */
|
||||||
|
export function grantPortalAccess(id: string): Promise<NumidAllocation> {
|
||||||
|
return apiFetch<NumidAllocation>(`/customers/${id}/portal-access`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------ Policies module */
|
/* ------------------------------------------------------ Policies module */
|
||||||
|
|
||||||
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
/** Renewal horizon in days, shared by the list, stats and detail calls so the
|
||||||
@@ -589,8 +634,13 @@ export function getBillingFacets(): Promise<BillingFacets> {
|
|||||||
return apiFetch<BillingFacets>("/billing/facets");
|
return apiFetch<BillingFacets>("/billing/facets");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStatement(customerId: string): Promise<Statement> {
|
/** `year` omitted reads the current period; earlier years come from an archive. */
|
||||||
return apiFetch<Statement>(`/billing/customers/${customerId}`);
|
export function getStatement(
|
||||||
|
customerId: string,
|
||||||
|
year?: number,
|
||||||
|
): Promise<Statement> {
|
||||||
|
const q = year === undefined ? "" : `?year=${year}`;
|
||||||
|
return apiFetch<Statement>(`/billing/customers/${customerId}${q}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append a new ledger movement. Booked movements are never edited — fix
|
/** Append a new ledger movement. Booked movements are never edited — fix
|
||||||
@@ -950,6 +1000,18 @@ export function getReplicationStatus(): Promise<ReplicationStatus> {
|
|||||||
return apiFetch<ReplicationStatus>("/ops/replication");
|
return apiFetch<ReplicationStatus>("/ops/replication");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare every customer-visible table against the master, row by row.
|
||||||
|
*
|
||||||
|
* Slow by nature — it is a full scan of both servers — so it is a button, not
|
||||||
|
* part of the poll. Answers the question replication status cannot: GTIDs prove
|
||||||
|
* the replica applied everything the master sent, not that nothing else changed
|
||||||
|
* the rows here.
|
||||||
|
*/
|
||||||
|
export function verifyReplication(): Promise<VerifyResult> {
|
||||||
|
return apiFetch<VerifyResult>("/ops/replication/verify", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
export function listOpsJobs(): Promise<OpsJob[]> {
|
export function listOpsJobs(): Promise<OpsJob[]> {
|
||||||
return apiFetch<OpsJob[]>("/ops/jobs");
|
return apiFetch<OpsJob[]>("/ops/jobs");
|
||||||
}
|
}
|
||||||
@@ -959,10 +1021,16 @@ export function getOpsJob(id: string): Promise<OpsJob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
/** Start a mutating op. `file` is required for RESTORE. 409 if one is running. */
|
||||||
export function startOpsJob(kind: OpsJobKind, file?: string): Promise<OpsJob> {
|
/** `forceFull` applies to REIMPORT only: proceed even though the rebuild
|
||||||
|
* deletes rows that exist only in the platform. */
|
||||||
|
export function startOpsJob(
|
||||||
|
kind: OpsJobKind,
|
||||||
|
file?: string,
|
||||||
|
forceFull?: boolean,
|
||||||
|
): Promise<OpsJob> {
|
||||||
return apiFetch<OpsJob>("/ops/jobs", {
|
return apiFetch<OpsJob>("/ops/jobs", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ kind, file }),
|
body: JSON.stringify({ kind, file, forceFull }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,17 +1394,26 @@ export function listStatementDocuments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Multi-file upload — one batch is usually several multi-page scans. */
|
/** Multi-file upload — one batch is usually several multi-page scans. */
|
||||||
export async function uploadStatementBatch(
|
export function uploadStatementBatch(
|
||||||
files: File[],
|
files: File[],
|
||||||
serviceKind: ServiceKind,
|
serviceKind: ServiceKind,
|
||||||
label?: string,
|
label?: string,
|
||||||
): Promise<StatementBatch> {
|
): Promise<StatementBatch> {
|
||||||
const body = new FormData();
|
|
||||||
for (const f of files) body.append("files", f, f.name);
|
|
||||||
const qs = new URLSearchParams({ serviceKind });
|
const qs = new URLSearchParams({ serviceKind });
|
||||||
if (label) qs.set("label", label);
|
if (label) qs.set("label", label);
|
||||||
|
return postStatementFiles(`/statements/batches?${qs}`, files);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${API_ORIGIN}/statements/batches?${qs}`, {
|
/** More scans for a batch kept open for one check. */
|
||||||
|
export function addStatementFiles(batchId: string, files: File[]): Promise<StatementBatch> {
|
||||||
|
return postStatementFiles(`/statements/batches/${batchId}/files`, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postStatementFiles(path: string, files: File[]): Promise<StatementBatch> {
|
||||||
|
const body = new FormData();
|
||||||
|
for (const f of files) body.append("files", f, f.name);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_ORIGIN}${path}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body,
|
body,
|
||||||
@@ -1364,6 +1441,17 @@ export function reviewStatementDocument(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split a receipt across customers; an empty list undoes the split. */
|
||||||
|
export function setStatementSplits(
|
||||||
|
id: string,
|
||||||
|
parts: SplitPartInput[],
|
||||||
|
): Promise<StatementDocument> {
|
||||||
|
return apiFetch(`/statements/documents/${id}/splits`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ parts }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
|
export function rejectStatementDocument(id: string): Promise<StatementDocument> {
|
||||||
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
return apiFetch(`/statements/documents/${id}/reject`, { method: "POST" });
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1476,41 @@ export function statementPageUrl(documentId: string): string {
|
|||||||
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
return `${API_ORIGIN}/statements/documents/${documentId}/page`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------- Policy OCR (GMX) */
|
/* ------------------------------------------------ EFE cash receipt book */
|
||||||
|
|
||||||
|
export function getNextCashFolio(): Promise<{ folio: number }> {
|
||||||
|
return apiFetch("/cash-receipts/next-folio");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCashReceipts(q: { query?: string; page?: number } = {}): Promise<{
|
||||||
|
items: CashReceipt[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
}> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q.query) params.set("query", q.query);
|
||||||
|
if (q.page) params.set("page", String(q.page));
|
||||||
|
return apiFetch(`/cash-receipts?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCashReceipt(folio: number): Promise<CashReceipt & { company: CompanyHeader }> {
|
||||||
|
return apiFetch(`/cash-receipts/${folio}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCashReceipt(input: CashReceiptInput): Promise<CashReceipt> {
|
||||||
|
return apiFetch("/cash-receipts", { method: "POST", body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCashReceiptDeposit(id: string, depositedAt?: string): Promise<CashReceipt> {
|
||||||
|
return apiFetch(`/cash-receipts/${id}/deposit`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ depositedAt }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------- Policy OCR (GMX / ANA) */
|
||||||
|
|
||||||
export function getPolicyOcrStatus(): Promise<{
|
export function getPolicyOcrStatus(): Promise<{
|
||||||
ocrAvailable: boolean;
|
ocrAvailable: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { PaymentFrequency } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side twin of apps/api/src/policies/premium.ts. Duplicated rather than
|
||||||
|
* shared because the API and the web app do not share a package today, and
|
||||||
|
* both need it: the form computes IVA and Total live as the operator types,
|
||||||
|
* the API stores what it is sent.
|
||||||
|
*
|
||||||
|
* base = prima neta + recargo + derecho de póliza
|
||||||
|
* IVA = round(base * tasa)
|
||||||
|
* Total = base + IVA
|
||||||
|
*
|
||||||
|
* The recargo is inside the taxable base — that is what reconciles the Access
|
||||||
|
* books (policy 7006785 prints IVA 52.03 on 610.86 + 8.55 + 31.00; leaving the
|
||||||
|
* recargo out gives 51.35, which matches nothing on the page).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Applied when neither the policy nor its type carries a rate. The single
|
||||||
|
* row both legacy IMPUESTOS tables held. */
|
||||||
|
export const DEFAULT_TAX_RATE = 0.08;
|
||||||
|
|
||||||
|
/** Paying in more than one exhibición is what earns a recargo. A null
|
||||||
|
* frequency (every migrated policy) is treated as "unknown, allow it": the
|
||||||
|
* legacy recargo figures are real and hiding the field would hide them. */
|
||||||
|
export function surchargeApplies(
|
||||||
|
frequency: PaymentFrequency | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return frequency !== "ANNUAL" && frequency !== "SINGLE";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function num(v: string | number | null | undefined): number {
|
||||||
|
if (v === null || v === undefined || v === "") return 0;
|
||||||
|
const n = typeof v === "number" ? v : Number(String(v).trim());
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Half-up to cents, matching how the printed policy rounds. */
|
||||||
|
export function round2(n: number): number {
|
||||||
|
return Math.round((n + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PremiumParts {
|
||||||
|
netPremium: string | number | null | undefined;
|
||||||
|
surcharge: string | number | null | undefined;
|
||||||
|
policyFee: string | number | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function taxableBase(p: PremiumParts): number {
|
||||||
|
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTax(p: PremiumParts, rate: number): number {
|
||||||
|
return round2(taxableBase(p) * rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeTotal(p: PremiumParts, rate: number): number {
|
||||||
|
return round2(taxableBase(p) + computeTax(p, rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rate ladder: what the policy was issued at, else its line of business, else
|
||||||
|
* the default. Keeps an old policy reading back at its original rate after
|
||||||
|
* somebody edits the catalog. */
|
||||||
|
export function resolveTaxRate(
|
||||||
|
policyRate: string | number | null | undefined,
|
||||||
|
policyTypeRate: string | number | null | undefined,
|
||||||
|
): number {
|
||||||
|
for (const candidate of [policyRate, policyTypeRate]) {
|
||||||
|
if (candidate === null || candidate === undefined || candidate === "") continue;
|
||||||
|
const n = Number(candidate);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return DEFAULT_TAX_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0.08 -> "8%". Rates are stored as fractions but read as percentages. */
|
||||||
|
export function formatRate(rate: number): string {
|
||||||
|
const pct = round2(rate * 100);
|
||||||
|
return `${pct}%`;
|
||||||
|
}
|
||||||
+247
-1
@@ -3,12 +3,31 @@
|
|||||||
|
|
||||||
export type Currency = "USD" | "MXN";
|
export type Currency = "USD" | "MXN";
|
||||||
|
|
||||||
|
/** How the premium is split into payments. Anything other than ANNUAL/SINGLE
|
||||||
|
* is what earns a recargo. Null on every migrated policy — the original ETL
|
||||||
|
* dropped Access's FORMA PAGO column entirely. */
|
||||||
|
export type PaymentFrequency =
|
||||||
|
| "ANNUAL"
|
||||||
|
| "SEMIANNUAL"
|
||||||
|
| "QUARTERLY"
|
||||||
|
| "MONTHLY"
|
||||||
|
| "SINGLE";
|
||||||
|
|
||||||
|
export const PAYMENT_FREQUENCY_LABELS: Record<PaymentFrequency, string> = {
|
||||||
|
ANNUAL: "Anual",
|
||||||
|
SEMIANNUAL: "Semestral",
|
||||||
|
QUARTERLY: "Trimestral",
|
||||||
|
MONTHLY: "Mensual",
|
||||||
|
SINGLE: "Contado",
|
||||||
|
};
|
||||||
|
|
||||||
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||||
|
|
||||||
export type Ability =
|
export type Ability =
|
||||||
| "customer:create"
|
| "customer:create"
|
||||||
| "customer:update"
|
| "customer:update"
|
||||||
| "customer:delete"
|
| "customer:delete"
|
||||||
|
| "customer:portal-access"
|
||||||
| "policy:create"
|
| "policy:create"
|
||||||
| "policy:update"
|
| "policy:update"
|
||||||
| "policy:delete"
|
| "policy:delete"
|
||||||
@@ -100,10 +119,52 @@ export interface ReplicationStatus {
|
|||||||
lastSqlError: string | null;
|
lastSqlError: string | null;
|
||||||
sourceHost: string | null;
|
sourceHost: string | null;
|
||||||
apply: ApplyProgress | null;
|
apply: ApplyProgress | null;
|
||||||
|
drift: GtidDrift | null;
|
||||||
problem: string | null;
|
problem: string | null;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executed-history gap between master and replica, in transactions.
|
||||||
|
*
|
||||||
|
* The only field on the card that is not self-reported by the replica, and the
|
||||||
|
* only one that catches a silently disconnected I/O thread: with no incoming
|
||||||
|
* events, `secondsBehind` reads 0 because there is nothing to measure staleness
|
||||||
|
* against, so a dead link looks perfectly current. This number grows instead.
|
||||||
|
*
|
||||||
|
* Null when the master could not be reached — "unknown" must not render as
|
||||||
|
* "identical".
|
||||||
|
*/
|
||||||
|
export interface GtidDrift {
|
||||||
|
missingTransactions: number;
|
||||||
|
missingGtidSet: string | null;
|
||||||
|
/**
|
||||||
|
* Transactions written on the replica under its own server UUID, which exist
|
||||||
|
* nowhere on the master. Non-zero is expected — restoring the seed dump
|
||||||
|
* executed its statements locally — and harmless while nothing replicates
|
||||||
|
* from this node.
|
||||||
|
*/
|
||||||
|
localTransactions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One table compared on both sides of the link. */
|
||||||
|
export interface TableFingerprint {
|
||||||
|
table: string;
|
||||||
|
masterRows: number;
|
||||||
|
replicaRows: number;
|
||||||
|
masterChecksum: string;
|
||||||
|
replicaChecksum: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyResult {
|
||||||
|
identical: boolean;
|
||||||
|
tables: TableFingerprint[];
|
||||||
|
problem: string | null;
|
||||||
|
checkedAt: string;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay-log apply progress, in source binlog bytes.
|
* Relay-log apply progress, in source binlog bytes.
|
||||||
*
|
*
|
||||||
@@ -131,6 +192,8 @@ export interface IngestFile {
|
|||||||
present: boolean;
|
present: boolean;
|
||||||
size: number | null;
|
size: number | null;
|
||||||
modifiedAt: string | null;
|
modifiedAt: string | null;
|
||||||
|
/** Year of a prior-period archive (`2025.accdb`); null on the four fixed sources. */
|
||||||
|
periodYear: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupFile {
|
export interface BackupFile {
|
||||||
@@ -157,6 +220,7 @@ export interface CustomerListItem {
|
|||||||
name: string;
|
name: string;
|
||||||
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
|
/** Legacy table the name was recovered from; null when it came from DATGRAL. */
|
||||||
nameSource: string | null;
|
nameSource: string | null;
|
||||||
|
folderNumber: number | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
state: string | null;
|
state: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
@@ -246,6 +310,16 @@ export interface Installment {
|
|||||||
paidDate: string | null;
|
paidDate: string | null;
|
||||||
checkNumber: string | null;
|
checkNumber: string | null;
|
||||||
isCash: boolean;
|
isCash: boolean;
|
||||||
|
/** Per-payment premium breakdown — a policy paid in several exhibiciones
|
||||||
|
* prices each one separately. `amount` is what was actually collected and
|
||||||
|
* can differ from `total` by rounding; it is not derived from these. */
|
||||||
|
netPremium: string | null;
|
||||||
|
surcharge: string | null;
|
||||||
|
policyFee: string | null;
|
||||||
|
tax: string | null;
|
||||||
|
taxRate: string | null;
|
||||||
|
total: string | null;
|
||||||
|
commission: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Vehicle {
|
export interface Vehicle {
|
||||||
@@ -355,10 +429,14 @@ export interface PolicyInput {
|
|||||||
policyTo?: string;
|
policyTo?: string;
|
||||||
coveragePeriodDays?: number;
|
coveragePeriodDays?: number;
|
||||||
netPremium?: number;
|
netPremium?: number;
|
||||||
|
surcharge?: number;
|
||||||
policyFee?: number;
|
policyFee?: number;
|
||||||
brokerFee?: number;
|
brokerFee?: number;
|
||||||
commission?: number;
|
commission?: number;
|
||||||
|
tax?: number;
|
||||||
|
taxRate?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
|
paymentFrequency?: PaymentFrequency;
|
||||||
currency?: Currency;
|
currency?: Currency;
|
||||||
observations?: string;
|
observations?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -376,6 +454,13 @@ export interface InstallmentInput {
|
|||||||
paidDate?: string;
|
paidDate?: string;
|
||||||
checkNumber?: string;
|
checkNumber?: string;
|
||||||
isCash?: boolean;
|
isCash?: boolean;
|
||||||
|
netPremium?: number;
|
||||||
|
surcharge?: number;
|
||||||
|
policyFee?: number;
|
||||||
|
tax?: number;
|
||||||
|
taxRate?: number;
|
||||||
|
total?: number;
|
||||||
|
commission?: number;
|
||||||
}
|
}
|
||||||
export interface VehicleInput {
|
export interface VehicleInput {
|
||||||
make?: string;
|
make?: string;
|
||||||
@@ -426,6 +511,10 @@ export interface PolicyTypeRow {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
shortDescription: string | null;
|
shortDescription: string | null;
|
||||||
|
/** IVA fraction for this line of business, 0.08 = 8%. Null means "not
|
||||||
|
* configured" and the form falls back to DEFAULT_TAX_RATE — it does NOT
|
||||||
|
* mean the line is untaxed. Serialized as a decimal string by Prisma. */
|
||||||
|
taxRate: string | null;
|
||||||
_count?: { policies: number };
|
_count?: { policies: number };
|
||||||
}
|
}
|
||||||
export interface AdjusterRow {
|
export interface AdjusterRow {
|
||||||
@@ -524,10 +613,14 @@ export interface PolicyDetail {
|
|||||||
policyTo: string | null;
|
policyTo: string | null;
|
||||||
coveragePeriodDays: number | null;
|
coveragePeriodDays: number | null;
|
||||||
netPremium: string | null;
|
netPremium: string | null;
|
||||||
|
surcharge: string | null;
|
||||||
policyFee: string | null;
|
policyFee: string | null;
|
||||||
brokerFee: string | null;
|
brokerFee: string | null;
|
||||||
commission: string | null;
|
commission: string | null;
|
||||||
|
tax: string | null;
|
||||||
|
taxRate: string | null;
|
||||||
total: string | null;
|
total: string | null;
|
||||||
|
paymentFrequency: PaymentFrequency | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
observations: string | null;
|
observations: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
@@ -951,6 +1044,8 @@ export interface BillingFacets {
|
|||||||
|
|
||||||
export interface StatementSummary {
|
export interface StatementSummary {
|
||||||
currency: LedgerCurrency;
|
currency: LedgerCurrency;
|
||||||
|
/** Balance carried in from before the statement year — legacy's BALANCE FORWARD. */
|
||||||
|
opening: string;
|
||||||
charges: string;
|
charges: string;
|
||||||
credits: string;
|
credits: string;
|
||||||
balance: string;
|
balance: string;
|
||||||
@@ -964,6 +1059,7 @@ export interface StatementSummary {
|
|||||||
export interface StatementDomainRow {
|
export interface StatementDomainRow {
|
||||||
domain: TransactionDomain;
|
domain: TransactionDomain;
|
||||||
currency: LedgerCurrency;
|
currency: LedgerCurrency;
|
||||||
|
opening: string;
|
||||||
charges: string;
|
charges: string;
|
||||||
credits: string;
|
credits: string;
|
||||||
balance: string;
|
balance: string;
|
||||||
@@ -999,6 +1095,15 @@ export interface Statement {
|
|||||||
propertyCount: number;
|
propertyCount: number;
|
||||||
policyCount: number;
|
policyCount: number;
|
||||||
};
|
};
|
||||||
|
/** Calendar year the statement covers; movements are scoped to it. */
|
||||||
|
year: number;
|
||||||
|
/**
|
||||||
|
* Periods this customer actually has, newest first. The current year is
|
||||||
|
* always present; each earlier year comes from an imported archive. Offering
|
||||||
|
* anything outside this list would render an empty statement that reads as
|
||||||
|
* "no activity" rather than "not imported".
|
||||||
|
*/
|
||||||
|
availableYears: number[];
|
||||||
summary: StatementSummary[];
|
summary: StatementSummary[];
|
||||||
byDomain: StatementDomainRow[];
|
byDomain: StatementDomainRow[];
|
||||||
byType: StatementTypeRow[];
|
byType: StatementTypeRow[];
|
||||||
@@ -1009,6 +1114,7 @@ export interface CustomerDetail {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
nameSource: string | null;
|
nameSource: string | null;
|
||||||
|
folderNumber: number | null;
|
||||||
addressLine1: string | null;
|
addressLine1: string | null;
|
||||||
addressLine2: string | null;
|
addressLine2: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
@@ -1034,12 +1140,16 @@ export interface CustomerDetail {
|
|||||||
properties: Property[];
|
properties: Property[];
|
||||||
policies: Policy[];
|
policies: Policy[];
|
||||||
transactions: Transaction[];
|
transactions: Transaction[];
|
||||||
|
/** Calendar year `transactions` covers. */
|
||||||
|
transactionYear: number;
|
||||||
transactionSummary: TransactionSummaryRow[];
|
transactionSummary: TransactionSummaryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Editable customer fields — shared by the create/edit form and the API. */
|
/** Editable customer fields — shared by the create/edit form and the API. */
|
||||||
export interface CustomerInput {
|
export interface CustomerInput {
|
||||||
name: string;
|
name: string;
|
||||||
|
/** null clears it. */
|
||||||
|
folderNumber?: number | null;
|
||||||
addressLine1?: string;
|
addressLine1?: string;
|
||||||
addressLine2?: string;
|
addressLine2?: string;
|
||||||
city?: string;
|
city?: string;
|
||||||
@@ -1305,6 +1415,8 @@ export interface StatementBatchDetail extends StatementBatch {
|
|||||||
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
byStatus: Partial<Record<StatementDocumentStatus, number>>;
|
||||||
/** Sum of the amounts still awaiting posting. */
|
/** Sum of the amounts still awaiting posting. */
|
||||||
pendingTotal: string;
|
pendingTotal: string;
|
||||||
|
/** Check the batch was last posted against, to prefill the next confirm. */
|
||||||
|
lastCheckNumber: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatementDocument {
|
export interface StatementDocument {
|
||||||
@@ -1328,6 +1440,22 @@ export interface StatementDocument {
|
|||||||
property: { id: string; addressLine1: string | null };
|
property: { id: string; addressLine1: string | null };
|
||||||
} | null;
|
} | null;
|
||||||
postedTransactionId: string | null;
|
postedTransactionId: string | null;
|
||||||
|
/** Sin fondos for this receipt alone. */
|
||||||
|
outstanding: boolean;
|
||||||
|
/** Empty unless the receipt is shared by several customers. */
|
||||||
|
splits: StatementSplit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementSplit {
|
||||||
|
id: string;
|
||||||
|
sequence: number;
|
||||||
|
amount: string;
|
||||||
|
customer: { id: string; name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitPartInput {
|
||||||
|
customerId: string;
|
||||||
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewDocumentInput {
|
export interface ReviewDocumentInput {
|
||||||
@@ -1338,6 +1466,7 @@ export interface ReviewDocumentInput {
|
|||||||
matchedPropertyServiceId?: string;
|
matchedPropertyServiceId?: string;
|
||||||
matchedCustomerId?: string;
|
matchedCustomerId?: string;
|
||||||
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
status?: "MATCHED" | "NEEDS_REVIEW" | "CONFIRMED";
|
||||||
|
outstanding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check-level fields shared by every line posted from a batch. */
|
/** Check-level fields shared by every line posted from a batch. */
|
||||||
@@ -1353,6 +1482,8 @@ export interface ConfirmBatchInput {
|
|||||||
export interface ConfirmBatchResult {
|
export interface ConfirmBatchResult {
|
||||||
posted: number;
|
posted: number;
|
||||||
total: string;
|
total: string;
|
||||||
|
/** How many of the posted rows went in sin fondos. */
|
||||||
|
outstanding: number;
|
||||||
checkNumber: string;
|
checkNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,7 +1493,7 @@ export interface DiscardBatchResult {
|
|||||||
rejected: number;
|
rejected: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------ Policy OCR intake (GMX) */
|
/* ------------------------------------- Policy OCR intake (GMX / ANA) */
|
||||||
|
|
||||||
export type PolicyOcrBatchStatus =
|
export type PolicyOcrBatchStatus =
|
||||||
| "UPLOADED"
|
| "UPLOADED"
|
||||||
@@ -1404,6 +1535,27 @@ export interface PolicyOcrCoverage {
|
|||||||
insuredAmount: number | null;
|
insuredAmount: number | null;
|
||||||
deductible: string | null;
|
deductible: string | null;
|
||||||
lossParticipation: string | null;
|
lossParticipation: string | null;
|
||||||
|
/** What the coverage COST, where the layout prints it separately from what
|
||||||
|
* it pays out (ANA's add-on sections). GMX never prints one. */
|
||||||
|
premium?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */
|
||||||
|
export interface PolicyOcrVehicle {
|
||||||
|
item: string;
|
||||||
|
modelYear: string | null;
|
||||||
|
make: string | null;
|
||||||
|
bodyType: string | null;
|
||||||
|
vinNumber: string | null;
|
||||||
|
licensePlate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyOcrDriver {
|
||||||
|
fullName: string;
|
||||||
|
licenseNumber: string | null;
|
||||||
|
address: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyOcrMatchCandidate {
|
export interface PolicyOcrMatchCandidate {
|
||||||
@@ -1413,6 +1565,18 @@ export interface PolicyOcrMatchCandidate {
|
|||||||
policyNumber: string;
|
policyNumber: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A customer whose name resembles the printed insured name. A suggestion,
|
||||||
|
* not a match — the API never preselects one.
|
||||||
|
*/
|
||||||
|
export interface PolicyOcrCustomerSuggestion {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
/** `EXACT` = same name tokens in any order; `PARTIAL` = one contains the other. */
|
||||||
|
tier: "EXACT" | "PARTIAL";
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PolicyOcrDocument {
|
export interface PolicyOcrDocument {
|
||||||
id: string;
|
id: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
@@ -1432,9 +1596,18 @@ export interface PolicyOcrDocument {
|
|||||||
extractedNetPremium: string | null;
|
extractedNetPremium: string | null;
|
||||||
extractedPolicyFee: string | null;
|
extractedPolicyFee: string | null;
|
||||||
extractedBrokerFee: string | null;
|
extractedBrokerFee: string | null;
|
||||||
|
/** IVA off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
|
||||||
|
* premium, so no tax either. `LOCAL TAX` is not folded in; a non-zero one
|
||||||
|
* shows up in `matchNote`. */
|
||||||
|
extractedTax: string | null;
|
||||||
extractedTotal: string | null;
|
extractedTotal: string | null;
|
||||||
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
extractedCoveragesJson: PolicyOcrCoverage[] | null;
|
||||||
extractedPremiumPayment: string | null;
|
extractedPremiumPayment: string | null;
|
||||||
|
extractedCoveragePeriodDays: number | null;
|
||||||
|
extractedVehiclesJson: PolicyOcrVehicle[] | null;
|
||||||
|
extractedDriversJson: PolicyOcrDriver[] | null;
|
||||||
|
/** `PolicyType.name` the parser read, resolved to an id only at confirm. */
|
||||||
|
extractedPolicyTypeName: string | null;
|
||||||
matchedPolicy: {
|
matchedPolicy: {
|
||||||
id: string;
|
id: string;
|
||||||
policyNumber: string | null;
|
policyNumber: string | null;
|
||||||
@@ -1443,6 +1616,7 @@ export interface PolicyOcrDocument {
|
|||||||
} | null;
|
} | null;
|
||||||
matchedCustomer: { id: string; name: string } | null;
|
matchedCustomer: { id: string; name: string } | null;
|
||||||
matchCandidates: PolicyOcrMatchCandidate[] | null;
|
matchCandidates: PolicyOcrMatchCandidate[] | null;
|
||||||
|
customerSuggestions: PolicyOcrCustomerSuggestion[] | null;
|
||||||
matchNote: string | null;
|
matchNote: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,8 +1634,10 @@ export interface PolicyOcrReviewInput {
|
|||||||
netPremium?: number;
|
netPremium?: number;
|
||||||
policyFee?: number;
|
policyFee?: number;
|
||||||
brokerFee?: number;
|
brokerFee?: number;
|
||||||
|
tax?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
premiumPayment?: string;
|
premiumPayment?: string;
|
||||||
|
coveragePeriodDays?: number;
|
||||||
coveragesJson?: PolicyOcrCoverage[];
|
coveragesJson?: PolicyOcrCoverage[];
|
||||||
matchedPolicyId?: string;
|
matchedPolicyId?: string;
|
||||||
matchedCustomerId?: string;
|
matchedCustomerId?: string;
|
||||||
@@ -1485,9 +1661,14 @@ export interface PolicyOcrConfirmDocument {
|
|||||||
netPremium?: number;
|
netPremium?: number;
|
||||||
policyFee?: number;
|
policyFee?: number;
|
||||||
brokerFee?: number;
|
brokerFee?: number;
|
||||||
|
tax?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
premiumPayment?: string;
|
premiumPayment?: string;
|
||||||
|
coveragePeriodDays?: number;
|
||||||
coveragesJson?: PolicyOcrCoverage[];
|
coveragesJson?: PolicyOcrCoverage[];
|
||||||
|
/** Explicit lookup picks; both beat the name the parser read. */
|
||||||
|
policyTypeId?: string;
|
||||||
|
insuranceProviderId?: string;
|
||||||
postPremium?: boolean;
|
postPremium?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,3 +1681,68 @@ export interface PolicyOcrConfirmResult {
|
|||||||
policies: string[];
|
policies: string[];
|
||||||
postedTransactions: number;
|
postedTransactions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------ EFE cash receipt book */
|
||||||
|
|
||||||
|
export interface CashReceipt {
|
||||||
|
id: string;
|
||||||
|
folio: number;
|
||||||
|
receivedAt: string;
|
||||||
|
amount: string;
|
||||||
|
currency: Currency;
|
||||||
|
exchangeRate: string | null;
|
||||||
|
concept: string;
|
||||||
|
depositedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
customer: { id: string; name: string; folderNumber: number | null };
|
||||||
|
preparedBy: { name: string };
|
||||||
|
/** The C<folio> credit. Voiding it voids the receipt. */
|
||||||
|
transaction: { id: string; voidedAt: string | null; amount: string; currency: Currency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Office details the printed receipt is headed with. */
|
||||||
|
export interface CompanyHeader {
|
||||||
|
name: string;
|
||||||
|
addressLine1: string;
|
||||||
|
addressLine2: string;
|
||||||
|
cityState: string;
|
||||||
|
mailingAddress: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
taxId: string;
|
||||||
|
website: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CashReceiptInput {
|
||||||
|
customerId: string;
|
||||||
|
folio?: number;
|
||||||
|
receivedAt: string;
|
||||||
|
amount: number;
|
||||||
|
currency: Currency;
|
||||||
|
exchangeRate?: number;
|
||||||
|
concept: string;
|
||||||
|
depositedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-screen account opening: cliente + propiedad + servicios + saldo inicial. */
|
||||||
|
export interface OnboardInput {
|
||||||
|
customer: CustomerInput;
|
||||||
|
property?: { addressLine1?: string; addressLine2?: string; phone1?: string };
|
||||||
|
services?: { kind: ServiceKind; accountNumber?: string; meterNumber?: string }[];
|
||||||
|
openingPayment?: {
|
||||||
|
method: "CASH" | "CHECK";
|
||||||
|
amount: number;
|
||||||
|
currency: Currency;
|
||||||
|
receivedAt: string;
|
||||||
|
checkNumber?: string;
|
||||||
|
exchangeRate?: number;
|
||||||
|
concept?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardResult {
|
||||||
|
customer: { id: string; name: string; folderNumber: number | null };
|
||||||
|
property: { id: string } | null;
|
||||||
|
receipt: { folio: number } | null;
|
||||||
|
payment: { id: string } | null;
|
||||||
|
}
|
||||||
@@ -61,6 +61,11 @@ services:
|
|||||||
INGEST_DIR: /data/ingest
|
INGEST_DIR: /data/ingest
|
||||||
BACKUP_DIR: /data/backups
|
BACKUP_DIR: /data/backups
|
||||||
MIGRATION_ENV: prod
|
MIGRATION_ENV: prod
|
||||||
|
# The API applies pending Prisma migrations at container start, before
|
||||||
|
# Nest listens, and refuses to start if they fail (docker/api-entrypoint.sh).
|
||||||
|
# Set false ONLY when the schema is being moved by hand — the app will
|
||||||
|
# then boot against whatever schema it finds.
|
||||||
|
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
||||||
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
||||||
# application user: --single-transaction needs the global RELOAD privilege
|
# application user: --single-transaction needs the global RELOAD privilege
|
||||||
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
||||||
@@ -132,8 +137,12 @@ services:
|
|||||||
dns_search:
|
dns_search:
|
||||||
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
|
- ${TAILNET_SUFFIX:-tail01aa2.ts.net}
|
||||||
environment:
|
environment:
|
||||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
# OPTIONAL override of the API URL the browser calls (injected at runtime,
|
||||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
# see layout.tsx). Leave it unset: the browser then derives the origin
|
||||||
|
# from the page it loaded — same host on port 3001 over plain HTTP, or
|
||||||
|
# /api behind a TLS-terminating proxy. Set it only when the API really
|
||||||
|
# lives on a different host than the web app.
|
||||||
|
API_ORIGIN: ${API_ORIGIN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-3000}:3000"
|
- "${WEB_PORT:-3000}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -8,10 +8,21 @@
|
|||||||
APP_TAG=latest
|
APP_TAG=latest
|
||||||
|
|
||||||
# --- Public URLs (what the end user's BROWSER hits) ---------------------------
|
# --- Public URLs (what the end user's BROWSER hits) ---------------------------
|
||||||
# API_ORIGIN is injected into the web app at runtime and used for browser fetches
|
# API_ORIGIN is OPTIONAL and normally left unset. The browser derives the API
|
||||||
# + document download links, so it must be browser-reachable (not swarm-internal).
|
# origin from the page it loaded (apps/web/src/lib/api.ts): same host on port
|
||||||
# WEB_ORIGIN is the web app's own public origin; the API allows it via CORS.
|
# 3001 over plain HTTP, or the same-origin /api path when the page is served
|
||||||
API_ORIGIN=http://192.168.4.212:3001
|
# over https by a TLS-terminating proxy that maps /api to the API. That is what
|
||||||
|
# lets the same deployment move — tailnet, office LAN, demo domain — untouched.
|
||||||
|
# Set it only when the API genuinely lives on a different host than the web app;
|
||||||
|
# it is used for browser fetches AND document download links, so it must be
|
||||||
|
# browser-reachable (never a swarm-internal name).
|
||||||
|
#API_ORIGIN=http://192.168.4.212:3001
|
||||||
|
#
|
||||||
|
# WEB_ORIGIN is the list of public origins the web app is reached under; the API
|
||||||
|
# allows them via CORS. COMMA-SEPARATED — one deployment is reachable under
|
||||||
|
# several origins (LAN IP, tailnet name, demo domain) and a credentialed fetch
|
||||||
|
# from an origin missing here gets no CORS headers and fails. A same-origin
|
||||||
|
# setup (web + API behind one proxy) never hits CORS at all.
|
||||||
WEB_ORIGIN=http://192.168.4.212:3000
|
WEB_ORIGIN=http://192.168.4.212:3000
|
||||||
|
|
||||||
# Published ports on the swarm host.
|
# Published ports on the swarm host.
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ services:
|
|||||||
INGEST_DIR: /data/ingest
|
INGEST_DIR: /data/ingest
|
||||||
BACKUP_DIR: /data/backups
|
BACKUP_DIR: /data/backups
|
||||||
MIGRATION_ENV: prod
|
MIGRATION_ENV: prod
|
||||||
|
# The API applies pending Prisma migrations at container start, before
|
||||||
|
# Nest listens, and refuses to start if they fail (docker/api-entrypoint.sh).
|
||||||
|
# Set false ONLY when the schema is being moved by hand — the app will
|
||||||
|
# then boot against whatever schema it finds.
|
||||||
|
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
||||||
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
# Credentials the "Operaciones" screen runs mysqldump/mysql as. NOT the
|
||||||
# application user: --single-transaction needs the global RELOAD privilege
|
# application user: --single-transaction needs the global RELOAD privilege
|
||||||
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
# and the app user has only ALL ON jorgecuadros.*, so every backup, sync
|
||||||
@@ -92,8 +97,12 @@ services:
|
|||||||
labels:
|
labels:
|
||||||
io.jorgecuadros.role: "web"
|
io.jorgecuadros.role: "web"
|
||||||
environment:
|
environment:
|
||||||
# Public API URL the browser calls (injected at runtime, see layout.tsx).
|
# OPTIONAL override of the API URL the browser calls (injected at runtime,
|
||||||
API_ORIGIN: ${API_ORIGIN:?API_ORIGIN must be set}
|
# see layout.tsx). Leave it unset: the browser then derives the origin
|
||||||
|
# from the page it loaded — same host on port 3001 over plain HTTP, or
|
||||||
|
# /api behind a TLS-terminating proxy. Set it only when the API really
|
||||||
|
# lives on a different host than the web app.
|
||||||
|
API_ORIGIN: ${API_ORIGIN:-}
|
||||||
ports:
|
ports:
|
||||||
- target: 3000
|
- target: 3000
|
||||||
published: ${WEB_PORT:-3000}
|
published: ${WEB_PORT:-3000}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Delete unused images from the target host after a successful deploy.
|
||||||
|
*
|
||||||
|
* This exists because nothing else reclaims them. Every build.yml run pushes a
|
||||||
|
* new api + web image, every deploy pulls both onto the host, and the previous
|
||||||
|
* pair is left behind untagged-but-present forever. On galactus that reached
|
||||||
|
* 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
|
||||||
|
* filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
|
||||||
|
* because the Operaciones REIMPORT job leads with a mysqldump that could no
|
||||||
|
* longer write its safety backup.
|
||||||
|
*
|
||||||
|
* Two things keep this from eating a live deployment:
|
||||||
|
*
|
||||||
|
* - Docker never prunes an image that a container references, running or
|
||||||
|
* stopped. The five images the prod stacks use are therefore untouchable
|
||||||
|
* for as long as their containers exist.
|
||||||
|
* - `until` gives a grace window on top of that, so a rollback target stays
|
||||||
|
* on disk instead of forcing a re-pull from the registry.
|
||||||
|
*
|
||||||
|
* TRAP: `until` filters on the image's CREATION time, not when the host pulled
|
||||||
|
* it. Rolling back to an old tag pulls an image that is already older than the
|
||||||
|
* window, so the grace period does NOT protect it — the running-container rule
|
||||||
|
* is what does. That is why this step must run AFTER the app stack is deployed
|
||||||
|
* and verified, never before.
|
||||||
|
*
|
||||||
|
* Required env:
|
||||||
|
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
|
||||||
|
* Optional:
|
||||||
|
* KEEP_HOURS grace window in hours (default 168 = 7 days)
|
||||||
|
*
|
||||||
|
* 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 KEEP_HOURS = process.env.KEEP_HOURS || "168";
|
||||||
|
|
||||||
|
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
|
||||||
|
|
||||||
|
// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
|
||||||
|
// the default, which only collects untagged layers. The tagged-but-superseded
|
||||||
|
// api/web images are the whole problem, and the default filter walks straight
|
||||||
|
// past them.
|
||||||
|
const FILTERS = JSON.stringify({
|
||||||
|
dangling: ["false"],
|
||||||
|
until: [`${KEEP_HOURS}h`],
|
||||||
|
});
|
||||||
|
|
||||||
|
function human(bytes) {
|
||||||
|
if (!bytes) return "0B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let i = 0;
|
||||||
|
let n = bytes;
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-API-Key": API_KEY },
|
||||||
|
});
|
||||||
|
const body = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = report.ImagesDeleted ?? [];
|
||||||
|
const reclaimed = report.SpaceReclaimed ?? 0;
|
||||||
|
console.log(
|
||||||
|
`pruned images older than ${KEEP_HOURS}h and unused by any container`,
|
||||||
|
);
|
||||||
|
console.log(` entries removed : ${deleted.length}`);
|
||||||
|
console.log(` space reclaimed : ${human(reclaimed)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// Non-fatal by contract: the step that calls this sets continue-on-error, so
|
||||||
|
// housekeeping never turns a good deploy red. Exit non-zero anyway so the
|
||||||
|
// failure is visible in the run rather than swallowed.
|
||||||
|
console.error(`::warning::image prune FAILED: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Apply pending Prisma migrations, then hand off to the API.
|
||||||
|
#
|
||||||
|
# WHY THE CONTAINER AND NOT THE DEPLOY WORKFLOW
|
||||||
|
#
|
||||||
|
# The workflow still has its own `prisma migrate deploy` step and that is not
|
||||||
|
# redundant: it runs BEFORE the new images are pulled, i.e. while the OLD code
|
||||||
|
# is still serving, which is the order expand/contract migrations are designed
|
||||||
|
# around (see docs/DEPLOY_AND_MIGRATIONS.md). Doing it here as well closes the
|
||||||
|
# gaps that step cannot:
|
||||||
|
#
|
||||||
|
# - The runner has to reach MySQL directly. When it cannot, the deploy is run
|
||||||
|
# with `skip_migrate=true` and the schema silently does not move — the app
|
||||||
|
# then boots against a schema that is one release behind, which surfaces
|
||||||
|
# later as a column-not-found at runtime rather than as a failed deploy.
|
||||||
|
# - A container restarted by `restart: unless-stopped` after a host reboot,
|
||||||
|
# or a stack re-applied by hand in Portainer, never goes through the
|
||||||
|
# workflow at all.
|
||||||
|
#
|
||||||
|
# `migrate deploy` is idempotent, so running it in both places costs one
|
||||||
|
# no-op query on the normal path.
|
||||||
|
#
|
||||||
|
# THE API DOES NOT START IF THE MIGRATION FAILS. That is deliberate: serving
|
||||||
|
# against a schema that does not match the code is worse than being down,
|
||||||
|
# because the failures it produces are partial and silent (a write to a column
|
||||||
|
# that does not exist yet fails for one feature while the rest of the app looks
|
||||||
|
# healthy). The container exits non-zero and Docker's restart policy retries.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCHEMA=/repo/packages/database/prisma/schema.prisma
|
||||||
|
|
||||||
|
log() { echo "[entrypoint] $*"; }
|
||||||
|
|
||||||
|
if [ "${RUN_MIGRATIONS:-true}" != "true" ]; then
|
||||||
|
log "RUN_MIGRATIONS=${RUN_MIGRATIONS} — skipping migrations, starting the API"
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${DATABASE_URL}" ]; then
|
||||||
|
log "DATABASE_URL is unset; cannot migrate." >&2
|
||||||
|
log "Set it, or set RUN_MIGRATIONS=false if you migrate out of band." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# pnpm's hoisted linker normally puts the CLI in the root .bin, but the
|
||||||
|
# workspace package keeps its own link too. Accept either rather than pinning
|
||||||
|
# a layout detail of the installer — the Dockerfile asserts at build time that
|
||||||
|
# one of these exists, so a missing CLI breaks the image build, not a deploy.
|
||||||
|
PRISMA=""
|
||||||
|
for candidate in /repo/node_modules/.bin/prisma \
|
||||||
|
/repo/packages/database/node_modules/.bin/prisma; do
|
||||||
|
if [ -x "$candidate" ]; then
|
||||||
|
PRISMA="$candidate"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$PRISMA" ]; then
|
||||||
|
log "prisma CLI not found in this image; cannot migrate." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Retry ONLY a connection failure (P1001). On a full bring-up the database
|
||||||
|
# container can still be starting, and on galactus the API additionally has to
|
||||||
|
# resolve the host's MagicDNS name — a lookup that is unreliable for the first
|
||||||
|
# moments after a host reboot (see the dns block in the app compose file, and
|
||||||
|
# docs/DEPLOY_AND_MIGRATIONS.md).
|
||||||
|
#
|
||||||
|
# Every other failure exits immediately. Retrying a migration that is actually
|
||||||
|
# broken just delays the same error behind a minute of noise, and P3005 in
|
||||||
|
# particular needs a human.
|
||||||
|
attempt=1
|
||||||
|
max="${MIGRATE_MAX_ATTEMPTS:-20}"
|
||||||
|
delay="${MIGRATE_RETRY_SECONDS:-3}"
|
||||||
|
|
||||||
|
while : ; do
|
||||||
|
log "prisma migrate deploy (attempt ${attempt}/${max})"
|
||||||
|
if output=$("$PRISMA" migrate deploy --schema "$SCHEMA" 2>&1); then
|
||||||
|
printf '%s\n' "$output"
|
||||||
|
log "migrations up to date"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$output" >&2
|
||||||
|
|
||||||
|
if ! printf '%s' "$output" | grep -q 'P1001'; then
|
||||||
|
log "migrate deploy FAILED — refusing to start the API." >&2
|
||||||
|
if printf '%s' "$output" | grep -q 'P3005'; then
|
||||||
|
log "P3005: the database has tables but no migration history. This is a" >&2
|
||||||
|
log "database that predates Prisma migrations. Baseline it ONCE with:" >&2
|
||||||
|
log " npx prisma@5 migrate resolve --applied 0000_init --schema $SCHEMA" >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$attempt" -ge "$max" ]; then
|
||||||
|
log "database unreachable after ${max} attempts — giving up." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
sleep "$delay"
|
||||||
|
done
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -113,5 +113,21 @@ ENV APP_VERSION=$APP_VERSION \
|
|||||||
GIT_SHA=$GIT_SHA \
|
GIT_SHA=$GIT_SHA \
|
||||||
BUILD_DATE=$BUILD_DATE
|
BUILD_DATE=$BUILD_DATE
|
||||||
|
|
||||||
|
# Pending migrations are applied at container start, before Nest listens —
|
||||||
|
# see the header of the script for why this is done here as well as in the
|
||||||
|
# deploy workflow. Asserted at BUILD time so a missing prisma CLI breaks the
|
||||||
|
# image build rather than a production boot: the runtime layer copies
|
||||||
|
# /repo/node_modules wholesale, and which of these two paths carries the bin
|
||||||
|
# is an implementation detail of pnpm's hoisted linker.
|
||||||
|
COPY docker/api-entrypoint.sh /usr/local/bin/api-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/api-entrypoint.sh
|
||||||
|
RUN for c in /repo/node_modules/.bin/prisma \
|
||||||
|
/repo/packages/database/node_modules/.bin/prisma; do \
|
||||||
|
if [ -x "$c" ]; then echo "prisma CLI found at $c"; exit 0; fi; \
|
||||||
|
done; \
|
||||||
|
echo "FATAL: prisma CLI is not in the runtime layer; api-entrypoint.sh cannot migrate" >&2; \
|
||||||
|
exit 1
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
|
ENTRYPOINT ["/usr/local/bin/api-entrypoint.sh"]
|
||||||
CMD ["node", "apps/api/dist/main.js"]
|
CMD ["node", "apps/api/dist/main.js"]
|
||||||
+87
-15
@@ -12,8 +12,8 @@ carries the reasoning. Close an item *there* as well as here, or the two drift.
|
|||||||
**Verified against dev at compile time** (re-run before trusting the numbers):
|
**Verified against dev at compile time** (re-run before trusting the numbers):
|
||||||
|
|
||||||
```
|
```
|
||||||
policy_types: AUTO, LICENCIAS, MULT
|
policy_types: AUTO, LICENCIAS, MULT (+ M_EMPR after 20260815160000)
|
||||||
policies NULL policyTypeId: 5
|
policies NULL policyTypeId: 5 (0 after 20260815160000)
|
||||||
policies pending liquidación: 226
|
policies pending liquidación: 226
|
||||||
customers: 1536
|
customers: 1536
|
||||||
last tag: v1.0.6 (2026-08-02 02:06 UTC) — 14 commits, 5 migrations behind HEAD
|
last tag: v1.0.6 (2026-08-02 02:06 UTC) — 14 commits, 5 migrations behind HEAD
|
||||||
@@ -83,7 +83,7 @@ guess.
|
|||||||
| 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 |
|
| 1.3 | Carrier API **direction**: outbound quote/issue (ANA supports today) or inbound portfolio sync (no evidence either carrier offers it) | whether §4 is buildable at all | INSURANCE §4 |
|
||||||
| 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 |
|
| 1.4 | CFE amount: the rounded barcode figure (`$268`, what is paid at the window) or the exact breakdown total (`$268.88`) | the parser currently takes the barcode | STATEMENT_OCR / RECEIPT §2 |
|
||||||
| 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 |
|
| 1.5 | The Seguros USD bank's name, currency and details | multi-bank is built; that account does not exist yet | RECEIPT §3 |
|
||||||
| 1.6 | Recycling triggers — exact "1 year inactive" / "cancelled" definitions, and whether recycling ever means true data purge | §4 recycling | RECEIPT §4 |
|
| 1.6 | Whether recycling ever means a true data purge. The *triggers* are now settled and built (see §5 "NUMid allocation"); what is still open is whether a recycled id's old rows are ever deleted rather than left attached to the previous customer | nothing — the allocator ships without a purge | RECEIPT §4 |
|
||||||
| 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 |
|
| 1.7 | Notice body in Spanish or English | `Customer` carries no language preference | INSURANCE §1 |
|
||||||
| 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying −7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 |
|
| 1.8 | How to model `TRASPASOS PAYPAL` — a clearing account, not a customer, carrying −7.03M MXN over 309 movements and therefore topping the adeudo worklist | deliberately not special-cased in code | RESUME §6 |
|
||||||
| 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 |
|
| 1.9 | The 78 policyholders with no email — skip silently or produce a print worklist | recommendation is the worklist | INSURANCE §1 |
|
||||||
@@ -98,16 +98,31 @@ in the same phone call — (55) 5480-4000.
|
|||||||
|
|
||||||
## 2. Live data defects — open, and confirmed open today
|
## 2. Live data defects — open, and confirmed open today
|
||||||
|
|
||||||
### 2.1 `policy_types` is missing `INCENDIO` and `M_EMPR`, and 5 policies are orphaned
|
### 2.1 ~~`policy_types` missing rows + 5 orphaned policies~~ — FIXED 2026-08-15
|
||||||
|
|
||||||
`policyTypeId` is `String?` with a plain relation, so Prisma's default is
|
`policyTypeId` is `String?` with a plain relation, so Prisma's default is
|
||||||
`SetNull`. The spec's recommended `onDelete: Restrict` was **never applied**.
|
`SetNull`, and `removePolicyType()` had no in-use guard — deleting a lookup row
|
||||||
Five `m_empr` policies lost their ramo; four of them are pending liquidación
|
returned 200 and silently blanked the ramo on every policy using it. That is
|
||||||
and are invisible to every ramo-filtered query — including the pending report
|
what happened to `M_EMPR` and its 5 `m_empr` policies.
|
||||||
§2 is supposed to produce.
|
|
||||||
|
|
||||||
Fix alongside the liquidación work (3.1), since it distorts that feature's own
|
Closed by `20260815160000_policy_type_repair` plus the guard in
|
||||||
report. Source: INSURANCE "Two defects found while verifying this spec".
|
`policies.service.ts`:
|
||||||
|
|
||||||
|
- `M_EMPR` restored and the 5 policies re-pointed at it, scoped to
|
||||||
|
`policyTypeId IS NULL AND legacySourceTable = 'm_empr'` so it cannot claim a
|
||||||
|
policy blanked for some other reason. Idempotent; verified against dev inside
|
||||||
|
a rolled-back transaction.
|
||||||
|
- **`INCENDIO` deliberately not recreated.** The legacy `INCENDIO` table has
|
||||||
|
1 row and it never loaded, so the type has zero policies — restoring it would
|
||||||
|
only add a dead option to the type picker.
|
||||||
|
- Deleting an in-use policy type, carrier or adjuster now **refuses** with the
|
||||||
|
name and the count. `claims.adjusterId` had the identical `SET NULL` trap and
|
||||||
|
is guarded too. `onDelete: Restrict` at the schema level was not applied —
|
||||||
|
the application guard gives a Spanish message the operator can act on, where
|
||||||
|
a raw FK error would not.
|
||||||
|
- The duplicate `ANA` carrier row (1 policy) was merged into `ANA SEGUROS`
|
||||||
|
(738), since OCR now assigns the carrier automatically and two rows would
|
||||||
|
keep splitting the book.
|
||||||
|
|
||||||
### 2.2 ≤41 MULT second settlements were dropped in migration
|
### 2.2 ≤41 MULT second settlements were dropped in migration
|
||||||
|
|
||||||
@@ -133,7 +148,9 @@ carries tables nothing writes teaches the next reader the wrong thing.
|
|||||||
| 3.1 | **Liquidación batch workflow** | ~70% of the fields already wired end to end. **226 policies pending.** Needs the ramo-parameterized pending report + `POST /policies/liquidate-batch` under a new `policy:liquidate` (MANAGER). Smallest remaining piece of step 12 | INSURANCE §2 |
|
| 3.1 | **Liquidación batch workflow** | ~70% of the fields already wired end to end. **226 policies pending.** Needs the ramo-parameterized pending report + `POST /policies/liquidate-batch` under a new `policy:liquidate` (MANAGER). Smallest remaining piece of step 12 | INSURANCE §2 |
|
||||||
| 3.2 | **Certificate rendering** | The report half is buildable now off the same `format: "letter"` machinery as `aviso-renovacion`. Portal delivery waits on steps 8/9. Whole section waits on 1.2 | INSURANCE §3 |
|
| 3.2 | **Certificate rendering** | The report half is buildable now off the same `format: "letter"` machinery as `aviso-renovacion`. Portal delivery waits on steps 8/9. Whole section waits on 1.2 | INSURANCE §3 |
|
||||||
| 3.3 | **Carrier API integration** | Blocked on 1.3. ANA's SOAP service is real with a known operation list; GMX publishes nothing machine-readable and writes the larger half of this book | INSURANCE §4 |
|
| 3.3 | **Carrier API integration** | Blocked on 1.3. ANA's SOAP service is real with a known operation list; GMX publishes nothing machine-readable and writes the larger half of this book | INSURANCE §4 |
|
||||||
| 3.4 | **Customer-number recycling** | Not started. `Customer.customerNumber` **does not exist in the schema**. Backfill needs care: ~140 utilities rows and all insurance-only customers carry synthetic `rownum_N`/`insrow_N` placeholders, not real `NUM id`s. Last unbuilt piece of step 11 | RECEIPT §4 |
|
| 3.4 | ~~**Customer-number recycling**~~ | **BUILT 2026-10-12** (`b016e53`) as `customers.folderNumber` ("Carpeta"): a label, not a key, unique among non-archived customers, lowest free suggested. Backfilled from the utilities NUMid; insurance-only customers start blank | RECEIPT §4 |
|
||||||
|
| 3.5 | **Portal login by email for every customer** | Not started (deferred 2026-10-12). Every customer with an email gets access automatically: email → verification code → session. Token carries the email and resolves the customer set per request, so it survives the cutover REIMPORT. 30 emails are shared by 68 customers → account picker after verify. 107 active customers have no email → no access until the office adds one. Unlocks the 284 customers with no NUMid. In `my-jorgecuadros-web`: `verification_codes` is keyed `(email, numid)`, ~68 files authorize on `$tokenData['numid']` — route them through one helper | meeting 2026-10-08 |
|
||||||
|
| 3.6 | **Remove NUMid + password portal login** | Not started (deferred 2026-10-12, lands with or after 3.5). Delete `login.php`'s NUMid/password path and the legacy `PASSWD` reads; the email code becomes the only way in. Assumes no store-installed mobile app still calls `login.php` | meeting 2026-10-08 |
|
||||||
|
|
||||||
Note 3.3 partly overlaps what [`POLICY_OCR.md`](POLICY_OCR.md) already does —
|
Note 3.3 partly overlaps what [`POLICY_OCR.md`](POLICY_OCR.md) already does —
|
||||||
an OCR path that turns a carrier PDF into a `Policy` row covers some of what
|
an OCR path that turns a carrier PDF into a `Policy` row covers some of what
|
||||||
@@ -164,13 +181,41 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
|||||||
be added.
|
be added.
|
||||||
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
|
||||||
|
|
||||||
|
**Captura de pólizas — desglose de primas** (built 2026-08-18)
|
||||||
|
- **IVA y prima total no existen en los datos legacy.** En Access eran
|
||||||
|
controles calculados sin campo, así que las 2,378 pólizas migradas leen
|
||||||
|
`tax` y `total` en null hasta que alguien las edite. No es recuperable: no
|
||||||
|
hay de dónde.
|
||||||
|
- **`LOCAL TAX` de A.N.A. no se captura.** El IVA (`TAX`) sí — se guarda desde
|
||||||
|
2026-08-18 — pero `LOCAL TAX` es un gravamen distinto sin columna destino y
|
||||||
|
**no** se suma al IVA: sumarlo daría una cifra que ya no divide de vuelta a
|
||||||
|
una tasa. Imprime 0.00 en todas las pólizas vistas hasta hoy; una distinta
|
||||||
|
de cero levanta la nota *"impuesto local N no capturado"* y significa que
|
||||||
|
`total` no cuadra contra `netPremium + policyFee + tax`.
|
||||||
|
- **`Policy.taxRate` queda en null por la ruta OCR.** A.N.A. imprime el monto
|
||||||
|
del IVA, no la tasa, y despejarla a la inversa inventaría una tasa que el
|
||||||
|
documento nunca declaró. El formulario resuelve una desde el ramo.
|
||||||
|
- **El recargo no se valida contra la forma de pago en datos migrados.** El
|
||||||
|
formulario lo deshabilita en ANUAL/CONTADO, pero
|
||||||
|
`backfill_policy_premium_breakdown.py` solo advierte cuando encuentra una
|
||||||
|
póliza anual con recargo; no la corrige.
|
||||||
|
- **Las parcialidades 3 y 4 no llevan desglose.** Access solo dibujó la fila
|
||||||
|
de dinero dos veces, así que una póliza trimestral capturada hoy sí puede
|
||||||
|
llenar las cuatro a mano, pero no hay nada legacy que migrar a las dos
|
||||||
|
últimas.
|
||||||
|
|
||||||
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
|
||||||
- **GMX only.** The dispatcher is a `[provider, pattern]` table plus a parser
|
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
|
||||||
map, so a second carrier is one function and two entries — but no other
|
a parser map, so a third carrier is one function and two entries — but no
|
||||||
layout has been seen, and guessing produces a parser nobody can verify.
|
other layout has been seen, and guessing produces a parser nobody can verify.
|
||||||
- **The `recibo` PDF is unread.** The GMX certificate carries no premium at
|
- **The `recibo` PDF is unread.** The GMX certificate carries no premium at
|
||||||
all; reading the separate receipt and pairing it to its certificate is what
|
all; reading the separate receipt and pairing it to its certificate is what
|
||||||
would let `postPremium` stop being a manual tick.
|
would let `postPremium` stop being a manual tick. A.N.A. prints its premium
|
||||||
|
on the face, so this is a GMX-only gap.
|
||||||
|
- **No `insuranceProviderId` beyond the two OCR carriers.** Confirm resolves
|
||||||
|
the parser's provider to an `insurance_providers` row by name, so GMX and
|
||||||
|
A.N.A. land correctly; a policy typed in by hand still gets whatever the
|
||||||
|
operator picks.
|
||||||
- **No versioning.** A re-issued policy arrives as a new certificate with the
|
- **No versioning.** A re-issued policy arrives as a new certificate with the
|
||||||
same number and confirm updates the existing row. Nothing records that this
|
same number and confirm updates the existing row. Nothing records that this
|
||||||
is the 2027 issue of that policy.
|
is the 2027 issue of that policy.
|
||||||
@@ -184,6 +229,33 @@ Each of these is a known, deliberate stopping point rather than a bug.
|
|||||||
- Handwritten folder numbers are deliberately not an input to matching
|
- Handwritten folder numbers are deliberately not an input to matching
|
||||||
(Tesseract read `405` as `205`).
|
(Tesseract read `405` as `205`).
|
||||||
|
|
||||||
|
**NUMid allocation** — `POST /customers/:id/portal-access` assigns the portal
|
||||||
|
"Security Number", on a staff action rather than at create time, because an
|
||||||
|
insurance-only customer has no reason to hold a utilities id.
|
||||||
|
|
||||||
|
- **Recycling is built but switched off.** `numid.recycleEmpty` in `app_settings`
|
||||||
|
defaults to false, and that default is a safety property, not a preference:
|
||||||
|
every reusable id still exists in Access DATGRAL, and a `--sync` migration run
|
||||||
|
upserts refs with `ON DUPLICATE KEY UPDATE customerId`
|
||||||
|
(`transform_customers.py:327`), so an id recycled today is handed back to its
|
||||||
|
Access owner on the next sync and the customer given it loses portal access.
|
||||||
|
**Flip it on after utilities cuts over**, or for ids deleted at the source.
|
||||||
|
- **A full re-import would destroy every natively allocated id — now guarded.**
|
||||||
|
`transform_customers.py:246` truncates `customers` and `customer_legacy_refs`
|
||||||
|
(and the other transforms truncate everything they own), then rebuild from
|
||||||
|
Access alone. `migration/native_guard.py` runs before any of it and refuses
|
||||||
|
when the target holds rows Access has never seen; `run_all.py --force-full`,
|
||||||
|
or the checkbox in the REIMPORT confirm, overrides and deletes them. **`--sync`
|
||||||
|
remains the correct path for any database with native rows** — the guard stops
|
||||||
|
the loss, it does not make full mode preserve anything.
|
||||||
|
- **The empty-id rule exists twice**: enforced in `numid.service.ts`
|
||||||
|
(`EMPTY_NUMID_SQL`) and reported by `scripts/numid-audit.sql`. They agree today
|
||||||
|
(both return 1089, 1094, 1134, 1143 on dev); they are not mechanically kept in
|
||||||
|
step, so change them together.
|
||||||
|
- **No un-assign.** Nothing removes a NUMid once given, and nothing reports which
|
||||||
|
ids were recycled from whom beyond the `customer.portal-access` activity-log
|
||||||
|
entry.
|
||||||
|
|
||||||
**Bank** — the concept→ramo classifier is **won't-build**, not pending.
|
**Bank** — the concept→ramo classifier is **won't-build**, not pending.
|
||||||
`concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is
|
`concepto` is a payee name (0 of 22,354 match a category) and TABLA RAMODOS is
|
||||||
a property-management expense chart, not the business-line split it was assumed
|
a property-management expense chart, not the business-line split it was assumed
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ workflow's last step fails if the API does not report the tag you dispatched.
|
|||||||
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
|
`pre-migrate-<tag>-<timestamp>.sql.gz`, which is exactly what the
|
||||||
**Operaciones** admin screen lists and can restore. A dump taken on the CI
|
**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.
|
runner would be unreachable by the only restore path the platform has.
|
||||||
3. **`prisma migrate deploy`** — as a workflow *step*, never the container
|
3. **`prisma migrate deploy`** — as a workflow *step*, so the schema moves
|
||||||
`CMD`. If it were the CMD, N replicas would race each other applying the
|
while the OLD code is still serving, which is the order expand/contract is
|
||||||
same migration.
|
designed around. **The api container repeats this at start** (below); the
|
||||||
|
command is idempotent, so on the normal path the container's run is a
|
||||||
|
no-op.
|
||||||
4. **app** — the new api + web images.
|
4. **app** — the new api + web images.
|
||||||
5. **Verify** — `GET /version` on the running API must report the dispatched
|
5. **Verify** — `GET /version` on the running API must report the dispatched
|
||||||
tag.
|
tag.
|
||||||
@@ -141,6 +143,59 @@ Commit the generated `migrations/<timestamp>_add_foo/` directory. `db push` is
|
|||||||
now a local-scratch tool only — using it against a database with history
|
now a local-scratch tool only — using it against a database with history
|
||||||
desynchronises it from `_prisma_migrations`.
|
desynchronises it from `_prisma_migrations`.
|
||||||
|
|
||||||
|
If you hand-write a migration instead of generating one, check it against what
|
||||||
|
Prisma would have produced before committing — a hand-written file that drifts
|
||||||
|
from `schema.prisma` fails on the *next* deploy, not this one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
prisma migrate diff \
|
||||||
|
--from-schema-datamodel <schema.prisma at the previous commit> \
|
||||||
|
--to-schema-datamodel packages/database/prisma/schema.prisma --script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations also run at container start
|
||||||
|
|
||||||
|
`docker/api-entrypoint.sh` is the api image's `ENTRYPOINT`. It runs
|
||||||
|
`prisma migrate deploy` and only then `exec`s the API. **If the migration
|
||||||
|
fails the container exits non-zero and the API never listens.**
|
||||||
|
|
||||||
|
That is the point. Serving against a schema that does not match the code is
|
||||||
|
worse than being down, because the failures are partial and silent — a write
|
||||||
|
to a column that does not exist yet breaks one feature while the rest of the
|
||||||
|
app looks healthy.
|
||||||
|
|
||||||
|
This does not replace the workflow step, which still runs first and against
|
||||||
|
the old code. It covers what that step cannot:
|
||||||
|
|
||||||
|
- **`skip_migrate: true`.** Previously that left the schema behind with no
|
||||||
|
further safety net, and the mismatch surfaced later as a runtime error. Now
|
||||||
|
it just moves the migration into the container, so it is a safe choice when
|
||||||
|
the runner cannot reach MySQL.
|
||||||
|
- **Restarts that never touch the workflow** — `restart: unless-stopped`
|
||||||
|
bringing the stack back after a host reboot, or a stack re-applied by hand
|
||||||
|
in Portainer.
|
||||||
|
|
||||||
|
Behaviour worth knowing:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `RUN_MIGRATIONS=false` | Skip and start anyway. Plumbed through both app stack files. For when the schema is being moved by hand. |
|
||||||
|
| `DATABASE_URL` unset | Refuses to start (it would have failed at Nest boot anyway, but this says why). |
|
||||||
|
| Cannot reach the database (**P1001**) | Retries, default 20 × 3s. Covers a cold `db` container and galactus's MagicDNS lookup right after a reboot. `MIGRATE_MAX_ATTEMPTS` / `MIGRATE_RETRY_SECONDS` tune it. |
|
||||||
|
| Any other failure | Exits at once. Retrying a broken migration only delays the same error; **P3005** additionally prints the `migrate resolve --applied 0000_init` hint. |
|
||||||
|
|
||||||
|
**On replicas.** Both stacks are `replicas: 1` and must stay that way for an
|
||||||
|
unrelated reason (the servicios email sweep has no DB lock — see the caveats
|
||||||
|
below). If that ever changes, concurrent `migrate deploy` runs are safe on
|
||||||
|
their own: Prisma takes a database advisory lock, so the others block and then
|
||||||
|
find nothing pending. They would each pay the wait at startup, not corrupt
|
||||||
|
anything.
|
||||||
|
|
||||||
|
The prisma CLI has to be present in the runtime layer for any of this. The
|
||||||
|
image copies `/repo/node_modules` wholesale so it already is, and the
|
||||||
|
Dockerfile **asserts it at build time** — a missing CLI breaks the image
|
||||||
|
build rather than a production boot.
|
||||||
|
|
||||||
## galactus vs cubex
|
## galactus vs cubex
|
||||||
|
|
||||||
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
|
`galactus` is standalone Docker (Portainer endpoint **3**), `cubex` is a 3-node
|
||||||
@@ -319,9 +374,10 @@ backup does them (see `deploy/scripts/pre-migrate-backup.mjs`):
|
|||||||
Portainer serves a self-signed certificate. It is scoped to that one step,
|
Portainer serves a self-signed certificate. It is scoped to that one step,
|
||||||
which talks to nothing but Portainer. Replacing the certificate and dropping
|
which talks to nothing but Portainer. Replacing the certificate and dropping
|
||||||
the flag is the real fix.
|
the flag is the real fix.
|
||||||
- The runner lives on cubex and must reach the target host's Portainer (9443)
|
- 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
|
It should also reach MySQL (3306) for the migrate step, but that is no longer
|
||||||
a host that can and dispatch with `skip_migrate: true`.
|
load-bearing — dispatch with `skip_migrate: true` and the api container
|
||||||
|
applies the migrations itself at start.
|
||||||
- `bootstrap: true` lets the pre-migrate backup be skipped when no API container
|
- `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
|
exists yet. Use it for a first-ever deploy only — it is the one switch that
|
||||||
lets a migration run with no restore point.
|
lets a migration run with no restore point.
|
||||||
|
|||||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user