Compare commits

...
16 Commits
Author SHA1 Message Date
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
rmancinasandClaude Opus 5 36158ae761 feat(notificaciones): sweep one aseguradora at a time, and stop the robot quoting a premium
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m59s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m27s
The office works GMX and ANA as separate batches, so the manual barrido now
takes a "Compañía" selection. It filters the pending list as well as the
sweep, so what is on screen is exactly what "Ejecutar barrido" will mail, and
the confirmation names the carrier — running GMX when ANA was meant is the
mistake the filter exists to prevent, and it is not reversible once the mail
is out.

The carrier is chosen by InsuranceProvider id, from the same /lookups the
policy form reads, so a renamed or newly added aseguradora needs no code
change here.

A carrier-scoped run deliberately does NOT advance `lastSuccessfulAt`. The
sweep's catch-up window is computed from it, so advancing after a run that
looked at every day but mailed only one carrier would push every OTHER
carrier's letters out of tomorrow's window and they would never be sent.
Same reasoning that already keeps a debug run from advancing it.

Separately, the letter's "Prima" row is now dropped from the unattended
scheduled sweep only. A premium can still be re-rated at renewal, and an
amount a robot mailed out is one the office has to walk back; every send a
person triggers — the manual barrido and the per-row "Enviar aviso" — still
quotes it. `recordLog` renders with the same flag, so `bodySnapshot` cannot
show the office a letter the customer never received.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 07:24:28 -07:00
rmancinasandClaude Opus 5 fa38ff581e fix(deploy): reclaim superseded images so the host stops filling up
Every build pushes a new api + web image and every deploy pulls both onto
galactus, but nothing ever removed the pair they replaced. That reached 63
images / 83.85GB, of which 79.26GB was unused, and filled the 98GB root
filesystem to 100%.

The symptom was not a disk alert. It was "re-import is broken": the
Operaciones REIMPORT job leads with a mysqldump safety backup, that write
had nowhere to go, and PIPEFAIL took the job down before it touched the
database. Nothing in the ops_jobs log pointed at the disk.

Prune runs last, after the verify step, because Docker refuses to prune an
image that a container references — the running stack is what protects the
release just shipped. `until` adds a grace window on top so a rollback
dispatch stays a stack swap instead of a re-pull, but note it filters on
image creation time rather than pull time, so it does NOT cover rolling
back to an old tag; the running-container rule is what does.

continue-on-error: housekeeping that fails leaves a fat host, not a broken
release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:32:52 -07:00
rmancinasandClaude Opus 5 c4213aa697 fix(audit): the corte audit computed a book the app no longer shows
The script carried its own copy of the balance rules, and since 1.0.26 that
copy was stale: it reported 631,078.98 MXN where the application reports
-416,403.42. An audit that disagrees with production is worse than no audit,
because its numbers look authoritative and diff cleanly against yesterday's
run. It now mirrors NOT_CASH_JOURNAL and archiveIsHistorySql, keeps the
floor-only figure as a labelled line so older runs still diff, and says
plainly which line is the app's.

Section B stops being an open defect list. EFECTIVO is a receipt book whose
receipts are posted to the datos2 ledger by design, so those rows exist and
always will; what matters is whether any of them still reaches a balance.
That is now a counted assertion which must stay at 0, and it fails loudly if
someone writes a balance query that forgets the exclusion.

Section A grew the line that changes its recommendation. "Floor them, never
carry" was written when the floorless group looked like utilities receipts
whose charges were never migrated. Measured on production today: of the 99,
ninety-five carry insurance-line cash and NONE carry utilities rows. The
seguros EFECTIVO is that line's only ledger, so flooring them deletes
receipts rather than removing a double count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 20:05:47 -07:00
gitea-actions f7507f2370 chore(release): v1.0.26
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m26s
Deploy on tag / Deploy to galactus (push) Successful in 38s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.26.
2026-08-20 02:44:12 +00:00
rmancinasandClaude Opus 5 2f9a9afc0d fix(billing): the cash receipt book is not a second ledger
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m28s
EFECTIVO is a journal, not a ledger. The office writes a numbered paper
receipt for money handed over the counter and then posts that same receipt
to the utilities ledger as reference `C<folio>`. Legacy summed the ledger
alone — ledger_repository.php reads `datosfreak`, materialized from DATOS2
only — but the migration flattened both tables into one `transactions`
table, so every balance counted each counter payment twice.

Confirmed against the live legacy database rather than inferred: of the 297
receipts written in 2026, 296 carry a matching DATOS2 posting. Six of them
post converted to pesos under a mistyped folio, which is why matching pairs
on folio and amount found fewer duplicates than exist — and why this
excludes the whole journal instead of a list of confirmed pairs. Only folio
13536 (CL 717, $400 USD) has no posting anywhere; that one wants a human.

The database qualifier is load-bearing. `SEGUROS 16_be` keeps its own table
also called EFECTIVO, and that one is the insurance line's only ledger —
nothing posts it anywhere else. Excluding by table name alone would erase
55,444.95 USD and 63,957.78 MXN across 102 customers, 99 of whom have no
other rows at all. Extending the qualified rule to the statement and the
customer file also gives those 99 back a statement that is not empty.

The same queries were missing the archive window the statement already had,
so the worklist and the book also counted a closed year twice for customers
floored inside an archive.

Measured on production, utilities MXN: NUMid 6 and 173 unchanged to the
cent, 501 unchanged at -10,874.33 (still the portal's number), 10 drops
2,362.20 -> -1,137.80 (exactly the 3,500.00 duplicate), 295 drops
14,377.46 -> 4,377.46 — which is what his statement already said. The
worklist and the statement now agree, which is the point.

Left alone deliberately: the movement browser, which is inventory rather
than balance and should still show what was captured; and stats()'s
outstanding rows, which turn on a client decision that is still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:36:49 -07:00
gitea-actions 7e71a993d0 chore(release): v1.0.25
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m40s
Deploy on tag / Deploy to galactus (push) Successful in 1m9s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.25.
2026-08-20 00:31:07 +00:00
rmancinasandClaude Opus 5 aa5867c8ea fix(statements): an archive is history below the year start, not nothing
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m49s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m20s
c6feae9 excluded imported periods from the current period outright. That is
right for the rows an archive spills into the following January — those already
sit inside the next year's BALANCE FORWARD, which is the sum of the whole
archive, so counting them again would double-book them and file a closed year's
row as current.

It is wrong for everything below the year start. Those rows are what `opening`
exists for, and for a customer whose newest BALANCE FORWARD lives *inside* an
archive they are the only carry there is: the corte skipped NUMid 295 in 2026,
so his floor is the archive's own January 1 and excluding it dropped his entire
2025 closing balance. His statement read 3,592.00 against a true 4,377.46.

So the rule is a window, not an exclusion: an archive row counts below the year
start and never at or above it. Applied identically to statement(), the
edo-cuenta-datos report and the customer-file card, which have to agree.

Spelled as a positive OR rather than NOT(tag AND date). `NOT (col LIKE '...'
AND ...)` is NULL for a row with no legacySourceTable, so the negated form would
have silently dropped every app-captured movement — the same NULL trap the
source-table exclusion was already fixed for.

Verified against prod, which now carries datos2@2025: exactly one customer is
affected and the book moves by his 785.46. NUMid 501 is unchanged at -10,874.33
and still matches the portal; 6 and 173 are unchanged to the cent; the two
customers whose archives spill into January 2026 still keep those rows out of
the current period.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:29:08 -07:00
57 changed files with 3684 additions and 397 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=
+25
View File
@@ -25,6 +25,10 @@
# through this workflow at all. # through this workflow at all.
# 4. app (api + web) the new images. # 4. app (api + web) the new images.
# 5. verify ask the running API what it actually is. # 5. verify ask the running API what it actually is.
# 6. prune images reclaim the superseded api/web images. LAST, and
# after verify: Docker will not prune an image a
# container references, so the running stack is what
# protects the release we just shipped.
# #
# Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the # Rollback = re-dispatch with an older `tag`. That rolls back CODE only; the
# schema stays forward. This is exactly why every schema change must be # schema stays forward. This is exactly why every schema change must be
@@ -386,3 +390,24 @@ jobs:
echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)" echo "dispatched '$WANT'; tiers report '$API_VER' (not directly comparable)"
;; ;;
esac esac
# --- housekeeping ------------------------------------------------------
# Runs LAST, and only after the verify step proved the new containers are
# up. See deploy/scripts/prune-images.mjs: Docker refuses to prune an
# image a container references, so "the stack is running" is what makes
# the current images safe. Pruning earlier would have nothing holding
# them.
#
# continue-on-error: reclaiming disk is not what the deploy is for. A
# prune that fails leaves a fat host, not a broken release.
- name: Prune unused images
continue-on-error: true
env:
PORTAINER_URL: ${{ secrets.PORTAINER_URL_GALACTUS }}
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY_GALACTUS }}
PORTAINER_ENDPOINT_ID: ${{ secrets.PORTAINER_ENDPOINT_ID_GALACTUS }}
# Grace window. Keeps the previous few releases on disk so a rollback
# dispatch is a stack swap instead of a re-pull.
KEEP_HOURS: "168"
NODE_TLS_REJECT_UNAUTHORIZED: "0"
run: node deploy/scripts/prune-images.mjs
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "1.0.24", "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,
+23 -9
View File
@@ -149,26 +149,40 @@ describe("balance floor", () => {
}); });
it("keeps the source-table exclusion alongside the floor", async () => { it("keeps the source-table exclusion alongside the floor", async () => {
// The two guards answer different questions — one reproduces legacy's // The three guards answer different questions — one windows imported
// DATOS2-only materialization, the other drops superseded history — and // periods, one drops the cash receipt book the ledger already posts, the
// dropping either one changes the customer's balance. // floor drops superseded history — and dropping any one of them changes
// the customer's balance.
const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z")); const { service, findMany } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.statement("c1"); await service.statement("c1");
const where = rowsQuery(findMany).where; const where = rowsQuery(findMany).where;
expect(where.OR).toEqual([ // Imported periods are windowed rather than excluded: history below the
// year start (the only carry a floored-by-archive customer has), never
// at or above it (those rows sit inside the next BALANCE FORWARD).
expect(where.AND).toEqual([
{
OR: [
{ legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
{ transactionDate: { lt: expect.any(Date) } },
],
},
// The cash journal, qualified by the database it came from — the
// insurance line has its own EFECTIVO and that one is a real ledger.
{
OR: [
{ legacySourceDb: null },
{ legacySourceDb: { not: "UTILITIES" } },
{ legacySourceTable: null }, { legacySourceTable: null },
{ {
legacySourceTable: { legacySourceTable: {
notIn: expect.arrayContaining(["EFECTIVO"]), notIn: expect.arrayContaining(["EFECTIVO"]),
// Imported prior periods are excluded here too. The floor does not
// cover them: a customer whose newest BALANCE FORWARD sits inside
// an archive floors at that archive's own Jan 1, and every archive
// spills a row or two into the following January.
not: { startsWith: "datos2@" },
}, },
}, },
],
},
]); ]);
}); });
}); });
+145 -47
View File
@@ -209,18 +209,6 @@ export const BALANCE_FLOOR_JOIN = Prisma.sql`
*/ */
export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`; export const NOT_SUPERSEDED = Prisma.sql`(bfloor.floorDate IS NULL OR t.transactionDate >= bfloor.floorDate)`;
/**
* Source tables excluded from the customer-facing statement.
*
* The legacy portal's `datosfreak` table was materialized from DATOS2 only
* (`objects.json:1358`), so the customer's "current balance" never saw
* EFECTIVO / EFECTIVO FM3 / CHEQUE FM3 / EFECTIVO_BACKUP cash receipts, nor
* the IVA 2015 snapshot. The unified `transactions` table has all of them, so
* the statement must drop them to match the legacy number the customer has
* been quoted for years. The staff-facing balances worklist and movement
* browser keep them — they're real money, just tracked separately
* (FM3 = visa fee stream, EFECTIVO = cash receipt stream).
*/
/** /**
* `legacySourceTable` of an imported prior period. * `legacySourceTable` of an imported prior period.
* *
@@ -235,7 +223,34 @@ export const periodSourceTable = (year: number) => `datos2@${year}`;
/** Matches any imported period tag, for discovering which years a customer has. */ /** Matches any imported period tag, for discovering which years a customer has. */
export const PERIOD_TABLE_PREFIX = "datos2@"; export const PERIOD_TABLE_PREFIX = "datos2@";
export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [ /**
* The legacy cash receipt book — a journal, not a ledger.
*
* `EFECTIVO` is the office's numbered receipt pad: money is handed over the
* counter, a folio is written, and the same receipt is then *posted* to the
* utilities ledger (`DATOS2`) as reference `C<folio>`. Legacy summed the ledger
* alone — `ws/v2/lib/ledger_repository.php` reads `datosfreak`, which is
* materialized from DATOS2 only (`objects.json:1358`). The migration flattened
* both tables into one `transactions` table, so anything summing a customer's
* rows counts every cash receipt twice.
*
* Verified against the live legacy database on 2026-08-20: of the 297 receipts
* written in 2026, 296 carry a matching DATOS2 posting. Only folio 13536 (CL
* 717, $400 USD) has no posting anywhere, and it wants a human's eyes rather
* than a code change. Six receipts post converted to pesos under a mistyped
* folio, which is why matching on folio and amount found fewer duplicate pairs
* than actually exist — a reason to exclude the whole journal rather than to
* exclude a list of confirmed pairs.
*
* THE DATABASE QUALIFIER IS LOAD-BEARING. `SEGUROS 16_be` keeps its own table
* also called `EFECTIVO`, and that one is the insurance line's *only* ledger —
* nothing posts it anywhere else. Excluding by table name alone erases the
* whole insurance balance: 55,444.95 USD and 63,957.78 MXN across 102
* customers, 99 of whom have no other rows at all.
*/
export const CASH_JOURNAL_SOURCE_DB = "UTILITIES";
export const CASH_JOURNAL_SOURCE_TABLES: readonly string[] = [
"EFECTIVO", "EFECTIVO",
"EFECTIVO_BACKUP", "EFECTIVO_BACKUP",
"EFECTIVO FM3", "EFECTIVO FM3",
@@ -243,6 +258,62 @@ export const STATEMENT_EXCLUDED_SOURCE_TABLES: readonly string[] = [
"IVA 2015", "IVA 2015",
]; ];
/**
* Prisma form of the cash-journal exclusion.
*
* Spelled as a positive OR on purpose. `notIn` alone compiles to SQL `NOT IN`,
* and `NULL NOT IN (...)` is NULL rather than true, so every app-captured row
* (no `legacySourceTable`) would silently vanish. Same for the database test.
*/
export const notCashJournal = (): Prisma.TransactionWhereInput => ({
OR: [
{ legacySourceDb: null },
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
{ legacySourceTable: null },
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
],
});
/** Raw-SQL form, for the aggregate queries that cannot use Prisma's builder. */
export const NOT_CASH_JOURNAL = Prisma.sql`(
t.legacySourceDb IS NULL
OR t.legacySourceDb <> ${CASH_JOURNAL_SOURCE_DB}
OR t.legacySourceTable IS NULL
OR t.legacySourceTable NOT IN (${Prisma.join([
...CASH_JOURNAL_SOURCE_TABLES,
])}))`;
/**
* Keeps an imported prior period out of the *current* period, NULL-safely.
*
* The balance floor does not settle the archives on its own, in both
* directions. A customer whose newest BALANCE FORWARD lives *inside* an archive
* floors at that archive's own January 1st, so every row of it clears the floor
* — and that is correct, because below the year start the archive is the only
* carry there is. At or above the year start it must go: the archives spill a
* couple of rows into the following January and those already sit inside the
* next year's BALANCE FORWARD, which is the sum of the whole archive.
*
* Spelled as a positive OR for the same NULL reason as above.
*/
export const archiveIsHistorySql = (yearStart: Date) => Prisma.sql`(
t.legacySourceTable IS NULL
OR t.legacySourceTable NOT LIKE ${`${PERIOD_TABLE_PREFIX}%`}
OR t.transactionDate < ${yearStart})`;
/** January 1st of the running year, UTC — the current period's lower bound. */
export const currentYearStart = () =>
new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
/**
* What a balance sums over beyond "not voided": the cash journal out, the
* archives kept to history. Shared by `balances()`, `stats()` and the reports,
* so a customer's balance reads the same on every screen that quotes one.
* Pair it with BALANCE_FLOOR_JOIN + NOT_SUPERSEDED.
*/
export const balanceScopeSql = () =>
Prisma.sql`AND ${NOT_CASH_JOURNAL} AND ${archiveIsHistorySql(currentYearStart())}`;
@Injectable() @Injectable()
export class BillingService { export class BillingService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -412,6 +483,14 @@ export class BillingService {
async balances(params: BalanceParams) { async balances(params: BalanceParams) {
const { query, page, pageSize, currency, balance, domain, sort } = params; const { query, page, pageSize, currency, balance, domain, sort } = params;
// A balance is what the customer owes, so it takes the same rules the
// statement takes: the floor, the cash journal, and the archive window.
// Without the last two the worklist quoted a different number than the
// customer's own statement — NUMid 295 read 14,377.46 against a statement
// of 4,377.46, and NUMid 10 read 2,362.20 against -1,137.80, the gap in
// each case being a cash receipt already posted to the ledger.
const scope = balanceScopeSql();
const filters: Prisma.Sql[] = []; const filters: Prisma.Sql[] = [];
if (domain) filters.push(Prisma.sql`t.domain = ${domain}`); if (domain) filters.push(Prisma.sql`t.domain = ${domain}`);
const txFilter = filters.length const txFilter = filters.length
@@ -474,7 +553,7 @@ export class BillingService {
FROM customers c FROM customers c
JOIN transactions t ON t.customerId = c.id JOIN transactions t ON t.customerId = c.id
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter} WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state GROUP BY c.id, c.name, c.nameSource, c.nameMissing, c.city, c.state
${having} ${having}
${orderBy} ${orderBy}
@@ -490,7 +569,7 @@ export class BillingService {
-- Must match the page query's filters exactly, or the total disagrees -- Must match the page query's filters exactly, or the total disagrees
-- with the rows. (The void exclusion was missing here before the -- with the rows. (The void exclusion was missing here before the
-- outstanding work; a voided-only customer inflated the count.) -- outstanding work; a voided-only customer inflated the count.)
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${nameFilter} ${txFilter} WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND ${NOT_SUPERSEDED} ${scope} ${nameFilter} ${txFilter}
GROUP BY c.id GROUP BY c.id
${having} ${having}
) x ) x
@@ -537,11 +616,20 @@ export class BillingService {
* Two different questions live here and they use different row sets. * Two different questions live here and they use different row sets.
* `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are * `movements`, `ledgerCustomers`, `crossLineCustomers` and the date range are
* INVENTORY — what is stored — and count everything not voided. Everything * INVENTORY — what is stored — and count everything not voided. Everything
* under `byCurrency` / `byDomain` is a BALANCE, so it applies NOT_SUPERSEDED * under `byCurrency` / `byDomain` is a BALANCE, so it takes the same scope
* and drops rows an opening balance already accounts for. The four aggregates * `balances()` takes — the opening-balance floor, the cash journal and the
* moved from Prisma groupBy to raw SQL to express that join; groupBy cannot. * archive window — and the book has to agree with the worklist that sits
* under it. The four aggregates moved from Prisma groupBy to raw SQL to
* express that join; groupBy cannot.
*
* KNOWN DIVERGENCE, left deliberately: these three do not drop outstanding
* rows, while `balances()` does. Reconciling them moves the book by about
* 1.95M MXN and turns on whether an unfunded charge is owed by the customer,
* which is the client's call and not settled yet.
*/ */
async stats() { async stats() {
const scope = balanceScopeSql();
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 }),
this.prisma.transaction this.prisma.transaction
@@ -573,7 +661,7 @@ export class BillingService {
SUM(t.amount > 0) AS creditCount SUM(t.amount > 0) AS creditCount
FROM transactions t FROM transactions t
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.currency GROUP BY t.currency
`; `;
@@ -589,7 +677,7 @@ export class BillingService {
SUM(t.amount) AS net, COUNT(*) AS count SUM(t.amount) AS net, COUNT(*) AS count
FROM transactions t FROM transactions t
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.domain, t.currency GROUP BY t.domain, t.currency
`; `;
@@ -610,7 +698,7 @@ export class BillingService {
SELECT t.customerId, t.currency, SUM(t.amount) AS bal SELECT t.customerId, t.currency, SUM(t.amount) AS bal
FROM transactions t FROM transactions t
${BALANCE_FLOOR_JOIN} ${BALANCE_FLOOR_JOIN}
WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} WHERE t.voidedAt IS NULL AND ${NOT_SUPERSEDED} ${scope}
GROUP BY t.customerId, t.currency GROUP BY t.customerId, t.currency
) x ) x
GROUP BY currency GROUP BY currency
@@ -784,6 +872,19 @@ export class BillingService {
} }
const isArchive = requested !== thisYear; const isArchive = requested !== thisYear;
//
// Deliberately open-ended at the top. A period is a table in legacy, not a
// date range, so whatever the office filed in it belongs to it — including
// the future-dated rows the current ledger carries (it runs to 2028). An
// upper bound would hide them from every view, which is not what legacy did
// and not what the office has been reading.
//
// An archive needs no fold at all: it *is* the period, and its own Jan-1
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
const yearStart = isArchive
? new Date(0)
: new Date(Date.UTC(requested, 0, 1));
// One customer, so the balance floor is a single date rather than the // One customer, so the balance floor is a single date rather than the
// derived table the aggregate queries join. See NOT_SUPERSEDED: rows before // derived table the aggregate queries join. See NOT_SUPERSEDED: rows before
// the opening balance are already inside it, and showing them would both // the opening balance are already inside it, and showing them would both
@@ -819,26 +920,36 @@ export class BillingService {
{ legacySourceTable: periodSourceTable(requested) } { legacySourceTable: periodSourceTable(requested) }
: { : {
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe exclusion. `notIn` alone compiles to SQL `NOT IN`, and // An archive row belongs to this period only as history. Below
// `NULL NOT IN (...)` is NULL, not true — so every app-captured row // the year start it is exactly what `opening` is for, and for the
// (which has no legacySourceTable) silently vanished from the // one customer whose newest BALANCE FORWARD lives *inside* an
// statement while still showing in the movement browser. Rows the app // archive it is the only carry there is — dropping it outright
// books must appear on the customer's statement, so the null case is // understated NUMid 295 by his whole 2025 closing balance, 785.46.
// spelled out. //
// At or above the year start it must go. The archives spill a
// couple of rows into the following January, and those are
// already inside the next year's BALANCE FORWARD (which is the
// sum of the whole archive), so listing them here would both
// double-count and file a closed year's row as current.
//
// Spelled as a positive OR because `NOT (col LIKE ... AND ...)`
// is NULL for an app-captured row, which would drop every one.
AND: [
{
OR: [ OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ {
legacySourceTable: { legacySourceTable: {
notIn: STATEMENT_EXCLUDED_SOURCE_TABLES as string[],
// The archives have to go too, and the floor will not do
// it. A customer whose newest BALANCE FORWARD lives inside
// an archive floors at that archive's own Jan 1, so all 28
// of its rows clear it; and the archives spill rows into
// the following January, which clears any floor. Both put a
// closed year back into the current one.
not: { startsWith: PERIOD_TABLE_PREFIX }, not: { startsWith: PERIOD_TABLE_PREFIX },
}, },
}, },
{ transactionDate: { lt: yearStart } },
],
},
// The cash receipt book is the ledger's own postings written a
// second time, so listing it here would show every counter
// payment twice and double the credit side.
notCashJournal(),
], ],
}), }),
}, },
@@ -867,19 +978,6 @@ export class BillingService {
// opening balance), the earlier rows still have to be *counted* or every // opening balance), the earlier rows still have to be *counted* or every
// balance below is wrong, so they are folded into `opening` rather than // balance below is wrong, so they are folded into `opening` rather than
// listed. That is the same thing a BALANCE FORWARD row does, just computed. // listed. That is the same thing a BALANCE FORWARD row does, just computed.
//
// Deliberately open-ended at the top. A period is a table in legacy, not a
// date range, so whatever the office filed in it belongs to it — including
// the future-dated rows the current ledger carries (it runs to 2028). An
// upper bound would hide them from every view, which is not what legacy did
// and not what the office has been reading.
//
// An archive needs no fold at all: it *is* the period, and its own Jan-1
// BALANCE FORWARD row is the carry, listed exactly as legacy listed it.
const yearStart = isArchive
? new Date(0)
: new Date(Date.UTC(requested, 0, 1));
const running = new Map<string, Prisma.Decimal>(); const running = new Map<string, Prisma.Decimal>();
/** Balance carried into `yearStart`, per currency. */ /** Balance carried into `yearStart`, per currency. */
const opening = new Map<string, Prisma.Decimal>(); const opening = new Map<string, Prisma.Decimal>();
+77
View File
@@ -0,0 +1,77 @@
import { Prisma } from "@jorgecuadros/database";
import {
CASH_JOURNAL_SOURCE_DB,
CASH_JOURNAL_SOURCE_TABLES,
NOT_CASH_JOURNAL,
notCashJournal,
} from "./billing.service";
/**
* `EFECTIVO` is the office's paper receipt book, and every receipt in it is
* also posted to the utilities ledger as `C<folio>`. Both copies were imported
* into one `transactions` table, so a balance that reads the journal counts
* each counter payment twice — 1,094,347.78 MXN of phantom credit book-wide,
* and 3,500.00 of it on NUMid 10 alone.
*
* These fail silently in the worst way: the numbers stay plausible, they are
* just too generous to the customer. Two shapes of mistake are easy to make
* here and both are covered below — dropping the database qualifier (which
* erases the insurance line's only ledger) and writing the exclusion as a bare
* `NOT IN` (which erases every app-captured row).
*/
describe("cash journal exclusion", () => {
describe("raw SQL form", () => {
it("binds the source database rather than interpolating it", () => {
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
});
it("qualifies the table names with the database they came from", () => {
// `SEGUROS 16_be` has its own EFECTIVO and it is the insurance line's
// ONLY ledger — nothing posts it anywhere else. Matching on the table
// name alone erases 55,444.95 USD and 63,957.78 MXN across 102 customers.
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb <>");
expect(NOT_CASH_JOURNAL.values).toContain(CASH_JOURNAL_SOURCE_DB);
});
it("spells both null cases out instead of relying on NOT IN", () => {
// `NULL NOT IN (...)` is NULL, not true. Without these branches every
// app-captured row — the ones staff key in by hand — drops out of the
// balance while still showing in the movement browser.
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceDb IS NULL");
expect(NOT_CASH_JOURNAL.sql).toContain("t.legacySourceTable IS NULL");
});
it("covers the whole cash family, not just EFECTIVO", () => {
for (const table of CASH_JOURNAL_SOURCE_TABLES) {
expect(NOT_CASH_JOURNAL.values).toContain(table);
}
});
it("is a single parenthesised term, safe to AND into a WHERE clause", () => {
// It is composed as `... AND ${NOT_CASH_JOURNAL} AND ...`. An unbracketed
// OR chain would swallow every condition after it and silently widen the
// whole query to the entire table.
const sql = NOT_CASH_JOURNAL.sql.trim();
expect(sql.startsWith("(")).toBe(true);
expect(sql.endsWith(")")).toBe(true);
});
});
describe("Prisma form", () => {
it("matches the raw form's terms so the two cannot drift apart", () => {
const branches = notCashJournal().OR as Prisma.TransactionWhereInput[];
expect(branches).toEqual([
{ legacySourceDb: null },
{ legacySourceDb: { not: CASH_JOURNAL_SOURCE_DB } },
{ legacySourceTable: null },
{ legacySourceTable: { notIn: [...CASH_JOURNAL_SOURCE_TABLES] } },
]);
});
it("returns a fresh object each call", () => {
// It is spread into `AND: [...]` arrays that Prisma may mutate; a shared
// singleton would leak one query's filters into the next.
expect(notCashJournal()).not.toBe(notCashJournal());
});
});
});
@@ -0,0 +1,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,
}; };
@@ -60,24 +60,25 @@ describe("customer file ledger card", () => {
expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate"); expect(groupBy.mock.calls[0][0].where).not.toHaveProperty("transactionDate");
}); });
it("excludes imported periods from the totals", async () => { it("counts an archive as history but never as current", async () => {
// The floor alone is not enough: it does not exist for the floorless, and // The floor alone is not enough: a customer floored by an archive clears
// archives carry rows dated past their own period that clear it. // it with every row of that archive, and the rows archives spill into the
// following January clear any floor. But excluding archives outright is
// wrong too — below the year start they are the only carry a
// floored-by-archive customer has (NUMid 295, 785.46).
const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z")); const { service, groupBy } = serviceWith(new Date("2026-01-01T00:00:00Z"));
await service.detail("c1"); await service.detail("c1");
const and = groupBy.mock.calls[0][0].where.AND; const and = groupBy.mock.calls[0][0].where.AND;
expect(and).toEqual( const rule = and.find((c: { OR?: unknown[] }) =>
expect.arrayContaining([ JSON.stringify(c).includes("datos2@"),
{ );
OR: [ expect(rule.OR).toEqual([
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } }, { legacySourceTable: { not: { startsWith: "datos2@" } } },
], { transactionDate: { lt: expect.any(Date) } },
}, ]);
]),
);
}); });
it("keeps the cash-source exclusion so it reads like the statement", async () => { it("keeps the cash-source exclusion so it reads like the statement", async () => {
@@ -110,11 +111,12 @@ describe("customer file ledger card", () => {
await service.detail("c1"); await service.detail("c1");
const include = prisma.customer.findUnique.mock.calls[0][0].include; const include = prisma.customer.findUnique.mock.calls[0][0].include;
expect(include.transactions.where).toMatchObject({ expect(include.transactions.where.OR).toEqual([
OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } }, { legacySourceTable: { not: { startsWith: "datos2@" } } },
], // Nothing below yearStart reaches this list, so the third branch never
}); // admits an archive row here — it is carried for one shared rule.
{ transactionDate: { lt: expect.any(Date) } },
]);
}); });
}); });
@@ -0,0 +1,68 @@
import { BadRequestException } from "@nestjs/common";
import { CustomersService } from "./customers.service";
/**
* Onboarding writes four tables in one transaction; what is checked here is
* that a request it can't complete is refused before anything is written, and
* that a cash opening balance goes through the receipt book inside that same
* transaction. The rollback itself was exercised against the dev database.
*/
function build() {
const tx = {
customer: { create: jest.fn().mockResolvedValue({ id: "c1" }) },
property: { create: jest.fn().mockResolvedValue({ id: "p1" }) },
propertyService: { createMany: jest.fn() },
typeTransaction: { findFirst: jest.fn() },
transaction: { create: jest.fn() },
};
const prisma = {
customer: { findFirst: jest.fn().mockResolvedValue(null) },
$transaction: jest.fn((fn) => fn(tx)),
};
const cash = { create: jest.fn().mockResolvedValue({ folio: 13935 }) };
return { service: new CustomersService(prisma as never, cash as never), prisma, tx, cash };
}
const pay = { amount: 500, currency: "MXN" as const, receivedAt: "2026-10-12" };
describe("customer onboarding", () => {
it("refuses a check opening balance with no check number, writing nothing", async () => {
const { service, prisma } = build();
await expect(
service.onboard({ customer: { name: "X" }, openingPayment: { ...pay, method: "CHECK" } }, "u1"),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("refuses services with no property to hang them on", async () => {
const { service, prisma } = build();
await expect(
service.onboard({ customer: { name: "X" }, services: [{ kind: "WATER" }] }, "u1"),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.$transaction).not.toHaveBeenCalled();
});
it("issues the cash receipt inside the onboarding transaction", async () => {
const { service, tx, cash } = build();
const r = await service.onboard(
{
customer: { name: "X" },
property: { addressLine1: "Calle 1" },
services: [{ kind: "WATER", accountNumber: "3014057" }],
openingPayment: { ...pay, method: "CASH" },
},
"u1",
);
expect(cash.create).toHaveBeenCalledWith(
expect.objectContaining({ customerId: "c1", amount: 500, concept: "SALDO INICIAL" }),
"u1",
tx,
);
expect(tx.propertyService.createMany).toHaveBeenCalledWith({
data: [{ kind: "WATER", accountNumber: "3014057", propertyId: "p1" }],
});
expect(r.receipt).toEqual({ folio: 13935 });
});
});
@@ -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],
+164 -33
View File
@@ -1,31 +1,44 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { CreateCustomerDto } from "./create-customer.dto"; import { CreateCustomerDto } from "./create-customer.dto";
import { UpdateCustomerDto } from "./update-customer.dto"; import { UpdateCustomerDto } from "./update-customer.dto";
import type { OnboardCustomerDto } from "./onboard-customer.dto";
import { CashReceiptsService } from "../cash-receipts/cash-receipts.service";
import { import {
BALANCE_FORWARD_TYPE, BALANCE_FORWARD_TYPE,
notCashJournal,
PERIOD_TABLE_PREFIX, PERIOD_TABLE_PREFIX,
STATEMENT_EXCLUDED_SOURCE_TABLES,
} from "../billing/billing.service"; } from "../billing/billing.service";
/** /**
* Keeps imported prior periods out of a query, NULL-safely. * Keeps an imported prior period out of the *current* period, NULL-safely.
* *
* A closed year is imported as its own tagged copy of that year's ledger * A closed year is imported as its own tagged copy (`datos2@2025`). Below the
* (`datos2@2025`) and the BALANCE FORWARD rows above it already contain every * year start it is history and counts — for the one customer whose newest
* peso of it. Anything summing a customer's history has to leave the archives * BALANCE FORWARD lives inside an archive it is the only carry there is, and
* out or it counts each closed year twice — see the floor comment below. * dropping it understated NUMid 295 by his entire 2025 closing balance. At or
* above the year start it must go: the archives spill a couple of rows into the
* following January, and those already sit inside the next year's BALANCE
* FORWARD, which is the sum of the whole archive.
* *
* `NULL NOT LIKE '...'` is NULL rather than true, so app-captured rows (which * Spelled as a positive OR because `NOT (col LIKE ... AND ...)` evaluates to
* carry no legacySourceTable) need the null branch spelled out or they vanish. * NULL for an app-captured row (no legacySourceTable), dropping every one.
*/ */
const EXCLUDE_ARCHIVES: Prisma.TransactionWhereInput = { const archiveIsHistory = (
yearStart: Date,
): Prisma.TransactionWhereInput => ({
OR: [ OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } }, { legacySourceTable: { not: { startsWith: PERIOD_TABLE_PREFIX } } },
{ transactionDate: { lt: yearStart } },
], ],
}; });
export interface ListParams { export interface ListParams {
query?: string; query?: string;
@@ -45,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) {
@@ -62,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: {} };
@@ -84,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,
@@ -100,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,
@@ -152,7 +171,15 @@ export class CustomersService {
// in 2026, datos2@2025 two more — so a date test alone would surface // in 2026, datos2@2025 two more — so a date test alone would surface
// a closed year's rows in the current year's list, duplicating the // a closed year's rows in the current year's list, duplicating the
// live ledger's own copy of them for three customers. // live ledger's own copy of them for three customers.
where: { transactionDate: { gte: yearStart }, ...EXCLUDE_ARCHIVES }, // Archives are kept out by tag, not by date. They are not cleanly
// bounded — datos2@2025 carries rows dated into 2026 — so a date test
// alone would surface a closed year's rows in the current year's
// list. Nothing below yearStart reaches this list anyway, so the
// window rule reduces to a plain exclusion here.
where: {
transactionDate: { gte: yearStart },
...archiveIsHistory(yearStart),
},
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
include: { type: true }, include: { type: true },
}, },
@@ -197,23 +224,10 @@ export class CustomersService {
voidedAt: null, voidedAt: null,
outstanding: false, outstanding: false,
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// The floor alone would leave the archives out for anyone who has an // The floor alone does not settle the archives: a customer floored by
// opening balance, but 102 customers have none — for them there is no // an archive clears it with every row of that archive, and the rows
// floor at all, and the archives' rows dated past their own period // archives spill into the following January clear any floor.
// clear it even for the rest. AND: [archiveIsHistory(yearStart), notCashJournal()],
AND: [
EXCLUDE_ARCHIVES,
{
OR: [
{ legacySourceTable: null },
{
legacySourceTable: {
notIn: [...STATEMENT_EXCLUDED_SOURCE_TABLES],
},
},
],
},
],
}, },
_sum: { amount: true }, _sum: { amount: true },
_count: { _all: true }, _count: { _all: true },
@@ -247,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.
@@ -254,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) });
} }
@@ -269,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 },
@@ -279,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;
@@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => {
expect(result.html).toContain("Calle Uno 123"); expect(result.html).toContain("Calle Uno 123");
}); });
it("omits the premium when the sender did not ask for it", () => {
// The unattended sweep quotes no amount: the premium can still be
// re-rated at renewal, and a number a robot mailed out is one the office
// has to walk back.
const result = renderRenewalEmail(letter(), { includePremium: false });
expect(result.html).not.toContain("Prima");
expect(result.html).not.toContain("1,392.00");
// Everything else the customer needs is still there.
expect(result.html).toContain("POL-123");
expect(result.html).toContain("01/09/2026");
expect(result.html).toContain("Ana Pérez");
});
it("uses overdue wording for generation three", () => { it("uses overdue wording for generation three", () => {
const result = renderRenewalEmail(letter({ generation: 3 })); const result = renderRenewalEmail(letter({ generation: 3 }));
+15 -2
View File
@@ -34,10 +34,23 @@ function row(label: string, value: string): string {
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`; return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
} }
export function renderRenewalEmail(letter: RenewalLetterRow): { /**
* Render one renewal letter.
*
* `includePremium` decides whether the "Prima" row appears. The unattended
* sweep sends without it — an amount quoted by a robot, on a premium that may
* still be re-rated at renewal, is a number the office has to walk back — and
* every staff-triggered send (the manual barrido and the per-row "Enviar
* aviso") keeps it, because a person chose to quote it.
*/
export function renderRenewalEmail(
letter: RenewalLetterRow,
options: { includePremium?: boolean } = {},
): {
subject: string; subject: string;
html: string; html: string;
} { } {
const includePremium = options.includePremium !== false;
const expired = letter.generation === 3; const expired = letter.generation === 3;
const subject = expired const subject = expired
? `Póliza vencida: ${letter.policyNumber}` ? `Póliza vencida: ${letter.policyNumber}`
@@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): {
row("Tipo de póliza", letter.policyType), row("Tipo de póliza", letter.policyType),
row("Aseguradora", letter.provider), row("Aseguradora", letter.provider),
row("Fecha de vencimiento", displayDate(letter.policyTo)), row("Fecha de vencimiento", displayDate(letter.policyTo)),
row("Prima", money(premium, letter.currency)), ...(includePremium ? [row("Prima", money(premium, letter.currency))] : []),
row("Cliente", letter.customerName), row("Cliente", letter.customerName),
row("Correo", letter.customerEmail ?? "No disponible"), row("Correo", letter.customerEmail ?? "No disponible"),
row("Teléfono", phone), row("Teléfono", phone),
@@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => {
expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled();
}); });
it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => {
const manual = build({});
await manual.service.sweep("user-1");
expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima");
const automatic = build({});
await automatic.service.scheduledSweep();
const body = automatic.record.mock.calls[0][0].bodySnapshot;
// The snapshot has to match the mail that actually went out, or the
// office reads a letter the customer never received.
expect(body).not.toContain("Prima");
expect(body).toContain("700442181");
});
it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => {
const { service, prisma, send } = build({});
const result = await service.sweep("user-1", { providerId: "gmx-id" });
expect(result.sent).toBe(1);
expect(result.providerId).toBe("gmx-id");
expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({
insuranceProviderId: "gmx-id",
});
expect(send).toHaveBeenCalledTimes(1);
// Only one carrier was mailed, so the days this run covered are still owed
// to every other carrier: advancing `lastSuccessfulAt` would move them out
// of tomorrow's window and they would never be sent.
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
expect(release.data.lastSuccessfulAt).toBeUndefined();
});
it("advances the catch-up window on a clean unfiltered sweep", async () => {
const { service, prisma } = build({});
await service.sweep("user-1");
expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty(
"insuranceProviderId",
);
const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0];
expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date);
});
it("does not fail a delivered notice when the log write throws", async () => { it("does not fail a delivered notice when the log write throws", async () => {
const { service, record } = build({}); const { service, record } = build({});
record.mockRejectedValue(new Error("log table gone")); record.mockRejectedValue(new Error("log table gone"));
+14 -2
View File
@@ -25,6 +25,13 @@ class RenewalFlagsDto {
debug?: boolean; debug?: boolean;
} }
class SweepRenewalsDto extends RenewalFlagsDto {
/** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */
@IsOptional()
@IsString()
providerId?: string;
}
class SendRenewalDto extends RenewalFlagsDto { class SendRenewalDto extends RenewalFlagsDto {
@IsString() @IsString()
policyId!: string; policyId!: string;
@@ -43,17 +50,22 @@ export class RenewalsController {
constructor(private readonly renewals: RenewalsService) {} constructor(private readonly renewals: RenewalsService) {}
@Get("pending") @Get("pending")
pending(@Query("days") days?: string) { pending(
@Query("days") days?: string,
@Query("providerId") providerId?: string,
) {
return this.renewals.pending( return this.renewals.pending(
Math.min(365, Math.max(1, Number(days) || 30)), Math.min(365, Math.max(1, Number(days) || 30)),
providerId?.trim() || undefined,
); );
} }
@Post("sweep") @Post("sweep")
@RequireAbility("renewal:send") @RequireAbility("renewal:send")
sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) { sweep(@Body() dto: SweepRenewalsDto, @Req() req: Request) {
return this.renewals.sweep((req.user as { id: string }).id, { return this.renewals.sweep((req.user as { id: string }).id, {
debug: dto?.debug, debug: dto?.debug,
providerId: dto?.providerId,
}); });
} }
+55 -10
View File
@@ -84,10 +84,14 @@ export class RenewalsService implements OnModuleInit {
/** The unattended run always sends for real: `debug` is a per-click switch /** The unattended run always sends for real: `debug` is a per-click switch
* in the UI, never persisted, so the schedule cannot inherit a forgotten * in the UI, never persisted, so the schedule cannot inherit a forgotten
* test toggle and silently stop mailing customers. */ * test toggle and silently stop mailing customers.
*
* `automatic` is what drops the premium from the letter — see
* `renderRenewalEmail`. It is set here and nowhere else, so every sweep a
* person clicks still quotes the amount. */
async scheduledSweep(): Promise<void> { async scheduledSweep(): Promise<void> {
try { try {
await this.sweep(); await this.sweep(undefined, { automatic: true });
} catch (error) { } catch (error) {
this.logger.error( this.logger.error(
`Falló el barrido de renovaciones: ${(error as Error).message}`, `Falló el barrido de renovaciones: ${(error as Error).message}`,
@@ -95,7 +99,10 @@ export class RenewalsService implements OnModuleInit {
} }
} }
async pending(days = 30) { /** @param providerId Restrict to one aseguradora. The list has to agree
* with what a sweep would send, or the carrier-scoped barrido shows rows it
* will not mail. */
async pending(days = 30, providerId?: string) {
const today = dateInTimeZone(new Date()); const today = dateInTimeZone(new Date());
const state = await this.prisma.scheduledJobState.findUnique({ const state = await this.prisma.scheduledJobState.findUnique({
where: { name: JOB_NAME }, where: { name: JOB_NAME },
@@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit {
item, item,
today, today,
state?.lastSuccessfulAt ?? null, state?.lastSuccessfulAt ?? null,
providerId,
), ),
})), })),
); );
@@ -122,8 +130,20 @@ export class RenewalsService implements OnModuleInit {
); );
} }
async sweep(userId?: string, flags: { debug?: boolean } = {}) { /**
* @param flags.providerId Sweep only one aseguradora. GMX and ANA are worked
* as separate batches by the office, so mixing them in one run is what this
* exists to prevent.
* @param flags.automatic Set only by the scheduler. Drops the premium from
* the letter.
*/
async sweep(
userId?: string,
flags: { debug?: boolean; providerId?: string; automatic?: boolean } = {},
) {
const debug = !!flags.debug; const debug = !!flags.debug;
const providerId = flags.providerId?.trim() || undefined;
const includePremium = !flags.automatic;
const now = new Date(); const now = new Date();
const state = await this.acquireLock(now); const state = await this.acquireLock(now);
@@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit {
cadence, cadence,
today, today,
state.lastSuccessfulAt, state.lastSuccessfulAt,
providerId,
); );
eligible += policies.length; eligible += policies.length;
@@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit {
await this.recordLog(policy, cadence.generation, "", { await this.recordLog(policy, cadence.generation, "", {
status: "SKIPPED_NO_EMAIL", status: "SKIPPED_NO_EMAIL",
debug, debug,
includePremium,
}); });
skipped++; skipped++;
continue; continue;
} }
try { try {
await this.deliver(policy, cadence.generation, to, userId, debug); await this.deliver(policy, cadence.generation, to, userId, {
debug,
includePremium,
});
sent++; sent++;
} catch (error) { } catch (error) {
failures.push({ failures.push({
@@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit {
failed: failures.length, failed: failures.length,
failures, failures,
debug, debug,
providerId: providerId ?? null,
}; };
// A debug run must not advance `lastSuccessfulAt`: it wrote no // A debug run must not advance `lastSuccessfulAt`: it wrote no
// RenewalNotice rows, so the days it "covered" are still owed, and // RenewalNotice rows, so the days it "covered" are still owed, and
// narrowing tomorrow's window back to a single day would drop them. // narrowing tomorrow's window back to a single day would drop them.
await this.releaseLock(!debug && failures.length === 0 ? now : null); //
// A carrier-scoped run must not advance it either, for the same reason
// one step out: it looked at the whole window but only mailed one
// aseguradora, so every other carrier's letters in those days would fall
// outside tomorrow's window and never be sent at all.
const complete = !debug && !providerId && failures.length === 0;
await this.releaseLock(complete ? now : null);
void this.audit.log(userId, "renewalNotice.sweep", result); void this.audit.log(userId, "renewalNotice.sweep", result);
return result; return result;
} catch (error) { } catch (error) {
@@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit {
throw new BadRequestException("El cliente no tiene correo registrado."); throw new BadRequestException("El cliente no tiene correo registrado.");
} }
// A person clicked this, so the premium stays in the letter — only the
// scheduler's unattended run omits it.
const { sentAt, providerMessageId, addressedTo } = await this.deliver( const { sentAt, providerMessageId, addressedTo } = await this.deliver(
policy, policy,
generation, generation,
to, to,
userId, userId,
debug, { debug, includePremium: true },
); );
return { return {
policyId, policyId,
@@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit {
generation: number, generation: number,
to: string, to: string,
userId?: string, userId?: string,
debug = false, options: { debug?: boolean; includePremium?: boolean } = {},
) { ) {
const debug = !!options.debug;
const includePremium = options.includePremium !== false;
const letter = toRenewalLetterRow(policy, generation); const letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter); const message = renderRenewalEmail(letter, { includePremium });
const addressedTo = debug ? DEBUG_RECIPIENT : to; const addressedTo = debug ? DEBUG_RECIPIENT : to;
let result: Awaited<ReturnType<MailService["send"]>>; let result: Awaited<ReturnType<MailService["send"]>>;
@@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit {
status: "FAILED", status: "FAILED",
error: detail, error: detail,
debug, debug,
includePremium,
}); });
throw error; throw error;
} }
@@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit {
providerResponse: result.response || undefined, providerResponse: result.response || undefined,
sendDate: sentAt, sendDate: sentAt,
debug, debug,
includePremium,
}); });
void this.audit.log(userId, "renewalNotice.send", { void this.audit.log(userId, "renewalNotice.send", {
policyId: policy.id, policyId: policy.id,
@@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit {
error?: string; error?: string;
sendDate?: Date; sendDate?: Date;
debug?: boolean; debug?: boolean;
/** Must match what `deliver` rendered, or `bodySnapshot` shows the
* office a letter the customer never received. */
includePremium?: boolean;
}, },
): Promise<void> { ): Promise<void> {
const letter = toRenewalLetterRow(policy, generation); const letter = toRenewalLetterRow(policy, generation);
const message = renderRenewalEmail(letter); const message = renderRenewalEmail(letter, {
includePremium: outcome.includePremium,
});
try { try {
await this.notificationLog.record({ await this.notificationLog.record({
notificationType: "RENEWAL_NOTICE", notificationType: "RENEWAL_NOTICE",
@@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit {
cadence: (typeof RENEWAL_CADENCE)[number], cadence: (typeof RENEWAL_CADENCE)[number],
today: Date, today: Date,
lastSuccessfulAt: Date | null, lastSuccessfulAt: Date | null,
providerId?: string,
) { ) {
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt); const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
return this.prisma.policy.findMany({ return this.prisma.policy.findMany({
where: { where: {
archivedAt: null, archivedAt: null,
...(providerId && { insuranceProviderId: providerId }),
policyTo: { gte: window.from, lte: window.to }, policyTo: { gte: window.from, lte: window.to },
customer: { customer: {
archivedAt: null, archivedAt: null,
+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);
+357 -177
View File
@@ -14,12 +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,
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,
@@ -36,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",
@@ -63,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)",
@@ -71,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<{
@@ -96,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)
: "—",
}; };
}); });
@@ -135,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",
@@ -168,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",
@@ -309,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;
}> }>
>` >`
@@ -347,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`,
}; };
}, },
}; };
@@ -399,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",
@@ -421,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" },
@@ -435,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: [
@@ -466,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),
}; };
}); });
@@ -540,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" },
@@ -570,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
@@ -585,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 ?? "—",
@@ -688,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" },
@@ -719,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;
}); });
@@ -801,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
@@ -835,25 +1008,28 @@ const edoCuentaDatos: ReportDef = {
{ legacySourceTable: periodSourceTable(requestedYear) } { legacySourceTable: periodSourceTable(requestedYear) }
: { : {
...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}), ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
// NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn` // Archive rows count as history below the year start (that is
// drops every app-captured row (they have no legacySourceTable) — the // what `opening` is for, and for a customer floored by an archive
// same defect this report's on-screen twin was fixed for. // it is the only carry there is) and are dropped at or above it.
// Same rule as the on-screen twin — see BillingService.statement.
AND: [
{
OR: [ OR: [
{ legacySourceTable: null }, { legacySourceTable: null },
{ legacySourceTable: { not: { startsWith: "datos2@" } } },
{ {
legacySourceTable: { transactionDate: {
notIn: [ lt: new Date(Date.UTC(requestedYear, 0, 1)),
"EFECTIVO", },
"EFECTIVO_BACKUP", },
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
], ],
// Archives out of the current period too — the balance
// floor does not exclude them (see the on-screen twin).
not: { startsWith: "datos2@" },
},
}, },
// The cash receipt book, which the ledger already carries as
// its own `C<folio>` postings. Taken from the shared helper
// rather than restated, so the printed statement and the screen
// cannot drift apart — and so this keeps the database
// qualifier that spares the insurance line's own EFECTIVO.
notCashJournal(),
], ],
}), }),
}, },
@@ -962,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 ?? "",
@@ -979,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`,
}; };
}, },
}; };
@@ -1017,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" },
@@ -1046,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 } },
}, },
}); });
@@ -1072,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.24", "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)} />
@@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react";
import { useCan } from "@/lib/abilities"; import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels"; import { formatDate, formatMoney } from "@/lib/labels";
import { NotificationLogPanel } from "@/components/NotificationLogPanel"; import { NotificationLogPanel } from "@/components/NotificationLogPanel";
import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api"; import {
apiFetch,
getLookups,
POLIZAS_LOG_SCOPE,
type NotificationFlags,
} from "@/lib/api";
import type { ProviderRow } from "@/lib/types";
/** /**
* Renewal notices — the "Pólizas" half of /notificaciones. Shows which * Renewal notices — the "Pólizas" half of /notificaciones. Shows which
@@ -22,6 +28,12 @@ import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api";
* thing here as it does for servicios: the mail is diverted to the override * thing here as it does for servicios: the mail is diverted to the override
* inbox. It additionally does NOT mark the notice as sent, so a test send * inbox. It additionally does NOT mark the notice as sent, so a test send
* leaves the row exactly where it was — pending. * leaves the row exactly where it was — pending.
*
* The barrido manual is scoped by aseguradora because the office works GMX and
* ANA as separate batches. The selection filters the pending list too, so what
* is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped
* run deliberately does not advance the sweep's catch-up window — it only
* covered one carrier — so the other carriers' letters stay pending.
*/ */
export interface RenewalLetter { export interface RenewalLetter {
@@ -46,6 +58,8 @@ export interface RenewalSweepResult {
failed: number; failed: number;
failures: { policyId: string; generation: number; error: string }[]; failures: { policyId: string; generation: number; error: string }[];
debug: boolean; debug: boolean;
/** Echoed back so the confirmation says which carrier actually ran. */
providerId: string | null;
} }
export interface RenewalSendResult { export interface RenewalSendResult {
@@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const allowed = useCan("renewal:send"); const allowed = useCan("renewal:send");
const debug = !!flags.debug; const debug = !!flags.debug;
const [days, setDays] = useState(30); const [days, setDays] = useState(30);
/** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers
* are renamed in the lookups screen and the filter must survive that. */
const [providerId, setProviderId] = useState("");
const [providers, setProviders] = useState<ProviderRow[]>([]);
const [pending, setPending] = useState<RenewalLetter[] | null>(null); const [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null); const [pendingError, setPendingError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
@@ -81,8 +99,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
setPendingError(null); setPendingError(null);
try { try {
const params = new URLSearchParams({ days: String(days) });
if (providerId) params.set("providerId", providerId);
const data = await apiFetch<RenewalLetter[]>( const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?days=${days}`, `/renewals/pending?${params.toString()}`,
); );
setPending(data); setPending(data);
} catch (e) { } catch (e) {
@@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
); );
setPending([]); setPending([]);
} }
}, [days]); }, [days, providerId]);
useEffect(() => { useEffect(() => {
if (allowed) refresh(); if (allowed) refresh();
}, [allowed, refresh]); }, [allowed, refresh]);
// Carriers come from the same lookups the policy form uses, so a new
// aseguradora shows up here without a code change.
useEffect(() => {
if (!allowed) return;
let cancelled = false;
getLookups()
.then((data) => {
if (!cancelled) setProviders(data.providers);
})
.catch(() => {
// A failed lookup only costs the filter; the unfiltered sweep still
// works, so this must not blank the screen.
if (!cancelled) setProviders([]);
});
return () => {
cancelled = true;
};
}, [allowed]);
const providerLabel =
providers.find((item) => item.id === providerId)?.name ?? "todas las compañías";
async function handleSweep() { async function handleSweep() {
// Only worth confirming when debug is off — that is the case where real // Only worth confirming when debug is off — that is the case where real
// customers receive mail. Mirrors "Ejecutar todos" on the servicios tab. // customers receive mail. Mirrors "Ejecutar todos" on the servicios tab.
// The carrier is named in the prompt: running GMX when ANA was meant is
// exactly the mistake this filter exists to prevent, and it is not
// reversible once the mail is out.
if (!debug) { if (!debug) {
const ok = window.confirm( const ok = window.confirm(
"debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?", `debug está desactivado: los avisos irán a los correos reales de los clientes. ` +
`¿Ejecutar el barrido de ${providerLabel}?`,
); );
if (!ok) return; if (!ok) return;
} }
@@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
try { try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", { const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST", method: "POST",
body: JSON.stringify({ debug }), body: JSON.stringify({ debug, providerId: providerId || undefined }),
}); });
setNotice( setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).` + `Enviados ${result.sent} avisos de ${providerLabel} ` +
`(${result.failed} con error).` +
(result.debug (result.debug
? " Modo debug: fueron al buzón de pruebas y siguen pendientes." ? " Modo debug: fueron al buzón de pruebas y siguen pendientes."
: ""), : ""),
@@ -199,7 +246,9 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
<h2 className="section-title">Barrido manual</h2> <h2 className="section-title">Barrido manual</h2>
<p className="muted small" style={{ marginTop: 4 }}> <p className="muted small" style={{ marginTop: 4 }}>
Usa la fecha actual del servidor como referencia para seleccionar Usa la fecha actual del servidor como referencia para seleccionar
avisos vencidos a 30 y 15 días, y vencidos hace 7 días. avisos vencidos a 30 y 15 días, y vencidos hace 7 días. La
compañía elegida filtra también la lista de abajo: se envía
exactamente lo que está en pantalla.
</p> </p>
</div> </div>
<button <button
@@ -211,7 +260,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
{sweeping ? "Enviando…" : "Ejecutar barrido"} {sweeping ? "Enviando…" : "Ejecutar barrido"}
</button> </button>
</div> </div>
<div className="field" style={{ maxWidth: 180, marginTop: 12, marginBottom: 0 }}> <div
className="row-actions"
style={{ marginTop: 12, alignItems: "flex-end", gap: 16 }}
>
<div className="field" style={{ maxWidth: 180, marginBottom: 0 }}>
<span className="field-label">Ventana (días)</span> <span className="field-label">Ventana (días)</span>
<input <input
className="input" className="input"
@@ -224,6 +277,22 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {
} }
/> />
</div> </div>
<div className="field" style={{ maxWidth: 260, marginBottom: 0 }}>
<span className="field-label">Compañía</span>
<select
className="input"
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
>
<option value="">Todas las compañías</option>
{providers.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
</div>
</section> </section>
{pendingError && <div className="state-box state-error">{pendingError}</div>} {pendingError && <div className="state-box state-error">{pendingError}</div>}
+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;
}
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Delete unused images from the target host after a successful deploy.
*
* This exists because nothing else reclaims them. Every build.yml run pushes a
* new api + web image, every deploy pulls both onto the host, and the previous
* pair is left behind untagged-but-present forever. On galactus that reached
* 63 images / 83.85GB (79.26GB of it unused) and filled the 98GB root
* filesystem to 100% on 2026-08-20 — which surfaced as "re-import is broken",
* because the Operaciones REIMPORT job leads with a mysqldump that could no
* longer write its safety backup.
*
* Two things keep this from eating a live deployment:
*
* - Docker never prunes an image that a container references, running or
* stopped. The five images the prod stacks use are therefore untouchable
* for as long as their containers exist.
* - `until` gives a grace window on top of that, so a rollback target stays
* on disk instead of forcing a re-pull from the registry.
*
* TRAP: `until` filters on the image's CREATION time, not when the host pulled
* it. Rolling back to an old tag pulls an image that is already older than the
* window, so the grace period does NOT protect it — the running-container rule
* is what does. That is why this step must run AFTER the app stack is deployed
* and verified, never before.
*
* Required env:
* PORTAINER_URL, PORTAINER_API_KEY, PORTAINER_ENDPOINT_ID
* Optional:
* KEEP_HOURS grace window in hours (default 168 = 7 days)
*
* TLS: Portainer here is self-signed; the caller sets
* NODE_TLS_REJECT_UNAUTHORIZED=0 for this step.
*/
function required(name) {
const v = process.env[name];
if (!v) {
console.error(`missing required env: ${name}`);
process.exit(1);
}
return v;
}
const PORTAINER_URL = required("PORTAINER_URL").replace(/\/+$/, "");
const API_KEY = required("PORTAINER_API_KEY");
const ENDPOINT_ID = required("PORTAINER_ENDPOINT_ID");
const KEEP_HOURS = process.env.KEEP_HOURS || "168";
const DOCKER = `${PORTAINER_URL}/api/endpoints/${ENDPOINT_ID}/docker`;
// `dangling: ["false"]` is what makes this `docker image prune -a` rather than
// the default, which only collects untagged layers. The tagged-but-superseded
// api/web images are the whole problem, and the default filter walks straight
// past them.
const FILTERS = JSON.stringify({
dangling: ["false"],
until: [`${KEEP_HOURS}h`],
});
function human(bytes) {
if (!bytes) return "0B";
const units = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
let n = bytes;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i += 1;
}
return `${n.toFixed(i === 0 ? 0 : 2)}${units[i]}`;
}
async function main() {
const url = `${DOCKER}/images/prune?filters=${encodeURIComponent(FILTERS)}`;
const res = await fetch(url, {
method: "POST",
headers: { "X-API-Key": API_KEY },
});
const body = await res.text();
if (!res.ok) {
throw new Error(`prune -> HTTP ${res.status} ${body.slice(0, 300)}`);
}
let report;
try {
report = JSON.parse(body);
} catch {
throw new Error(`prune returned non-JSON: ${body.slice(0, 300)}`);
}
const deleted = report.ImagesDeleted ?? [];
const reclaimed = report.SpaceReclaimed ?? 0;
console.log(
`pruned images older than ${KEEP_HOURS}h and unused by any container`,
);
console.log(` entries removed : ${deleted.length}`);
console.log(` space reclaimed : ${human(reclaimed)}`);
}
main().catch((err) => {
// Non-fatal by contract: the step that calls this sets continue-on-error, so
// housekeeping never turns a good deploy red. Exit non-zero anyway so the
// failure is visible in the run rather than swallowed.
console.error(`::warning::image prune FAILED: ${err.message}`);
process.exit(1);
});
+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.24", "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.24", "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")
}
+157 -12
View File
@@ -15,14 +15,27 @@
* is a raw lifetime sum. The platform only migrated the CURRENT-year * is a raw lifetime sum. The platform only migrated the CURRENT-year
* charge ledger (datos2); the per-year charge tables live in DreamHost * charge ledger (datos2); the per-year charge tables live in DreamHost
* and were never staged. What survives before the cutover is therefore * and were never staged. What survives before the cutover is therefore
* the EFECTIVO cash journal — receipts with no matching charges — so * a cash journal — receipts with no matching charges — so those sums read
* those sums read as the office owing money it does not owe. * as the office owing money it does not owe.
* *
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt recorded twice, once in * READ THE COMPOSITION LINE BEFORE ACTING ON THIS. After the prior-period
* EFECTIVO with folio `N` and once in datos2 with reference `CN`. Both * import the group is mostly insurance-only customers whose rows come from
* rows are after the 2026-01-01 floor, so both count. The statement hides * the seguros database's own EFECTIVO, which is that line's ONLY ledger.
* them (STATEMENT_EXCLUDED_SOURCE_TABLES drops EFECTIVO); the balances * Flooring those deletes receipts instead of removing a double count. The
* worklist, the movement browser and the /clientes/:id card do not. * "floor them, never carry" argument holds for the utilities rows alone.
*
* B. DOUBLE-BOOKED 2026 RECEIPTS — one cash receipt appearing twice, once in
* EFECTIVO with folio `N` and once in datos2 with reference `CN`.
*
* This is NOT an office data-entry defect, which is what it looked like
* while the pair count kept growing at ~40/month. EFECTIVO is the paper
* receipt book and every receipt in it is POSTED to the datos2 ledger by
* design — verified against the live legacy database, 296 of the 297
* receipts written in 2026 carry a matching posting. Legacy summed the
* ledger alone. The duplication was the migration flattening a journal and
* its postings into one table, and since 1.0.26 the application drops the
* journal from every balance. Section B now reports what the journal holds
* and asserts that none of it still reaches a balance.
* *
* The folio alone neither proves nor disproves a pair, so it is used as a * The folio alone neither proves nor disproves a pair, so it is used as a
* lead and never as the verdict. Folios are reused, so `C13483` can collide * lead and never as the verdict. Folios are reused, so `C13483` can collide
@@ -82,6 +95,44 @@ const BF_PREDICATE = `(
AND t.legacySourceTable = 'datos2') AND t.legacySourceTable = 'datos2')
)`; )`;
/**
* The rest of what a balance query drops, over and above the floor. Mirrors
* NOT_CASH_JOURNAL and archiveIsHistorySql in billing.service.ts — an audit
* that computes a different book than the application is worse than no audit,
* because its numbers look authoritative and diff cleanly against yesterday's.
*
* The database qualifier is not decoration. `SEGUROS 16_be` keeps its own table
* called EFECTIVO and that one is the insurance line's only ledger; matching on
* the table name alone would report 55,444.95 USD of real receivables as
* duplicate cash. See efectivo-is-a-journal-not-a-ledger.
*/
const NOT_CASH_JOURNAL = `(
t.legacySourceDb IS NULL
OR t.legacySourceDb <> 'UTILITIES'
OR t.legacySourceTable IS NULL
OR t.legacySourceTable NOT IN
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
)`;
/**
* An imported period counts as history below the year start and is dropped at
* or above it. Spelled as a positive OR: `NOT (col LIKE ... AND ...)` is NULL
* for an app-captured row, which would silently drop every one.
*
* The bound is the running calendar year, matching currentYearStart() in the
* application rather than the cutover — the app's current period is "this
* year", whatever cut the data happens to reflect.
*/
const yearStart = `${new Date().getUTCFullYear()}-01-01`;
const ARCHIVE_IS_HISTORY = `(
t.legacySourceTable IS NULL
OR t.legacySourceTable NOT LIKE 'datos2@%'
OR t.transactionDate < '${yearStart}'
)`;
/** Everything a balance drops apart from the floor itself. */
const READ_SCOPE = `(${NOT_CASH_JOURNAL} AND ${ARCHIVE_IS_HISTORY})`;
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const limit = Number(arg(args, "--limit", "25")); const limit = Number(arg(args, "--limit", "25"));
@@ -127,9 +178,13 @@ async function main() {
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn, ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS rawMxn,
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd, ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS rawUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
THEN t.amount ELSE 0 END), 2) AS todayMxn, THEN t.amount ELSE 0 END), 2) AS floorOnlyMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate) ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
THEN t.amount ELSE 0 END), 2) AS todayUsd, THEN t.amount ELSE 0 END), 2) AS floorOnlyUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
AND ${READ_SCOPE} THEN t.amount ELSE 0 END), 2) AS todayUsd,
ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ? ROUND(SUM(CASE WHEN t.currency='MXN' AND t.transactionDate >= ?
THEN t.amount ELSE 0 END), 2) AS flooredMxn, THEN t.amount ELSE 0 END), 2) AS flooredMxn,
ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ? ROUND(SUM(CASE WHEN t.currency='USD' AND t.transactionDate >= ?
@@ -197,6 +252,39 @@ async function main() {
cutover, cutover,
); );
// What the floorless population's balance is actually MADE OF.
//
// "Floor them, never carry" was written when this group looked like
// utilities cash receipts whose charges were never migrated. It is not that
// any more. After the prior-period import the group is 99 customers, and
// almost all of them are insurance-only — their rows come from the seguros
// database's own EFECTIVO, which is that line's ONLY ledger. Nothing posts
// it a second time, so flooring it does not remove a double count, it
// deletes receipts. Split the two so the remedy is chosen per population
// rather than for the group.
const [floorlessMix] = await prisma.$queryRawUnsafe(
`
WITH nobf AS (
SELECT c.id FROM customers c
WHERE EXISTS (SELECT 1 FROM transactions t WHERE t.customerId = c.id AND t.voidedAt IS NULL)
AND NOT EXISTS (
SELECT 1 FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.customerId = c.id AND t.voidedAt IS NULL AND ${BF_PREDICATE}
)
)
SELECT
COUNT(DISTINCT CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' THEN t.customerId END) AS insCusts,
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='MXN'
THEN t.amount ELSE 0 END), 2) AS insMxn,
ROUND(SUM(CASE WHEN t.legacySourceDb = 'SEGUROS 16_be' AND t.currency='USD'
THEN t.amount ELSE 0 END), 2) AS insUsd,
COUNT(DISTINCT CASE WHEN t.legacySourceDb <> 'SEGUROS 16_be' THEN t.customerId END) AS utilCusts
FROM transactions t JOIN nobf n ON n.id = t.customerId
WHERE t.voidedAt IS NULL AND t.outstanding = 0 AND t.transactionDate < ?
`,
cutover,
);
// ---- section B: double-booked receipts --------------------------------- // ---- section B: double-booked receipts ---------------------------------
const pairs = await prisma.$queryRawUnsafe( const pairs = await prisma.$queryRawUnsafe(
` `
@@ -269,6 +357,34 @@ async function main() {
`, `,
); );
// REGRESSION GUARD. Since 1.0.26 the application drops the whole cash
// journal from every balance, so none of section B's rows should reach one
// any more. This counts the ones that still do: it is 0 while the exclusion
// holds, and goes non-zero the moment someone reintroduces a balance query
// that forgets it. A defect list that cannot tell you whether the defect is
// still live is just history.
const [stillCounted] = await prisma.$queryRawUnsafe(
`
WITH bfloor AS (
SELECT t.customerId, MAX(t.transactionDate) AS floorDate
FROM transactions t LEFT JOIN type_transactions tt ON tt.id = t.typeId
WHERE t.voidedAt IS NULL AND ${BF_PREDICATE}
GROUP BY t.customerId
)
SELECT COUNT(*) AS n,
ROUND(SUM(CASE WHEN t.currency='MXN' THEN t.amount ELSE 0 END), 2) AS mxn,
ROUND(SUM(CASE WHEN t.currency='USD' THEN t.amount ELSE 0 END), 2) AS usd
FROM transactions t
LEFT JOIN bfloor b ON b.customerId = t.customerId
WHERE t.voidedAt IS NULL AND t.outstanding = 0
AND t.legacySourceDb = 'UTILITIES'
AND t.legacySourceTable IN
('EFECTIVO', 'EFECTIVO_BACKUP', 'EFECTIVO FM3', 'CHEQUE FM3', 'IVA 2015')
AND (b.floorDate IS NULL OR t.transactionDate >= b.floorDate)
AND ${READ_SCOPE}
`,
);
// ---- CSV escapes ------------------------------------------------------- // ---- CSV escapes -------------------------------------------------------
if (args.includes("--csv-a")) return dumpCsv(floorless); if (args.includes("--csv-a")) return dumpCsv(floorless);
if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]); if (args.includes("--csv-b")) return dumpCsv([...pairs, ...nearby]);
@@ -276,8 +392,15 @@ async function main() {
// ---- report ------------------------------------------------------------ // ---- report ------------------------------------------------------------
console.log("\nBOOK (voided and outstanding rows excluded)"); console.log("\nBOOK (voided and outstanding rows excluded)");
console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`); console.log(` raw lifetime sum, no floor ${money(book.rawMxn)} MXN ${money(book.rawUsd)} USD`);
console.log(` today (per-customer BF floor) ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`); console.log(` BF floor only (pre-1.0.26) ${money(book.floorOnlyMxn)} MXN ${money(book.floorOnlyUsd)} USD`);
console.log(` TODAY, as the app computes it ${money(book.todayMxn)} MXN ${money(book.todayUsd)} USD`);
console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`); console.log(` flat floor at ${cutover} ${money(book.flooredMxn)} MXN ${money(book.flooredUsd)} USD`);
console.log(
" (the middle line is the floor alone, kept only so older runs of this\n" +
" script still diff against something. The app has dropped the cash\n" +
" journal and windowed the archives since 1.0.26; USD going to zero on\n" +
" the utilities side is correct, that ledger is peso-denominated.)",
);
const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0); const preRowsTotal = floorless.reduce((s, r) => s + d(r.preRows), 0);
const wouldZero = floorless.filter((r) => d(r.postRows) === 0); const wouldZero = floorless.filter((r) => d(r.postRows) === 0);
@@ -291,6 +414,17 @@ async function main() {
console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`); console.log(` balance moved by flooring: ${money(-deltaMxn)} MXN ${money(-deltaUsd)} USD`);
console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` + console.log(` customers left with NO rows at all after the cut: ${wouldZero.length}` +
` (their balance becomes 0 — an assertion, not a migrated figure)`); ` (their balance becomes 0 — an assertion, not a migrated figure)`);
console.log(
` of the ${floorless.length}: ${d(floorlessMix.insCusts)} carry insurance-line cash` +
` (${d(floorlessMix.insMxn).toFixed(2)} MXN / ${d(floorlessMix.insUsd).toFixed(2)} USD)` +
`, ${d(floorlessMix.utilCusts)} carry utilities rows`,
);
console.log(
` READ THAT LINE BEFORE FLOORING ANYONE. The seguros EFECTIVO is that\n` +
` line's only ledger — nothing posts it twice — so flooring those\n` +
` customers deletes receipts rather than removing a double count.\n` +
` The argument for flooring holds for the utilities rows alone.`,
);
console.log( console.log(
`\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` + `\n ${"customer".padEnd(30)} ${"pre".padStart(4)} ${"post".padStart(4)}` +
` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`, ` ${"today MXN".padStart(13)} ${"after MXN".padStart(13)} ${"first tx".padStart(10)}`,
@@ -347,6 +481,16 @@ async function main() {
console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.length}`); console.log(` confirmed USD receipt posted to datos2 in MXN: ${converted.length}`);
console.log(` datos2 C-refs with no EFECTIVO partner at all: ${d(unpaired.n)}`); console.log(` datos2 C-refs with no EFECTIVO partner at all: ${d(unpaired.n)}`);
console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`); console.log(` EFECTIVO side of the confirmed pairs: ${money(efecMxn)} MXN ${money(efecUsd)} USD`);
console.log(
` still reaching a balance after the 1.0.26 exclusion: ${d(stillCounted.n)} rows` +
` (${d(stillCounted.mxn).toFixed(2)} MXN / ${d(stillCounted.usd).toFixed(2)} USD)` +
`${d(stillCounted.n) === 0 ? " <- 0 is the passing value" : " <- REGRESSION"}`,
);
console.log(
` The rows below still exist and always will; the ledger's own C<folio>\n` +
` posting is the copy that counts. This section is now a record of what\n` +
` the journal holds, not a list of money being double-counted.`,
);
console.log( console.log(
`\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` + `\n ${"customer".padEnd(28)} ${"datos2".padStart(10)} ${"efectivo".padStart(10)}` +
` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`, ` ${"datos2 amt".padStart(13)} ${"efectivo amt".padStart(13)} ref`,
@@ -382,8 +526,9 @@ async function main() {
"\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" + "\nNOTE: nothing above has been changed. Section A is a proposal to move the\n" +
"floor, not a carried-forward balance: the pre-cutover charge ledger was\n" + "floor, not a carried-forward balance: the pre-cutover charge ledger was\n" +
"never migrated, so no true opening balance can be computed from this\n" + "never migrated, so no true opening balance can be computed from this\n" +
"database. It exists in the DreamHost per-year tables. Section B is an\n" + "database. It exists in the DreamHost per-year tables. Section B is no\n" +
"independent defect and does not need a corte to fix.", "longer an open defect — it was fixed read-side in 1.0.26 — and the line\n" +
"that matters there is the regression count, which must stay at 0.",
); );
} finally { } finally {
await prisma.$disconnect(); await prisma.$disconnect();