Compare commits

..
11 Commits
Author SHA1 Message Date
rmancinas 344319f456 chore: clarify Dockerloid workflow comments 2026-10-13 18:04:43 -07:00
rmancinas ac735a627e chore: route cubex deployment through Dockerloid 2026-10-13 17:59:42 -07:00
gitea-actions af5261c644 chore(release): v1.1.0
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m30s
Deploy on tag / Deploy to galactus (push) Successful in 41s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.1.0.
2026-10-13 02:32:51 +00:00
rmancinasandClaude Opus 5.5 c239c1a866 fix(web): local default dates, styled selects and currency on the new capture screens
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m6s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
Found driving /clientes/alta and /efectivo in the browser: the default date
was UTC's (tomorrow, every evening), selects lacked the input style, the
service rows stretched one input per line, and the printed receipt showed a
bare $ with no currency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:30:37 -07:00
rmancinasandClaude Opus 5.5 6468811955 feat(clientes): alta de cliente in one screen, opening balance as a payment
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m51s
The 2026-10-08 ask: open an account without four screens — cliente,
propiedad, servicios and the saldo inicial, taken as the first payment.

POST /customers/onboard writes it all in one Prisma transaction; a bad
service or a taken folder number leaves nothing behind (rolled back on dev).
A cash opening balance goes through the EFE receipt book inside that same
transaction, so it gets its folio and its C<folio> credit; a check posts a
CHECK DEPOSIT against the number given. /clientes/alta pre-fills the next
free folder number and links the receipt for printing. The old
customer-only form stays as "Solo cliente".

Built without the screen sketch from the meeting (it never arrived); the
fields are the legacy ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:27:00 -07:00
rmancinasandClaude Opus 5.5 ca76f4d57a feat(efectivo): the EFE cash receipt book, folio and posting in one step
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m47s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
The office still issues cash receipts from Access (EFE MENU -> EFE). This is
that form: cliente, fecha, monto + moneda, concepto, elaboró (the logged-in
user), fecha de depósito, and a folio that continues the legacy book.

- cash_receipts, folio unique. Next folio = one past the highest of the
  platform's receipts and the imported utilities EFECTIVO journal, so after
  the cutover import it lands on the office's next number (13935 on prod
  today). A typed folio the legacy book already used is refused.
- Issuing a receipt posts its credit in the same database transaction, as
  legacy posted to DATOS2: reference C<folio>, CASH DEPOSIT, pseudo check
  CASH<MMDD><P|D>. Dollars with a rate credit a pesos ledger (1,500 USD at
  16.80 -> 25,200 MXN, as C13830 did). Voiding that row voids the receipt.
- /efectivo: issue, search, record the deposit date; /efectivo/<folio>
  prints the receipt.
- Reporte de efectivo now lists platform receipts by receipt, not by check,
  so each is counted once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:24:43 -07:00
rmancinasandClaude Opus 5.5 fd5df240bc feat(recibos): split a receipt, sin fondos per receipt, keep a batch open
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s
Three asks from the 2026-10-08 meeting, all on the OCR receipt queue.

- Split: one receipt paid by several customers (411 + 523 sharing water
  3014057) posts one charge per part. statement_document_splits holds free
  amounts that must sum to the receipt in cents, checked on save and again
  at confirm; "partes iguales" fills them in. Each part's row carries
  captureRef <docId>#<n>, so the double-post guard still holds.
- Sin fondos per receipt: StatementDocument.outstanding, toggled in review.
  At confirm a line is outstanding if its receipt or the whole check is.
- Open batch: POST /statements/batches/:id/files appends scans, numbered
  after the existing pages; a COMPLETED batch reopens. The confirm card
  prefills the check the batch was last posted against.

Verified on dev against a real batch: 1,892.00 split 946/946 across two
customers, a sibling receipt posted outstanding, second confirm refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:20:20 -07:00
rmancinasandClaude Opus 5.5 f655fcbb82 docs(backlog): folder number built; email portal login and dropping NUMid login pending
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:08:02 -07:00
rmancinasandClaude Opus 5.5 b016e53893 feat(customers): a shelf folder number the office can hand back out
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s
The office files every customer in a numbered paper folder and reuses the
number once an account closes. That number was the legacy NUMid, which is
also an import key and the portal login, so reusing it meant re-pointing an
identity. It is now customers.folderNumber: a plain label nothing joins on.

- Unique only among non-archived customers, checked in CustomersService
  (create, update, restore). Archived customers keep theirs as history.
- GET /customers/folder/next suggests the lowest free number; the new
  customer form pre-fills it.
- Backfilled from the utilities NUMid (dev: 1,171 folders, 0 shared); the
  importer copies it on load. Insurance-only customers start without one.
- Reports, the printed statement and the customer file show "Carpeta"
  instead of NUMid. The list searches by it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:04:01 -07:00
rmancinasandClaude Opus 5.5 031b13f8c8 fix(reports): balances the statement agrees with, NUMid on every report
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m44s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
From the 2026-10-08 meeting.

- Clientes en rojo summed every row a customer ever had, so the opening
  balance's history counted twice, cash receipts twice and the 2025
  archive on top: 195 customers in the red against 22 real ones. It now
  takes the balance scope balances() uses (extracted as balanceScopeSql),
  sorts by most recent real movement, and filters by ramo, date, minimum
  owed and inactive customers (status = 0, the internal accounts).
- Pagos no efectuados asked "no charge of any kind in N days"; legacy asks
  "no row of THAT service's concept in the period". Rewritten to legacy's
  question, per customer, defaulting to one billing cycle back. It also
  passed ELECTRICITY/PHONE, which are not ServiceKind values, so Luz and
  Telefono never returned a row. Sin fondos rows show as their own status.
- Faltantes demanded account, meter AND route of every kind; water and
  phone have no route. Now the legacy fields per kind.
- Reporte de efectivo counted every credit BALANCE FORWARD as cash
  (958 of 1093 rows on dev).
- Every report carries the legacy NUMid; policy reports add the
  insurance file number. The statement header shows it too.
- Printed headers showed placeholder company details: prod sets no
  COMPANY_* variables. Defaults are now the office's real locator, plus a
  US mailing address line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:20:44 -07:00
rmancinasandClaude Opus 5.5 2cf07ed5ee fix(migration): the CFE meter and route belong to the electric service
DATMEX.MEDIDOR/RUTA were unpivoted onto the WATER row, so every electric
service arrived without a meter and water carried CFE's. They are filled
on exactly the 1113 rows that carry an RPU (100% overlap), the meters are
CFE's alphanumeric form (53MT58), and every legacy query that reads them
is a LUZ one. medidor2/ruta2 pair with rpu2 the same way.

Takes effect on the next reimport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:20:44 -07:00
48 changed files with 2923 additions and 301 deletions

No files matched your search

+2 -1
View File
@@ -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=
+40 -42
View File
@@ -1,7 +1,7 @@
# Manual PROD deploy to Portainer. # Manual PROD deploy to Dockerloid.
# #
# This does NOT build — build.yml already builds + pushes the api/web images. # This does NOT build — build.yml already builds + pushes the api/web images.
# This workflow (re)applies the deploy/*.stack.yml files to the Portainer Swarm. # This workflow (re)applies the deploy/*.stack.yml files to the Dockerloid Swarm.
# Trigger it by hand from the Actions tab ("Run workflow") and choose: # Trigger it by hand from the Actions tab ("Run workflow") and choose:
# - tag: which already-published image tag to ship (default: latest) # - tag: which already-published image tag to ship (default: latest)
# - scope: how much to deploy # - scope: how much to deploy
@@ -21,7 +21,7 @@
# own workflow, .gitea/workflows/deploy-galactus.yml. # own workflow, .gitea/workflows/deploy-galactus.yml.
# #
# cssnr/portainer-stack-deploy-action creates each stack on first run and updates # cssnr/portainer-stack-deploy-action creates each stack on first run and updates
# it on every run, so no manual stack pre-creation in the Portainer UI. On a # it on every run, so no manual stack pre-creation in the Dockerloid UI. On a
# `full` deploy the db + minio stacks are applied BEFORE the app (the API depends # `full` deploy the db + minio stacks are applied BEFORE the app (the API depends
# on them). db + minio are stateful + pinned to node label jorgecuadros_db=true # on them). db + minio are stateful + pinned to node label jorgecuadros_db=true
# (see their stack files) — re-applying them is idempotent and keeps their data. # (see their stack files) — re-applying them is idempotent and keeps their data.
@@ -29,13 +29,13 @@
# Prereqs (once): # Prereqs (once):
# - one swarm node labelled jorgecuadros_db=true (db + minio + api pin there). # - one swarm node labelled jorgecuadros_db=true (db + minio + api pin there).
# - Gitea repo secrets set (Settings > Actions > Secrets): # - Gitea repo secrets set (Settings > Actions > Secrets):
# # Portainer # # Dockerloid
# PORTAINER_URL https://192.168.4.212:9443 # DOCKERLOID_URL http://192.168.4.212:7777
# PORTAINER_API_KEY Portainer access token # DOCKERLOID_API_KEY Dockerloid access token
# PORTAINER_ENDPOINT_ID 2 (the local Swarm endpoint) # DOCKERLOID_ENDPOINT_ID 2 (the local Swarm endpoint)
# PORTAINER_APP_STACK_NAME e.g. jorgecuadros-prod-app # DOCKERLOID_APP_STACK_NAME e.g. jorgecuadros-prod-app
# PORTAINER_DB_STACK_NAME e.g. jorgecuadros-prod-db (full only) # DOCKERLOID_DB_STACK_NAME e.g. jorgecuadros-prod-db (full only)
# PORTAINER_MINIO_STACK_NAME e.g. jorgecuadros-prod-minio (full only) # DOCKERLOID_MINIO_STACK_NAME e.g. jorgecuadros-prod-minio (full only)
# # App runtime # # App runtime
# DATABASE_URL mysql://jorgecuadros:<pass>@192.168.4.212:3306/jorgecuadros # DATABASE_URL mysql://jorgecuadros:<pass>@192.168.4.212:3306/jorgecuadros
# SESSION_SECRET 64-hex (openssl rand -hex 32) # SESSION_SECRET 64-hex (openssl rand -hex 32)
@@ -48,7 +48,7 @@
# # 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 Portainer (9443). It should also reach MySQL (3306) # - runner must reach Dockerloid API (7777) and MySQL (3306)
# for the migrate step, but that is no longer load-bearing: dispatch with # for the migrate step, but that is no longer load-bearing: dispatch with
# skip_migrate=true and the api container applies the migrations itself at # skip_migrate=true and the api container applies the migrations itself at
# start (docker/api-entrypoint.sh). `migrate deploy` is idempotent, so the # start (docker/api-entrypoint.sh). `migrate deploy` is idempotent, so the
@@ -59,7 +59,7 @@
# npx prisma@5 migrate resolve --applied 0000_init \ # npx prisma@5 migrate resolve --applied 0000_init \
# --schema packages/database/prisma/schema.prisma # --schema packages/database/prisma/schema.prisma
name: Deploy to Portainer name: Deploy via Dockerloid
on: on:
workflow_dispatch: workflow_dispatch:
@@ -109,12 +109,12 @@ jobs:
# action's input, not the secret you forgot. # action's input, not the secret you forgot.
- name: Preflight — required secrets - name: Preflight — required secrets
env: env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }} PORTAINER_URL: ${{ secrets.DOCKERLOID_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} PORTAINER_API_KEY: ${{ secrets.DOCKERLOID_API_KEY }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} PORTAINER_ENDPOINT_ID: ${{ secrets.DOCKERLOID_ENDPOINT_ID }}
PORTAINER_APP_STACK_NAME: ${{ secrets.PORTAINER_APP_STACK_NAME }} DOCKERLOID_APP_STACK_NAME: ${{ secrets.DOCKERLOID_APP_STACK_NAME }}
PORTAINER_DB_STACK_NAME: ${{ secrets.PORTAINER_DB_STACK_NAME }} DOCKERLOID_DB_STACK_NAME: ${{ secrets.DOCKERLOID_DB_STACK_NAME }}
PORTAINER_MINIO_STACK_NAME: ${{ secrets.PORTAINER_MINIO_STACK_NAME }} DOCKERLOID_MINIO_STACK_NAME: ${{ secrets.DOCKERLOID_MINIO_STACK_NAME }}
DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_URL: ${{ secrets.DATABASE_URL }}
SESSION_SECRET: ${{ secrets.SESSION_SECRET }} SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
APP_API_ORIGIN: ${{ secrets.APP_API_ORIGIN }} APP_API_ORIGIN: ${{ secrets.APP_API_ORIGIN }}
@@ -127,11 +127,11 @@ jobs:
SCOPE: ${{ github.event.inputs.scope }} SCOPE: ${{ github.event.inputs.scope }}
run: | run: |
REQUIRED="PORTAINER_URL PORTAINER_API_KEY PORTAINER_ENDPOINT_ID REQUIRED="PORTAINER_URL PORTAINER_API_KEY PORTAINER_ENDPOINT_ID
PORTAINER_APP_STACK_NAME DATABASE_URL SESSION_SECRET DOCKERLOID_APP_STACK_NAME DATABASE_URL SESSION_SECRET
APP_API_ORIGIN APP_WEB_ORIGIN APP_S3_ENDPOINT APP_API_ORIGIN APP_WEB_ORIGIN APP_S3_ENDPOINT
MINIO_ROOT_USER MINIO_ROOT_PASSWORD MYSQL_ROOT_PASSWORD" MINIO_ROOT_USER MINIO_ROOT_PASSWORD MYSQL_ROOT_PASSWORD"
if [ "$SCOPE" = "full" ]; then if [ "$SCOPE" = "full" ]; then
REQUIRED="$REQUIRED PORTAINER_DB_STACK_NAME PORTAINER_MINIO_STACK_NAME REQUIRED="$REQUIRED DOCKERLOID_DB_STACK_NAME DOCKERLOID_MINIO_STACK_NAME
MYSQL_PASSWORD" MYSQL_PASSWORD"
fi fi
missing="" missing=""
@@ -151,12 +151,12 @@ jobs:
if: ${{ github.event.inputs.scope == 'full' }} if: ${{ github.event.inputs.scope == 'full' }}
uses: cssnr/portainer-stack-deploy-action@v1 uses: cssnr/portainer-stack-deploy-action@v1
with: with:
url: ${{ secrets.PORTAINER_URL }} url: ${{ secrets.DOCKERLOID_URL }}
token: ${{ secrets.PORTAINER_API_KEY }} token: ${{ secrets.DOCKERLOID_API_KEY }}
name: ${{ secrets.PORTAINER_DB_STACK_NAME }} name: ${{ secrets.DOCKERLOID_DB_STACK_NAME }}
file: deploy/jorgecuadros-db.stack.yml file: deploy/jorgecuadros-db.stack.yml
type: file type: file
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }} endpoint: ${{ secrets.DOCKERLOID_ENDPOINT_ID }}
env_data: | env_data: |
{ {
"MYSQL_SERVER_ID": "1", "MYSQL_SERVER_ID": "1",
@@ -172,12 +172,12 @@ jobs:
if: ${{ github.event.inputs.scope == 'full' }} if: ${{ github.event.inputs.scope == 'full' }}
uses: cssnr/portainer-stack-deploy-action@v1 uses: cssnr/portainer-stack-deploy-action@v1
with: with:
url: ${{ secrets.PORTAINER_URL }} url: ${{ secrets.DOCKERLOID_URL }}
token: ${{ secrets.PORTAINER_API_KEY }} token: ${{ secrets.DOCKERLOID_API_KEY }}
name: ${{ secrets.PORTAINER_MINIO_STACK_NAME }} name: ${{ secrets.DOCKERLOID_MINIO_STACK_NAME }}
file: deploy/jorgecuadros-minio.stack.yml file: deploy/jorgecuadros-minio.stack.yml
type: file type: file
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }} endpoint: ${{ secrets.DOCKERLOID_ENDPOINT_ID }}
env_data: | env_data: |
{ {
"MINIO_API_PORT": "9000", "MINIO_API_PORT": "9000",
@@ -192,9 +192,9 @@ jobs:
# unreachable by the only restore path this platform has. # unreachable by the only restore path this platform has.
- name: Pre-migrate backup - name: Pre-migrate backup
env: env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }} PORTAINER_URL: ${{ secrets.DOCKERLOID_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} PORTAINER_API_KEY: ${{ secrets.DOCKERLOID_API_KEY }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} PORTAINER_ENDPOINT_ID: ${{ secrets.DOCKERLOID_ENDPOINT_ID }}
DATABASE_URL: ${{ secrets.DATABASE_URL }} DATABASE_URL: ${{ secrets.DATABASE_URL }}
# The dump runs as root: --single-transaction issues FLUSH TABLES, # The dump runs as root: --single-transaction issues FLUSH TABLES,
# which needs the global RELOAD privilege the application user # which needs the global RELOAD privilege the application user
@@ -202,9 +202,8 @@ jobs:
MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }} MYSQL_ROOT_PASSWORD: ${{ secrets.MYSQL_ROOT_PASSWORD }}
BACKUP_TAG: ${{ github.event.inputs.tag }} BACKUP_TAG: ${{ github.event.inputs.tag }}
ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }} ALLOW_MISSING_CONTAINER: ${{ github.event.inputs.bootstrap }}
# Portainer serves a self-signed certificate. Scoped to this step # Dockerloid uses HTTP on the LAN; this step does not need a TLS bypass.
# only, which does nothing but talk to Portainer. # Requests in this step go only to the Dockerloid API.
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/pre-migrate-backup.mjs run: node deploy/scripts/pre-migrate-backup.mjs
# --- schema, forward-only --------------------------------------------- # --- schema, forward-only ---------------------------------------------
@@ -234,28 +233,27 @@ jobs:
# serving an older build of the same tag. # serving an older build of the same tag.
- name: Pull images - name: Pull images
env: env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL }} PORTAINER_URL: ${{ secrets.DOCKERLOID_URL }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }} PORTAINER_API_KEY: ${{ secrets.DOCKERLOID_API_KEY }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID }} PORTAINER_ENDPOINT_ID: ${{ secrets.DOCKERLOID_ENDPOINT_ID }}
REGISTRY: ${{ env.REGISTRY }} REGISTRY: ${{ env.REGISTRY }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web IMAGES: ${{ github.repository_owner }}/jorgecuadros-api,${{ github.repository_owner }}/jorgecuadros-web
TAG: ${{ github.event.inputs.tag }} TAG: ${{ github.event.inputs.tag }}
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/pull-images.mjs run: node deploy/scripts/pull-images.mjs
# --- always: the app (web + api) ------------------------------------- # --- always: the app (web + api) -------------------------------------
- name: Deploy app stack - name: Deploy app stack
uses: cssnr/portainer-stack-deploy-action@v1 uses: cssnr/portainer-stack-deploy-action@v1
with: with:
url: ${{ secrets.PORTAINER_URL }} url: ${{ secrets.DOCKERLOID_URL }}
token: ${{ secrets.PORTAINER_API_KEY }} token: ${{ secrets.DOCKERLOID_API_KEY }}
name: ${{ secrets.PORTAINER_APP_STACK_NAME }} name: ${{ secrets.DOCKERLOID_APP_STACK_NAME }}
file: deploy/jorgecuadros-app.stack.yml file: deploy/jorgecuadros-app.stack.yml
type: file type: file
pull: true pull: true
endpoint: ${{ secrets.PORTAINER_ENDPOINT_ID }} endpoint: ${{ secrets.DOCKERLOID_ENDPOINT_ID }}
# NOTE: the block below is parsed as JSON — no comments inside it. # NOTE: the block below is parsed as JSON — no comments inside it.
# #
# API_ORIGIN is deliberately absent. The browser derives the API origin # API_ORIGIN is deliberately absent. The browser derives the API origin
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "1.0.26", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
+2
View File
@@ -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,
+11 -6
View File
@@ -305,6 +305,15 @@ export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
export const currentYearStart = () => export const currentYearStart = () =>
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1)); 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) {}
@@ -480,9 +489,7 @@ export class BillingService {
// customer's own statement — NUMid 295 read 14,377.46 against a statement // 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 // 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. // each case being a cash receipt already posted to the ledger.
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( const scope = balanceScopeSql();
currentYearStart(),
)}`;
const filters: Prisma.Sql[] = []; const filters: Prisma.Sql[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
@@ -621,9 +628,7 @@ export class BillingService {
* which is the client's call and not settled yet. * which is the client's call and not settled yet.
*/ */
async stats() { async stats() {
const scope = Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql( const scope = balanceScopeSql();
currentYearStart(),
)}`;
const [movements, ledgerCustomers] = await Promise.all([ const [movements, ledgerCustomers] = await Promise.all([
this.prisma.transaction.count({ where: NOT_VOIDED }), this.prisma.transaction.count({ where: NOT_VOIDED }),
@@ -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();
});
});
@@ -26,7 +26,7 @@ describe("customer file ledger card", () => {
}, },
}; };
return { return {
service: new CustomersService(prisma as never), service: new CustomersService(prisma as never, {} as never),
prisma, prisma,
groupBy, groupBy,
}; };
@@ -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 });
});
});
@@ -19,6 +19,7 @@ import { CustomersService } from "./customers.service";
import { NumidService } from "./numid.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")
@@ -45,6 +46,12 @@ export class CustomersController {
return { candidates: await this.numids.emptyCandidates() }; 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,
@@ -77,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(
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { SettingsModule } from "../settings/settings.module"; 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"; import { NumidService } from "./numid.service";
@Module({ @Module({
imports: [SettingsModule], imports: [SettingsModule, CashReceiptsModule],
controllers: [CustomersController], controllers: [CustomersController],
providers: [CustomersService, NumidService], providers: [CustomersService, NumidService],
exports: [NumidService], exports: [NumidService],
+135 -5
View File
@@ -1,8 +1,15 @@
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 { import {
BALANCE_FORWARD_TYPE, BALANCE_FORWARD_TYPE,
notCashJournal, notCashJournal,
@@ -51,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) {
@@ -68,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: {} };
@@ -90,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,
@@ -106,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,
@@ -248,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.
@@ -255,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) });
} }
@@ -270,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 },
@@ -280,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,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;
+14 -8
View File
@@ -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,
}; };
+2
View File
@@ -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);
+339 -163
View File
@@ -14,13 +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, BALANCE_FORWARD_TYPE,
balanceScopeSql,
NOT_SUPERSEDED,
notCashJournal, notCashJournal,
periodSourceTable, periodSourceTable,
} from "../billing/billing.service"; } 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 { import {
type ColumnDef,
intParam, intParam,
NOT_VOIDED, NOT_VOIDED,
parseDate, parseDate,
@@ -37,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",
@@ -64,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)",
@@ -72,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<{
@@ -97,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)
: "—",
}; };
}); });
@@ -136,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",
@@ -169,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
JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL WHERE t.customerId = c.id AND t.voidedAt IS NULL
AND t.amount < 0 AND t.outstanding = 0 AND tt.nameEn = ${concept}) AS lastPaid
AND t.transactionDate >= ${cutoff}) AS lastCharge,
(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",
@@ -310,37 +477,34 @@ 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;
}> }>
>` >`
@@ -348,44 +512,30 @@ const faltantes: ReportDef = {
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`,
}; };
}, },
}; };
@@ -400,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",
@@ -422,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" },
@@ -436,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: [
@@ -467,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),
}; };
}); });
@@ -541,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" },
@@ -571,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
@@ -586,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 ?? "—",
@@ -689,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" },
@@ -720,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;
}); });
@@ -802,6 +973,7 @@ 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 // The source-table exclusion, the balance floor and the year scope below
// are BillingService.statement's, because this report and // are BillingService.statement's, because this report and
@@ -966,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 ?? "",
@@ -983,7 +1156,7 @@ const edoCuentaDatos: ReportDef = {
...carried, ...carried,
...movements, ...movements,
], ],
subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`, subtitle: `Carpeta ${folder} · ${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
}; };
}, },
}; };
@@ -1021,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" },
@@ -1050,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 } },
}, },
}); });
@@ -1076,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 ?? "—",
@@ -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);
});
});
+20
View File
@@ -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(
+215 -34
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/web", "name": "@jorgecuadros/web",
"version": "1.0.26", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4500", "dev": "next dev -p 4500",
+1
View File
@@ -256,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",
+292
View File
@@ -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>
);
}
+10 -3
View File
@@ -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">
+ Solo cliente
</Link> </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>}
+100
View File
@@ -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>
);
}
+292
View File
@@ -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>
);
}
+250 -8
View File
@@ -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>
);
}
+2
View File
@@ -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" },
], ],
+21 -2
View File
@@ -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)} />
+3
View File
@@ -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>
)} )}
+77 -4
View File
@@ -31,6 +31,7 @@ import type {
UpdateBankAccountInput, UpdateBankAccountInput,
ResolveOutstandingInput, ResolveOutstandingInput,
ReviewDocumentInput, ReviewDocumentInput,
SplitPartInput,
StatementBatch, StatementBatch,
StatementBatchDetail, StatementBatchDetail,
StatementDocument, StatementDocument,
@@ -80,6 +81,11 @@ 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 — so one built image serves // Resolve the API origin at runtime, not build time — so one built image serves
@@ -215,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",
@@ -1375,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,
@@ -1413,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" });
} }
@@ -1437,6 +1476,40 @@ export function statementPageUrl(documentId: string): string {
return `${API_ORIGIN}/statements/documents/${documentId}/page`; return `${API_ORIGIN}/statements/documents/${documentId}/page`;
} }
/* ------------------------------------------------ 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) */ /* ----------------------------------------------- Policy OCR (GMX / ANA) */
export function getPolicyOcrStatus(): Promise<{ export function getPolicyOcrStatus(): Promise<{
+90
View File
@@ -220,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;
@@ -1113,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;
@@ -1146,6 +1148,8 @@ export interface CustomerDetail {
/** 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;
@@ -1411,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 {
@@ -1434,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 {
@@ -1444,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. */
@@ -1459,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;
} }
@@ -1656,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;
}
+4 -3
View File
@@ -1,7 +1,8 @@
# Canonical internal MySQL for the Jorge Cuadros platform. # Canonical internal MySQL for the Jorge Cuadros platform.
# #
# Target: Portainer "local" endpoint on cubex (192.168.4.212:9443), which is a # Target: cubex local endpoint, a 3-node Docker Swarm. The cubex workflow deploys
# 3-node Docker Swarm. This is the source-of-truth database from the plan's # through Dockerloid; the separate Galactus workflow uses its own Portainer. This
# is the source-of-truth database from the plan's
# "internal server" — the migration transform+load and the NestJS API both # "internal server" — the migration transform+load and the NestJS API both
# point at it, and (later) it becomes the replication SOURCE for the VPS. # point at it, and (later) it becomes the replication SOURCE for the VPS.
# #
@@ -25,7 +26,7 @@
# and MySQL will always run there, reusing the same volume. # and MySQL will always run there, reusing the same volume.
# #
# Secrets (MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD) are injected at deploy time via # Secrets (MYSQL_ROOT_PASSWORD, MYSQL_PASSWORD) are injected at deploy time via
# Portainer env_data / stack environment, not committed here. # The stack action env_data / stack environment, not committed here.
version: "3.8" version: "3.8"
+3 -1
View File
@@ -148,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
+17 -3
View File
@@ -166,12 +166,17 @@ def resolve_name(raw, nid, index) -> tuple[str, str | None]:
# ------------------------------- record builders ---------------------------- # # ------------------------------- record builders ---------------------------- #
def customer_from_utilities(row, name_index) -> dict: def customer_from_utilities(row, name_index) -> dict:
name, name_source = resolve_name(row["nombre"], norm_id(row["num_id"]), name_index) nid = norm_id(row["num_id"])
name, name_source = resolve_name(row["nombre"], nid, name_index)
return dict( return dict(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=name, name=name,
nameSource=name_source, nameSource=name_source,
nameMissing=int(name == NO_NAME), nameMissing=int(name == NO_NAME),
# The NUMid is the number on the customer's shelf folder. It is copied
# into a plain label so the office can reuse it without it ever being
# an identity; customer_legacy_refs keeps the NUMid itself.
folderNumber=int(nid) if nid and nid.isdigit() else None,
addressLine1=s(row["direccion"]), addressLine1=s(row["direccion"]),
addressLine2=s(row["colonia"]), addressLine2=s(row["colonia"]),
city=s(row["ciudad"]), city=s(row["ciudad"]),
@@ -205,6 +210,7 @@ def customer_from_insurance(row, name_index) -> dict:
name=name, name=name,
nameSource=name_source, nameSource=name_source,
nameMissing=int(name == NO_NAME), nameMissing=int(name == NO_NAME),
folderNumber=None, # insurance ids are a separate space, not a shelf folder
addressLine1=s(row["direccion_1"]), addressLine1=s(row["direccion_1"]),
addressLine2=s(row["direccion_2"]), addressLine2=s(row["direccion_2"]),
city=s(row["ciudad"]), city=s(row["ciudad"]),
@@ -228,7 +234,7 @@ def customer_from_insurance(row, name_index) -> dict:
_CUST_COLS = [ _CUST_COLS = [
"id", "name", "nameSource", "nameMissing", "addressLine1", "addressLine2", "city", "state", "zipCode", "id", "name", "nameSource", "nameMissing", "folderNumber", "addressLine1", "addressLine2", "city", "state", "zipCode",
"country", "phone", "mobile", "fax", "email", "notes", "identificationType", "country", "phone", "mobile", "fax", "email", "notes", "identificationType",
"identificationNumber", "identificationExpiration", "customerSince", "identificationNumber", "identificationExpiration", "customerSince",
"status", "feeAmount", "minimumBalance", "updatedAt", "status", "feeAmount", "minimumBalance", "updatedAt",
@@ -322,7 +328,7 @@ def main() -> None:
remap[rec["id"]] = stable or rec["id"] remap[rec["id"]] = stable or rec["id"]
for rec in customers: for rec in customers:
rec["id"] = remap[rec["id"]] rec["id"] = remap[rec["id"]]
cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS)) cur.execute(f"INSERT INTO customers ({','.join(f'`{c}`' for c in _CUST_COLS)}) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE name=VALUES(name),nameSource=VALUES(nameSource),nameMissing=VALUES(nameMissing),folderNumber=VALUES(folderNumber),addressLine1=VALUES(addressLine1),addressLine2=VALUES(addressLine2),city=VALUES(city),state=VALUES(state),zipCode=VALUES(zipCode),country=VALUES(country),phone=VALUES(phone),mobile=VALUES(mobile),fax=VALUES(fax),email=VALUES(email),notes=VALUES(notes),identificationType=VALUES(identificationType),identificationNumber=VALUES(identificationNumber),identificationExpiration=VALUES(identificationExpiration),customerSince=VALUES(customerSince),status=VALUES(status),feeAmount=VALUES(feeAmount),minimumBalance=VALUES(minimumBalance),updatedAt=VALUES(updatedAt)", tuple(rec[c] for c in _CUST_COLS))
for ref in refs: for ref in refs:
cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)", cur.execute("INSERT INTO customer_legacy_refs (id,customerId,sourceSystem,sourceTable,legacyId) VALUES (%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customerId=VALUES(customerId)",
(ref[0], remap[ref[1]], ref[2], ref[3], ref[4])) (ref[0], remap[ref[1]], ref[2], ref[3], ref[4]))
@@ -374,6 +380,12 @@ def main() -> None:
recovered = cur.fetchall() recovered = cur.fetchall()
cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,)) cur.execute("SELECT COUNT(*) FROM customers WHERE name = %s", (NO_NAME,))
still_unnamed = cur.fetchone()[0] still_unnamed = cur.fetchone()[0]
cur.execute("SELECT COUNT(folderNumber) FROM customers")
with_folder = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM (SELECT folderNumber FROM customers "
"WHERE archivedAt IS NULL AND folderNumber IS NOT NULL "
"GROUP BY folderNumber HAVING COUNT(*) > 1) d")
shared_folders = cur.fetchone()[0]
print("=== Customer load complete ===") print("=== Customer load complete ===")
print(f" utilities DATGRAL rows : {len(util)}") print(f" utilities DATGRAL rows : {len(util)}")
@@ -390,6 +402,8 @@ def main() -> None:
print(f" from {src:16} : {n}") print(f" from {src:16} : {n}")
print(f" of which via the linked insurance record : {from_ins_side}") print(f" of which via the linked insurance record : {from_ins_side}")
print(f" still {NO_NAME} : {still_unnamed}") print(f" still {NO_NAME} : {still_unnamed}")
print(f" folder numbers : {with_folder} (one per numeric utilities NUMid)")
print(f" held by >1 open customer (must be 0) : {shared_folders}")
if not sync_mode: if not sync_mode:
# Full-load invariants only: sync upserts into an already-loaded (and # Full-load invariants only: sync upserts into an already-loaded (and
# pruned) table, so these exact counts don't hold. # pruned) table, so these exact counts don't hold.
+14 -11
View File
@@ -164,18 +164,21 @@ def main():
services.append((str(uuid.uuid4()), pid, kind, account, meter, route, services.append((str(uuid.uuid4()), pid, kind, account, meter, route,
due, 1 if active else 0, notes)) due, 1 if active else 0, notes))
# WATER (primary + extra meters) # WATER — the CESPT account only. DATMEX has no water meter column.
if s(row["agua"]) or flag("water1", False): if s(row["agua"]) or flag("water1", False):
svc("WATER", account=s(row["agua"]), meter=s(row["medidor"]), svc("WATER", account=s(row["agua"]), due=s(row["diagua"]),
route=s(row["ruta"]), due=s(row["diagua"]), active=flag("water1")) active=flag("water1"))
for mcol, rcol in (("medidor2", "ruta2"), ("medidor3", "ruta3")): # ELECTRIC — rpu account + the CFE meter and route beside it. MEDIDOR/
if s(row[mcol]) or s(row[rcol]): # RUTA are the electric meter, not water: they are filled on exactly
svc("WATER", meter=s(row[mcol]), route=s(row[rcol]), # the 1113 rows that carry an RPU (100% overlap), the meters are CFE's
notes="secondary meter", active=flag("water1")) # alphanumeric `53MT58` form, and every legacy query that reads them is
# ELECTRIC (rpu account + extras); luz_tipo as note # a LUZ one (LUZ, FALTANTES DE LUZ, "luz ruta 325 y 329"). They used to
for rc in ("rpu", "rpu2", "rpu3"): # land on the WATER row, leaving every electric service without a meter.
if s(row[rc]): # Paired by suffix: medidor2/ruta2 belong to rpu2. luz_tipo as note.
svc("ELECTRIC", account=s(row[rc]), for rc, mc, rtc in (("rpu", "medidor", "ruta"), ("rpu2", "medidor2", "ruta2"),
("rpu3", "medidor3", "ruta3")):
if s(row[rc]) or s(row[mc]):
svc("ELECTRIC", account=s(row[rc]), meter=s(row[mc]), route=s(row[rtc]),
notes=s(row["luz_tipo"]) if rc == "rpu" else None, notes=s(row["luz_tipo"]) if rc == "rpu" else None,
active=flag("electric")) active=flag("electric"))
# GAS — the column mixes two things: an account/meter number for 160 of # GAS — the column mixes two things: an account/meter number for 160 of
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jorgecuadros-platform", "name": "jorgecuadros-platform",
"version": "1.0.26", "version": "1.1.0",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/database", "name": "@jorgecuadros/database",
"version": "1.0.26", "version": "1.1.0",
"private": true, "private": true,
"main": "generated/client/index.js", "main": "generated/client/index.js",
"types": "generated/client/index.d.ts", "types": "generated/client/index.d.ts",
@@ -0,0 +1,20 @@
-- The office files every customer in a numbered paper folder and hands the
-- number back out once an account is closed. That number has been the legacy
-- utilities NUMid; it becomes a plain label here so reusing it never touches
-- an identity. Unique among non-archived customers only, enforced by the API.
ALTER TABLE `customers` ADD COLUMN `folderNumber` INTEGER NULL;
CREATE INDEX `customers_folderNumber_idx` ON `customers`(`folderNumber`);
-- Existing customers start with their utilities NUMid. Insurance-only
-- customers have none and get one from the office.
UPDATE `customers` c
JOIN (
SELECT `customerId`, MIN(CAST(`legacyId` AS UNSIGNED)) AS n
FROM `customer_legacy_refs`
WHERE `sourceSystem` = 'utilities'
AND `sourceTable` = 'DATGRAL'
AND `legacyId` REGEXP '^[0-9]+$'
GROUP BY `customerId`
) r ON r.`customerId` = c.`id`
SET c.`folderNumber` = r.n;
@@ -0,0 +1,30 @@
-- Two asks from the 2026-10-08 meeting, both on the OCR receipt queue:
-- * sin fondos per receipt, not only per check (`outstanding`);
-- * one receipt paid by several customers (411 + 523 sharing a water bill),
-- posted as one charge per part.
-- AlterTable
ALTER TABLE `statement_documents` ADD COLUMN `outstanding` BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE `statement_document_splits` (
`id` VARCHAR(191) NOT NULL,
`documentId` VARCHAR(191) NOT NULL,
`sequence` INTEGER NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`postedTransactionId` VARCHAR(191) NULL,
UNIQUE INDEX `statement_document_splits_postedTransactionId_key`(`postedTransactionId`),
UNIQUE INDEX `statement_document_splits_documentId_sequence_key`(`documentId`, `sequence`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_documentId_fkey` FOREIGN KEY (`documentId`) REFERENCES `statement_documents`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `statement_document_splits` ADD CONSTRAINT `statement_document_splits_postedTransactionId_fkey` FOREIGN KEY (`postedTransactionId`) REFERENCES `transactions`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- EFE cash receipt book (2026-10-08 meeting). Each receipt posts its own ledger
-- credit, reference C<folio>, in the same database transaction.
-- CreateTable
CREATE TABLE `cash_receipts` (
`id` VARCHAR(191) NOT NULL,
`folio` INTEGER NOT NULL,
`customerId` VARCHAR(191) NOT NULL,
`receivedAt` DATETIME(3) NOT NULL,
`amount` DECIMAL(12, 2) NOT NULL,
`currency` ENUM('USD', 'MXN') NOT NULL,
`exchangeRate` DECIMAL(10, 4) NULL,
`concept` TEXT NOT NULL,
`preparedById` VARCHAR(191) NOT NULL,
`depositedAt` DATETIME(3) NULL,
`transactionId` VARCHAR(191) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `cash_receipts_folio_key`(`folio`),
UNIQUE INDEX `cash_receipts_transactionId_key`(`transactionId`),
INDEX `cash_receipts_customerId_idx`(`customerId`),
INDEX `cash_receipts_receivedAt_idx`(`receivedAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_customerId_fkey` FOREIGN KEY (`customerId`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_preparedById_fkey` FOREIGN KEY (`preparedById`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `cash_receipts` ADD CONSTRAINT `cash_receipts_transactionId_fkey` FOREIGN KEY (`transactionId`) REFERENCES `transactions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+74
View File
@@ -89,6 +89,13 @@ model Customer {
// can sort nameless records last — ordering by `name` alone puts them first, // can sort nameless records last — ordering by `name` alone puts them first,
// since "(" sorts before every letter. // since "(" sorts before every letter.
nameMissing Boolean @default(false) nameMissing Boolean @default(false)
// The number on the customer's paper folder on the office shelf. A label,
// not an identity: nothing joins on it. The office hands a number back out
// once an account is closed, so it is unique only among customers that are
// not archived — enforced by CustomersService, not by an index, because
// Prisma cannot model a partial/functional unique and would drop one.
// Imported customers start with their utilities NUMid.
folderNumber Int?
addressLine1 String? addressLine1 String?
addressLine2 String? addressLine2 String?
city String? city String?
@@ -123,11 +130,14 @@ model Customer {
transactions Transaction[] transactions Transaction[]
statementDocuments StatementDocument[] statementDocuments StatementDocument[]
statementSplits StatementDocumentSplit[]
cashReceipts CashReceipt[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer") policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[] emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[] accountStatusHistory AccountStatusHistory[]
@@index([folderNumber])
@@map("customers") @@map("customers")
} }
@@ -722,6 +732,26 @@ model StatementBatch {
} }
/// One statement — one customer, one period — after splitting the batch. /// One statement — one customer, one period — after splitting the batch.
/// One customer's share of a split receipt — a water bill two neighbours
/// share, an HOA fee spread across a building. Free amounts, no stored
/// percentages: the office splits unevenly as often as not.
model StatementDocumentSplit {
id String @id @default(uuid())
documentId String
document StatementDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
/// 1-based; also the `#n` in the posted row's captureRef.
sequence Int
customerId String
customer Customer @relation(fields: [customerId], references: [id])
amount Decimal @db.Decimal(12, 2)
postedTransactionId String? @unique
postedTransaction Transaction? @relation("StatementDocumentSplitTransaction", fields: [postedTransactionId], references: [id])
@@unique([documentId, sequence])
@@map("statement_document_splits")
}
model StatementDocument { model StatementDocument {
id String @id @default(uuid()) id String @id @default(uuid())
batchId String batchId String
@@ -767,6 +797,15 @@ model StatementDocument {
postedTransactionId String? @unique postedTransactionId String? @unique
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id]) postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
/// Sin fondos for this receipt alone: posted as outstanding even when the
/// rest of the check is funded.
outstanding Boolean @default(false)
/// When set, the receipt is paid for by several customers and posts one
/// charge per part instead of one to `matchedCustomer`. The parts always sum
/// to `extractedAmount`.
splits StatementDocumentSplit[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([batchId, pageNumber]) @@unique([batchId, pageNumber])
@@ -845,6 +884,8 @@ model Transaction {
/// Set only on OCR-posted rows — the statement page this came from. /// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument? statementDocument StatementDocument?
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
cashReceipt CashReceipt?
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction") policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
@@unique([legacySourceDb, legacySourceTable, legacyId]) @@unique([legacySourceDb, legacySourceTable, legacyId])
@@ -966,6 +1007,7 @@ model User {
statementBatches StatementBatch[] @relation("StatementBatchUploader") statementBatches StatementBatch[] @relation("StatementBatchUploader")
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer") statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
cashReceipts CashReceipt[] @relation("CashReceiptPreparer")
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader") policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer") policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
@@ -1248,3 +1290,35 @@ model AppSetting {
@@map("app_settings") @@map("app_settings")
} }
/// The EFE cash receipt book (legacy EFE form / EFECTIVO table): the paper the
/// office hands a customer who pays in cash. A journal, not a ledger — the
/// money reaches the customer's balance through `transaction`, posted with it
/// as `C<folio>` exactly as legacy posted to DATOS2. Voiding that row voids
/// the receipt.
model CashReceipt {
id String @id @default(uuid())
/// Printed receipt number. Continues the legacy book's sequence.
folio Int @unique
customerId String
customer Customer @relation(fields: [customerId], references: [id])
receivedAt DateTime
amount Decimal @db.Decimal(12, 2)
currency Currency
/// Set when dollars are credited to a pesos ledger, as legacy did; the
/// posted row then carries amount × rate in MXN.
exchangeRate Decimal? @db.Decimal(10, 4)
concept String @db.Text
/// ELABORÓ — whoever was logged in.
preparedById String
preparedBy User @relation("CashReceiptPreparer", fields: [preparedById], references: [id])
/// FECHA DEPÓSITO — filled in when the cash reaches the bank.
depositedAt DateTime?
transactionId String @unique
transaction Transaction @relation(fields: [transactionId], references: [id])
createdAt DateTime @default(now())
@@index([customerId])
@@index([receivedAt])
@@map("cash_receipts")
}