Forward implementation spec covering the legacy "Editor" receipt-capture workflow plus three net-new requests from the 2026-07-25/26 meeting with Jorge: PDF/OCR auto-capture, multi-bank chequera support, and customer-number recycling. Matching logic and data-model gaps for each were verified against the actual migration scripts and API code, not just the schema comments. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
796 lines
40 KiB
Markdown
796 lines
40 KiB
Markdown
# Receipt Capture ("Editor") & Related Net-New Features — Implementation Spec
|
||
|
||
Source: Jorge Cuadros meeting notes, 2026-07-25/26 (`Utility Management` section)
|
||
plus a business-logic read-through of `UTILITIES.accdb`'s legacy "Editor"
|
||
workflow (`docs/LEGACY_DATABASES_OBJECTS.md`, `docs/LEGACY_DATABASES.md`).
|
||
This is a forward spec for work **not yet built**, not a record of what
|
||
exists — contrast with `RENEWAL_NOTICES.md`, which documents a legacy
|
||
workflow already migrated.
|
||
|
||
## Why these four features are one spec
|
||
|
||
The meeting covered one legacy workflow (receipt capture, the "Editor") plus
|
||
three requests that have no legacy precedent at all. They're specified
|
||
together because they compose:
|
||
|
||
1. **Receipt capture module** — the direct replacement for the legacy
|
||
"Editor" screens, extending what's already built in `billing/`.
|
||
2. **PDF/OCR auto-capture** — a new intake path that feeds *into* module 1
|
||
(an OCR-confirmed statement becomes a captured receipt, not a separate
|
||
ledger).
|
||
3. **Multi-bank chequera** — changes what a captured receipt's check number
|
||
reconciles against (module 1's check-reconciliation view needs to know
|
||
*which bank account* a check was drawn on).
|
||
4. **Customer-number recycling** — changes how a customer is created, which
|
||
is the first step of capturing a receipt for them (module 1's customer
|
||
picker needs to respect whatever number a recycled customer was assigned).
|
||
|
||
Each section below is independently buildable and independently useful, but
|
||
1 should land before 2 (2 posts through 1's API), and 4 is fully
|
||
independent of the other three.
|
||
|
||
---
|
||
|
||
## 1. Receipt capture module (the "Editor" replacement)
|
||
|
||
### What's already built (do not re-build)
|
||
|
||
`apps/api/src/billing/` + `apps/web` `MovementForm.tsx` already provide
|
||
single-movement manual capture: `POST /billing` (`ledger:create`) creates one
|
||
signed `Transaction` row with customer, domain, amount, currency, date,
|
||
concept (`typeId`), period, reference, check number, and message; `POST
|
||
/billing/:id/void` (`ledger:void`) reverses one. This is the direct
|
||
equivalent of the legacy `DATOS AGUA`/`DATOS LUZ`/`DATOS TEL`/`DATOS CLAVE`
|
||
per-service capture screens, already generalized into one form — **the
|
||
per-service screens do not need to be rebuilt separately**; `domain` +
|
||
`typeId` (from `TypeTransaction`) already carry that distinction, and the
|
||
capture form can pre-select a concept when opened from a property's service
|
||
tab.
|
||
|
||
What's missing is everything the legacy system did *around* that single
|
||
capture: batching many receipts against one check, separating out unpaid
|
||
items, and reconciling a check's total against what was captured against it.
|
||
|
||
### 1.1 Outstanding ("NOPAGO") workflow
|
||
|
||
`Transaction.outstanding` already exists in the Prisma schema but nothing
|
||
reads or writes it yet.
|
||
|
||
- **`CreateMovementDto`** (`billing/movement.dto.ts`): add `outstanding?:
|
||
boolean`, default `false`. When `true`, the movement posts normally but is
|
||
excluded from "settled" balance views — mirrors the legacy `SALDOS ULTIMO
|
||
0` query, which already `HAVING NOPAGO = 0`s outstanding rows out of the
|
||
balance.
|
||
- **`MovementForm.tsx`**: add an "Outstanding (sin fondos)" checkbox, shown
|
||
only for `domain = UTILITY` charge-direction rows (this is a per-service
|
||
charge concept, not a credit/payment concept).
|
||
- **New endpoint** `POST /billing/:id/resolve-outstanding` (`ledger:create` —
|
||
same tier as capture, since resolving is completing a capture, not
|
||
reversing one). Body: `{ checkNumber: string, resolvedDate: string }`.
|
||
Sets `outstanding = false`, `checkNumber`, and updates `transactionDate` to
|
||
`resolvedDate` — this is the literal legacy behavior ("se actualiza
|
||
registro con fecha del día y el cheque a pagar y quitas outstanding").
|
||
Reject (400) if the row is already resolved or voided.
|
||
- **New list filter**: `GET /billing?outstanding=true` (extend
|
||
`MovementParams`) — replaces the legacy `EDITA NO PAGO AGUA/LUZ/PHONE`
|
||
per-service outstanding screens with one filterable view (service already
|
||
filterable via `typeId`).
|
||
- **Web**: an "Outstanding" tab or filter chip on `/estado-cuenta`
|
||
(Movimientos tab), each row showing a "Resolver" action that opens a small
|
||
form for check number + date.
|
||
|
||
### 1.2 Batch capture by check
|
||
|
||
Legacy staff key many customers' receipts against one check before cutting
|
||
it, then verify the captured total matches the check amount
|
||
(`CAPTURA AGUA`/`CAPTURA LUZ`/etc. feeding into `EDITA CHEQUE COUNT`/
|
||
`REPORTE POR CHEQUE`). Model this as a **bulk-create, not a new persisted
|
||
entity** — a check number is already a plain field on `Transaction`; there's
|
||
no need for a `ReceiptBatch` table when grouping by `checkNumber` already
|
||
answers every legacy query.
|
||
|
||
- **New endpoint** `POST /billing/batch` (`ledger:create`). Body: `{
|
||
domain, typeId, transactionDate, currency, checkNumber, lines: [{
|
||
customerId, amount, reference, period, outstanding? }] }` — the
|
||
check-level fields are shared, only the per-customer fields repeat. Runs
|
||
as one Prisma `$transaction`, returns the created rows plus `{ total,
|
||
count }` so the UI can show the running total against the physical check
|
||
amount as staff add lines, exactly matching the legacy reconciliation
|
||
practice.
|
||
- **Web**: a `/estado-cuenta/captura` (or `/estado-cuenta/lote`) page — a
|
||
service-type + check-number header, then a repeating row (customer picker
|
||
+ reference + amount + outstanding toggle), a running total, and a single
|
||
submit. This is the actual "Editor" screen the meeting notes are asking
|
||
for; it should be reachable from a new nav entry under "Estado de cuenta"
|
||
or "Servicios."
|
||
|
||
### 1.3 Check reconciliation view
|
||
|
||
Direct replacement for `EDITA CHEQUE ALF/COUNT/NUM`, `REPORTE POR CHEQUE`,
|
||
`REPORTE POR CHEQUE PARA ALFA`, and the `REPORTE CHEQUE COUNT` report named
|
||
explicitly in the meeting notes.
|
||
|
||
- **New endpoint** `GET /billing/by-check?checkNumber=...` — all
|
||
non-voided `Transaction` rows with that `checkNumber`, plus `{ total,
|
||
count }`. Trivial query, no new indexes needed beyond the existing
|
||
`checkNumber` column (add a plain index — it's currently unindexed).
|
||
- **New report entry** in `reports.registry.ts`: `slug: "cheque-count"`,
|
||
`legacyName: "REPORTE CHEQUE COUNT"`, params `{ checkNumber: text }`,
|
||
columns customer/reference/period/concept/amount, reusing the by-check
|
||
query above. This gives it a printable form for free via the existing
|
||
`/reportes/:slug` machinery — no new page needed.
|
||
|
||
### Abilities
|
||
|
||
No new abilities required — everything above reuses `ledger:create` /
|
||
`ledger:void` (already `STAFF` / `MANAGER`). Batch capture and outstanding
|
||
resolution are both "capturing a receipt," same trust tier as the existing
|
||
single-movement form.
|
||
|
||
---
|
||
|
||
## 2. PDF / OCR auto-capture
|
||
|
||
### Motivation (from the meeting)
|
||
|
||
Each utility company (CFE, water, phone, gas...) sends 300+ individual
|
||
statements a month, one per customer, currently keyed in by hand through the
|
||
per-service capture screens — a high-volume, error-prone manual step. The
|
||
ask: scan/receive the statements as PDF(s), have the system determine
|
||
customer + amount automatically, and only require staff review rather than
|
||
full manual entry.
|
||
|
||
### Pipeline
|
||
|
||
```
|
||
Upload (1+ PDFs, one service kind per batch)
|
||
-> StorageService stores raw file(s)
|
||
-> Split into one document per statement (if a batch PDF bundles multiple)
|
||
-> OCR extraction (account/meter number, amount, period, due date)
|
||
-> Auto-match against PropertyService (accountNumber / meterNumber / route)
|
||
-> Review queue: high-confidence matches pre-filled, no-match/low-confidence flagged
|
||
-> Staff confirms (bulk-confirm high-confidence rows, hand-correct the rest)
|
||
-> Confirmed rows post through the SAME batch-capture path as §1.2
|
||
-> Source PDF page attached as a ServiceDocument on the matched property
|
||
```
|
||
|
||
The last two steps deliberately reuse §1.2's batch-capture endpoint rather
|
||
than writing `Transaction` rows directly — OCR-sourced and hand-keyed
|
||
receipts should go through one write path, one validation path, one audit
|
||
trail.
|
||
|
||
### Data model (new)
|
||
|
||
```prisma
|
||
enum StatementBatchStatus {
|
||
UPLOADED
|
||
PROCESSING
|
||
READY_FOR_REVIEW
|
||
COMPLETED
|
||
FAILED
|
||
}
|
||
|
||
enum StatementDocumentStatus {
|
||
PENDING_OCR
|
||
OCR_FAILED
|
||
NEEDS_REVIEW // no confident match, or low OCR confidence
|
||
MATCHED // confident auto-match, awaiting staff confirmation
|
||
CONFIRMED // staff confirmed, not yet posted
|
||
POSTED // posted as a Transaction
|
||
REJECTED // staff rejected (duplicate, unreadable, wrong batch)
|
||
}
|
||
|
||
// `ServiceKind` needs one addition for this feature: `TELEPHONE`. It
|
||
// doesn't exist today — phone numbers live on `Property.phone1/2/3`, not as
|
||
// `PropertyService` rows. See "Matching logic" below for why OCR matching
|
||
// needs it as a real service kind, and the backfill this implies.
|
||
|
||
/// One upload session — e.g. "October CFE statements."
|
||
model StatementBatch {
|
||
id String @id @default(uuid())
|
||
serviceKind ServiceKind
|
||
status StatementBatchStatus @default(UPLOADED)
|
||
uploadedById String
|
||
fileCount Int
|
||
createdAt DateTime @default(now())
|
||
documents StatementDocument[]
|
||
|
||
@@map("statement_batches")
|
||
}
|
||
|
||
/// One statement (one customer, one period) after splitting the batch.
|
||
model StatementDocument {
|
||
id String @id @default(uuid())
|
||
batchId String
|
||
batch StatementBatch @relation(fields: [batchId], references: [id])
|
||
storageKey String
|
||
status StatementDocumentStatus @default(PENDING_OCR)
|
||
|
||
// Raw OCR output, kept even after a manual correction so mismatches are
|
||
// auditable.
|
||
ocrRawText String? @db.Text
|
||
ocrConfidence Decimal? @db.Decimal(4, 3)
|
||
|
||
// Extracted (and, after review, staff-corrected) fields.
|
||
extractedAccountRef String? // RPU / phone / water account / zona fed / clave catastral / gas meter — see "Matching logic" for which PropertyService field this maps to per serviceKind
|
||
extractedAmount Decimal? @db.Decimal(12, 2)
|
||
extractedPeriod String?
|
||
extractedDueDate DateTime?
|
||
|
||
// Match result.
|
||
matchedPropertyServiceId String?
|
||
matchedPropertyService PropertyService? @relation(fields: [matchedPropertyServiceId], references: [id])
|
||
matchedCustomerId String?
|
||
matchedCustomer Customer? @relation(fields: [matchedCustomerId], references: [id])
|
||
|
||
reviewedById String?
|
||
reviewedAt DateTime?
|
||
postedTransactionId String? @unique
|
||
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@map("statement_documents")
|
||
}
|
||
```
|
||
|
||
(`PropertyService`/`Customer`/`Transaction` gain the inverse relations.
|
||
`ServiceDocument` is reused as-is for the confirmed receipt's permanent
|
||
attachment — `StatementDocument.storageKey` and the eventual
|
||
`ServiceDocument.storageKey` may point at the same object, or the confirm
|
||
step copies it; either is fine, pick whichever is simpler at build time.)
|
||
|
||
### Matching logic
|
||
|
||
Match `extractedAccountRef` against **one specific `PropertyService` field,
|
||
chosen by `serviceKind`** — never a fuzzy match across all of
|
||
`accountNumber`/`meterNumber`/`route` at once, since that's how a water
|
||
account number could accidentally collide with an unrelated phone number.
|
||
Per the meeting notes' own field list:
|
||
|
||
| Service (meeting note) | `ServiceKind` | Match against | Legacy source (`DATMEX`) | Status |
|
||
|---|---|---|---|---|
|
||
| CFE — RPU | `ELECTRIC` | `accountNumber` | `RPU` / `RPU2` / `RPU3` | ✅ populated today (`transform_properties.py`) |
|
||
| Agua — Número de cuenta | `WATER` | `accountNumber` | `AGUA` | ✅ populated today |
|
||
| Zona Fed — Número de Zona Federal | `FEDERAL_ZONE` | `accountNumber` | `ZFED` | ✅ populated today |
|
||
| Tel — Número de teléfono | `TELEPHONE` *(new)* | `accountNumber` | `Property.phone1/2/3` (currently on `Property`, not `PropertyService`) | ⚠️ schema gap — see below |
|
||
| Impuesto — Clave Catastral | `PROPERTY_TAX` | `accountNumber` | migrated from `PREDIAL`, **not** `CLAVE` | ⚠️ needs verification — see below |
|
||
| Gas — Número de medidor | `GAS` | `meterNumber` | not populated — folded into free-text `notes` today | ⚠️ data gap — see below |
|
||
|
||
Confidence rule of thumb once a field is confirmed populated, tune after
|
||
seeing real statements:
|
||
|
||
- Exact match on the scoped field → `MATCHED`, high confidence, pre-checked
|
||
for bulk-confirm.
|
||
- No match, or the OCR confidence itself is low → `NEEDS_REVIEW`.
|
||
- Multiple candidate matches (shouldn't happen if account numbers are
|
||
unique, but the legacy data has had duplication issues before — see
|
||
`docs/LEGACY_DATABASES_OBJECTS.md`'s `DUPLICADOS` report) → `NEEDS_REVIEW`
|
||
with all candidates surfaced, not an arbitrary pick.
|
||
|
||
#### Three gaps this depends on — resolve before building the matcher
|
||
|
||
Cross-checking the requested field list against `transform_properties.py`
|
||
(the script that actually populated today's `property_services` table)
|
||
surfaced three mismatches. OCR matching is only as good as the field it
|
||
matches against, so these need to be closed first, not discovered mid-build:
|
||
|
||
1. **No `TELEPHONE` service kind exists.** `ServiceKind` today is `WATER |
|
||
ELECTRIC | GAS | CABLE | PROPERTY_TAX | FEDERAL_ZONE | ALARM | OTHER` —
|
||
telephone was never unpivoted into `PropertyService` at all; the three
|
||
phone numbers live directly on `Property.phone1/phone2/phone3` (raw
|
||
contact fields, not billable-service rows), even though the legacy
|
||
ledger clearly bills phone as its own `TYPE OF TRX = "TELEPHONE"` (see
|
||
`EDITA NO PAGO PHONE`, `DATOS TEL`/`CAPTURA TEL` in the prior analysis).
|
||
**Fix:** add `TELEPHONE` to the `ServiceKind` enum, and backfill one
|
||
`PropertyService` row per non-null `Property.phone1/2/3` (`kind:
|
||
TELEPHONE, accountNumber: <the phone number>`) as a one-time migration
|
||
script companion to this feature — mirrors how `transform_properties.py`
|
||
already emits multiple `WATER` rows per property for secondary meters.
|
||
|
||
2. **`PROPERTY_TAX.accountNumber` holds `PREDIAL`, not `CLAVE`.** The
|
||
meeting notes name "Clave Catastral" specifically, and the legacy
|
||
query/report names agree (`CAPTURA CLAVE`, `DATOS CLAVE`, `CLAVES
|
||
CATASTRALES`, `CATASTRO` — all filter on `DATMEX.CLAVE`). But
|
||
`transform_properties.py` line ~191 sets `PROPERTY_TAX.accountNumber =
|
||
DATMEX.PREDIAL`, a *different* column (`DATMEX` has both `CLAVE`
|
||
VARCHAR and `PREDIAL` DOUBLE). Two live possibilities: either `PREDIAL`
|
||
is the wrong field and the migration should have used `CLAVE`, or they're
|
||
two genuinely different numbers (e.g. clave catastral = the cadastral
|
||
lookup key stamped on the printed bill vs. predial = an internal
|
||
receipt/folio number) and `PropertyService` needs *both* — only one of
|
||
which (the clave catastral) is what OCR will actually read off a real
|
||
predial statement. **Needs a real predial receipt in hand (or Jorge's
|
||
confirmation) before deciding**; don't wire the matcher to `PREDIAL` on
|
||
the untested assumption it's the same thing.
|
||
|
||
3. **`GAS.meterNumber` is never populated.** `transform_properties.py`
|
||
only ever sets `notes = DATMEX.GAS` for gas service rows — there's no
|
||
distinct meter-number column in the legacy `DATMEX` table for gas at
|
||
all (unlike electric/water, which have `RPU`/`MEDIDOR`). This matches
|
||
the earlier finding that "Gas – Número de medidor" has no legacy source
|
||
field. **This can't be backfilled from existing data** — the practical
|
||
fix is that gas OCR matching starts cold (every gas statement lands in
|
||
`NEEDS_REVIEW` until a human confirms it once), and *that first
|
||
confirmation* is what populates `PropertyService.meterNumber` for that
|
||
property going forward, so subsequent statements for the same meter
|
||
auto-match. Worth calling out in the review-queue UI ("first time
|
||
seeing this meter — confirm to enable auto-match next time").
|
||
|
||
### OCR provider — open decision, don't build against one prematurely
|
||
|
||
CFE (and most MX utility) bills are **fixed-layout, single-language,
|
||
high-volume forms**, not arbitrary documents — this is closer to
|
||
"template/anchor text extraction" (regex against OCR'd text for known
|
||
labels like `RPU`, `No. de Cuenta`, `Total a pagar`) than to a full ML
|
||
document-understanding problem. Recommend:
|
||
|
||
- Define an `OcrProvider` interface (`extract(buffer, hints): Promise<{
|
||
text: string, fields: ExtractedFields, confidence: number }>`) so the
|
||
concrete engine is swappable.
|
||
- Start with a self-hosted OCR (e.g. Tesseract) + hand-written per-company
|
||
extraction rules (one rule set per `ServiceKind`/provider, since CFE's
|
||
layout differs from the water company's). Cheap, no per-page cost, and the
|
||
layouts are stable enough that this is realistic.
|
||
- Escalate to a managed document-extraction API (AWS Textract, Azure
|
||
Document Intelligence, Google Document AI) only if the self-hosted
|
||
accuracy proves too low in practice — all three fit behind the same
|
||
interface with no schema changes.
|
||
- **This choice needs Jorge's input on budget/volume before committing** —
|
||
300+ pages/month/company is enough volume that a per-page-priced API has a
|
||
real recurring cost.
|
||
|
||
### API surface
|
||
|
||
- `POST /statements/batches` (`statement:ingest`) — multipart upload, one or
|
||
more PDFs + `serviceKind`. Creates the batch, kicks off async
|
||
split+OCR+match (background job, not inline in the request).
|
||
- `GET /statements/batches` / `GET /statements/batches/:id` — status +
|
||
document list.
|
||
- `GET /statements/batches/:id/documents?status=NEEDS_REVIEW` — the review
|
||
queue.
|
||
- `PATCH /statements/documents/:id` (`statement:review`) — staff correction
|
||
of extracted fields or match.
|
||
- `POST /statements/documents/:id/confirm` (`statement:review`) — single
|
||
confirm.
|
||
- `POST /statements/batches/:id/confirm-matched` (`statement:review`) —
|
||
bulk-confirm every `MATCHED` document in one call.
|
||
- `POST /statements/documents/:id/reject` (`statement:review`).
|
||
- Confirming posts through §1.2's batch-capture internals (same service
|
||
method, not the HTTP endpoint) so it's one transaction per batch of
|
||
confirms, not N.
|
||
- **Confirm also backfills the matched field when it was empty** — if
|
||
`matchedPropertyServiceId` was set by staff (not by an exact auto-match)
|
||
because the scoped field was blank on that `PropertyService` (the `GAS`
|
||
case above, and any one-off historical gap in the other kinds), write
|
||
`extractedAccountRef` into that service's `accountNumber`/`meterNumber`
|
||
as part of the confirm transaction. This is what makes the "first
|
||
confirmation teaches the matcher" behavior in gap 3 above actually work,
|
||
rather than requiring a separate manual data-entry pass.
|
||
|
||
### Abilities (new)
|
||
|
||
| Ability | Min role | Notes |
|
||
|---|---|---|
|
||
| `statement:ingest` | STAFF | upload a batch |
|
||
| `statement:review` | STAFF | correct/confirm/reject; same tier as `ledger:create` since confirming *is* capturing |
|
||
|
||
### Open questions
|
||
|
||
- **Clave catastral vs. predial** (gap 2 above) — get a real predial
|
||
statement or Jorge's confirmation of whether `CLAVE` and `PREDIAL` are the
|
||
same number before wiring the `PROPERTY_TAX` matcher. Blocks that one
|
||
service kind, not the whole feature.
|
||
- **Telephone as a service kind** (gap 1 above) — confirm the backfill
|
||
approach (one `PropertyService` row per populated `Property.phone1/2/3`)
|
||
is correct, and whether a property with all three phones populated should
|
||
really produce three separate billable "services," or whether phone
|
||
billing is actually 1-per-property regardless of how many numbers are on
|
||
file (would change the backfill to pick a primary number instead of
|
||
fanning out to three rows).
|
||
- Multi-statement PDF splitting: does the source ever arrive as one PDF per
|
||
customer already (simplifies to "batch = folder of PDFs"), or as one
|
||
giant PDF per company per month that needs page-range splitting? Changes
|
||
whether a page-boundary detector is needed at all.
|
||
- Retention: keep `StatementDocument.storageKey` (and the raw OCR text)
|
||
indefinitely for audit, or purge after posting since `ServiceDocument`
|
||
already holds the permanent copy? Recommend keep — cheap, and it's the
|
||
audit trail for "why did the system think this was customer X."
|
||
|
||
---
|
||
|
||
## 3. Multi-bank chequera
|
||
|
||
### Motivation
|
||
|
||
Seguros uses a US bank account; Utilities uses a Mexican bank account. The
|
||
current `BankTransaction` model (migrated from `SCOTHIA.mdb`) has no bank or
|
||
currency dimension at all — it's a single implicit account, MXN-only, by
|
||
design (see `PLAN.md` migration step 7 finding (c)). Need to support more
|
||
than one register, each with its own bank and currency.
|
||
|
||
Confirmed against the actual code, not just the schema comment: this is a
|
||
real, deliberate, load-bearing assumption, not an oversight to patch around.
|
||
`migration/transform_bank.py` has no bank/currency column to read in the
|
||
first place — `DATOS I`/`DATOS E` are `fecha, tipo, num, concepto,
|
||
ingreso/egreso, operado, notas, cantidad_en_letra`, nothing else. And
|
||
`bank.service.ts`'s module doc-comment states outright: "SINGLE CURRENCY...
|
||
`bank_transactions` has none, and every `amountInWords` on the egreso side
|
||
is spelled out in PESOS. All figures in this module are MXN." Every method
|
||
in that file — `where()`, `totalsFor()`, `facets()`, `summary()`, `stats()`,
|
||
`createMovement()` — currently has zero notion of "which account." That's
|
||
the actual surface area this feature touches, itemized below.
|
||
|
||
### Data model changes
|
||
|
||
```prisma
|
||
model Bank {
|
||
id String @id @default(uuid())
|
||
name String @unique // e.g. "Scotiabank", "Bank of America"
|
||
country String? // "MX" | "US" — informational
|
||
accounts BankAccount[]
|
||
|
||
@@map("banks")
|
||
}
|
||
|
||
/// One physical chequera. Currency is fixed per account (real bank
|
||
/// accounts don't mix currencies) — do NOT add a currency filter to
|
||
/// BankTransaction itself; it inherits the account's currency.
|
||
model BankAccount {
|
||
id String @id @default(uuid())
|
||
bankId String
|
||
bank Bank @relation(fields: [bankId], references: [id])
|
||
label String // "Utilities operating (MXN)", "Seguros operating (USD)"
|
||
currency Currency
|
||
businessLine TransactionDomain? // hint only, not enforced — a chequera can pay for more than one line
|
||
active Boolean @default(true)
|
||
movements BankTransaction[]
|
||
|
||
@@map("bank_accounts")
|
||
}
|
||
```
|
||
|
||
`BankTransaction` gains:
|
||
|
||
```prisma
|
||
model BankTransaction {
|
||
// ...existing fields...
|
||
bankAccountId String
|
||
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
|
||
}
|
||
```
|
||
|
||
`bankAccountId` should be **required**, not optional — a bank movement
|
||
without a known account isn't meaningfully reconcilable. This means the
|
||
migration step below has to run before the column goes non-null.
|
||
|
||
### Migration of existing data
|
||
|
||
All 22,354 existing `BankTransaction` rows are SCOTHIA data — MXN, single
|
||
bank. Before making `bankAccountId` required:
|
||
|
||
1. Insert one `Bank` row for Scotiabank, one `BankAccount` row under it
|
||
(`label: "Utilities — Scotiabank (MXN)"`, `currency: MXN`,
|
||
`businessLine: UTILITY`).
|
||
2. Backfill every existing `BankTransaction.bankAccountId` to that account's
|
||
id.
|
||
3. Add the second account (`"Seguros — <bank TBD> (USD)"`) — **needs the
|
||
actual US bank name from Jorge**, plus whether historical Seguros bank
|
||
data exists anywhere to migrate (the current inventory has no Seguros
|
||
bank register file — only `SCOTHIA.mdb`, which is Utilities' own book,
|
||
per `PLAN.md`'s source inventory). If no historical USD register exists,
|
||
this account starts empty and only carries movements captured going
|
||
forward.
|
||
|
||
The existing `@@unique([legacySourceTable, legacyId])` on `BankTransaction`
|
||
needs no change — every legacy row only ever belongs to the one Scotiabank
|
||
account being backfilled in step 2, so provenance uniqueness still holds
|
||
per-row regardless of how many accounts exist afterward.
|
||
|
||
### Code touch points (`bank.service.ts`, `bank.controller.ts`)
|
||
|
||
This module currently has **no filterable dimension at all** beyond
|
||
direction/cleared/date — every account-scoping change is additive, not a
|
||
rewrite, but it touches every read method because two of them
|
||
(`facets()`, `summary()`) bypass the Prisma query builder entirely and use
|
||
hand-written `$queryRaw` template SQL:
|
||
|
||
- **`where()`** — trivial, add `bankAccountId` to the `AND` array like any
|
||
other filter (Prisma builder, same pattern as `direction`/`cleared`).
|
||
- **`totalsFor()`** — takes the already-built `where`, so it inherits the
|
||
scoping for free once `list()`/`stats()` pass a scoped `where` in.
|
||
- **`facets()`** — currently `SELECT YEAR(transactionDate)... FROM
|
||
bank_transactions WHERE voidedAt IS NULL` with no account clause at all;
|
||
needs `AND bankAccountId = ${accountId}` interpolated into the raw SQL
|
||
(parameterized, not string-concatenated — this file already uses Prisma's
|
||
tagged-template `$queryRaw`, which parameterizes automatically as long as
|
||
the account id is passed as a template value, not spliced into the string
|
||
by hand).
|
||
- **`summary()`** — same issue, in *two* raw queries (the yearly rollup and
|
||
the monthly rollup when a year is selected) — both need the same
|
||
`AND bankAccountId = ${accountId}` clause. Miss one and the "Resumen"
|
||
tab's year list and its drill-down would scope to different accounts,
|
||
which is a worse bug than not scoping at all (looks correct, silently
|
||
wrong).
|
||
- **`stats()`** — currently calls `totalsFor({})` (empty filter = every
|
||
row). Needs `totalsFor({ bankAccountId })`; same for the `count`/`bounds`/
|
||
`pending`/`transferred` aggregates alongside it.
|
||
- **`createMovement()` / `voidMovement()`** — `createMovement` needs
|
||
`bankAccountId` added to the `data` object (from the new required DTO
|
||
field below); `voidMovement` needs no change — it already operates by row
|
||
`id`, and a voided row's account never changes.
|
||
|
||
### API surface changes
|
||
|
||
- `bank.controller.ts`: every route (`list`, `summary`, `stats`, `facets`)
|
||
gains a required `?bankAccountId=` query param, threaded through to the
|
||
service methods above. **Required, not optional with an "all accounts"
|
||
default** — summing MXN and USD registers together would repeat the exact
|
||
currency-collapsing mistake the billing module's header comment
|
||
explicitly warns against (912 customers with both-currency ledgers).
|
||
There is no meaningful "no account selected" state once accounts exist,
|
||
only "no account selected *yet*" while the UI loads its default.
|
||
- New `bank/accounts` sub-resource: `GET /bank/accounts` (list, any
|
||
authenticated user — the account picker needs this before anything else
|
||
can render), `POST /bank/accounts` / `PATCH /bank/accounts/:id`
|
||
(`bank:manage-accounts`, MANAGER — creating/editing accounts is rarer and
|
||
higher-stakes than posting movements).
|
||
- `CreateBankMovementDto` gains a required `bankAccountId: string`.
|
||
|
||
### Web
|
||
|
||
- `bank/page.tsx`'s own doc-comment currently states "Single currency
|
||
(MXN) — the source has no currency column" as a design fact; that
|
||
comment (and the assumption behind it) needs to be removed/rewritten as
|
||
part of this change, not just the UI.
|
||
- `/banco` gains an account selector (tabs or a dropdown) at the top,
|
||
scoping both the "Movimientos" and "Resumen por periodo" tabs — mirrors
|
||
how `/estado-cuenta` already scopes by currency without ever summing
|
||
across it. Every existing call site in `lib/api.ts`
|
||
(`listBankMovements`, `getBankStats`, `getBankSummary`, `getBankFacets`,
|
||
`createBankMovement`) needs the new `bankAccountId` parameter threaded
|
||
through, and `lib/types.ts`'s `CreateBankMovementInput` gains the field.
|
||
- New `/banco/cuentas` (or a section under `/catalogos`) for managing banks
|
||
and accounts, gated the same way `/catalogos` already gates lookup
|
||
management.
|
||
|
||
### Abilities (new)
|
||
|
||
| Ability | Min role | Notes |
|
||
|---|---|---|
|
||
| `bank:manage-accounts` | MANAGER | create/edit `Bank`/`BankAccount` rows |
|
||
|
||
### Open questions
|
||
|
||
- Confirm the actual US bank name/details for the Seguros account.
|
||
- Does Seguros have *any* historical bank register data to migrate, or does
|
||
this start from zero on cutover?
|
||
- Should `businessLine` on `BankAccount` be enforced (a UTILITY account
|
||
can't post an INSURANCE movement) or left as a soft hint? Recommend soft —
|
||
the legacy single account already mixed concerns per `PLAN.md`'s finding
|
||
that `TABLA RAMODOS` wasn't a clean business-line split.
|
||
|
||
---
|
||
|
||
## 4. Customer-number recycling
|
||
|
||
### Motivation
|
||
|
||
The physical folder system is organized by `Customer` number
|
||
(`DATGRAL.[NUM id]` in the legacy data, currently only preserved as a
|
||
`CustomerLegacyRef` string, not a first-class field). When a customer
|
||
cancels, doesn't renew, or goes a year with no activity, staff currently
|
||
*manually* hunt for such customers, purge their folder, and reuse the
|
||
number for a new customer. The ask: keep the physical-folder-compatible
|
||
sequential numbering, but automate the search for reusable numbers and
|
||
auto-assign the lowest free one at creation — a Claude Code equivalent of
|
||
"find the first empty spot."
|
||
|
||
### Data model changes
|
||
|
||
```prisma
|
||
model Customer {
|
||
// ...existing fields...
|
||
customerNumber Int? @unique // the physical-folder number; null = not yet assigned (shouldn't happen post-migration) or released
|
||
numberReleasedAt DateTime? // non-null once the number has been freed for reuse; customerNumber is cleared at the same time (see below)
|
||
}
|
||
|
||
enum NumberReleaseReason {
|
||
CANCELLED // explicit non-renewal / service cancellation
|
||
INACTIVITY // >= 1 year with no ledger activity
|
||
MANUAL
|
||
}
|
||
|
||
/// Audit trail for a recycled number, surviving the Customer row it came
|
||
/// from being archived/purged. customerId is nullable so history remains
|
||
/// readable even if the originating customer is later hard-deleted.
|
||
model CustomerNumberHistory {
|
||
id String @id @default(uuid())
|
||
customerNumber Int
|
||
customerId String?
|
||
customer Customer? @relation(fields: [customerId], references: [id])
|
||
assignedAt DateTime
|
||
releasedAt DateTime?
|
||
releaseReason NumberReleaseReason?
|
||
releasedById String?
|
||
|
||
@@index([customerNumber])
|
||
@@map("customer_number_history")
|
||
}
|
||
```
|
||
|
||
### Backfill
|
||
|
||
At implementation time, backfill `customerNumber` from the existing
|
||
`CustomerLegacyRef` rows where `sourceSystem = "utilities" AND sourceTable =
|
||
"DATGRAL"` (the legacy `NUM id` — already migrated, just not promoted to a
|
||
first-class column). Insurance-only customers (no utilities `DATGRAL` row)
|
||
won't have a legacy number; decide at build time whether they get one
|
||
retroactively assigned or stay `null` until they need one (recommend: assign
|
||
one on demand, the first time anyone needs to give them a physical folder —
|
||
not retroactively for all 510 insurance-only customers at once).
|
||
|
||
**This backfill isn't a straight cast of every `legacyId` to `Int`.**
|
||
Checked against `migration/transform_customers.py`: for a utilities
|
||
`DATGRAL` row, `legacyId` is set to `nid or f"rownum_{len(customers)}"` —
|
||
`nid` is the real `NUM id` only when the source row actually had one;
|
||
**~140 utilities rows had a blank `NUM id`** (the same "blank name" data
|
||
quality issue the same script recovers names for) and got a synthetic
|
||
`rownum_N` placeholder instead, which is not a physical-folder number and
|
||
must not be cast into `customerNumber`. Insurance-side refs have the same
|
||
pattern (`insrow_N` placeholders). The backfill query needs an explicit
|
||
numeric filter (`legacyId REGEXP '^[0-9]+$'`, or equivalent), and every row
|
||
that fails it is exactly the "insurance-only or blank-`NUM id`" case that
|
||
falls through to on-demand assignment above, not an error to chase down.
|
||
|
||
`customers.service.ts`'s `list()`/`detail()` `select` blocks don't include
|
||
`customerNumber` today (only `name`, `nameSource`, contact fields, counts)
|
||
— it needs adding to both, plus to the `/clientes` list-page columns and
|
||
the customer detail header, since staff read this number constantly for
|
||
the physical folder. `list()`'s search (`where.OR`) already matches
|
||
`legacyRefs.some.legacyId.contains` as a fallback for finding someone by
|
||
their old number; once `customerNumber` is first-class, add a direct
|
||
`{ customerNumber: Number(query) }` branch when the query parses as an
|
||
integer, so a numeric search hits the fast indexed column instead of the
|
||
join.
|
||
|
||
### Eligibility detection (the automation)
|
||
|
||
This is explicitly framed as **surfacing candidates for staff review, not
|
||
auto-purging** — the actual release/reuse decision stays a human action,
|
||
matching how the office works today; only the *search* is automated.
|
||
|
||
- **New endpoint** `GET /customers/recycling-candidates` — customers where
|
||
either:
|
||
- `CANCELLED`: no active `Policy` (not archived, `policyTo` in the past
|
||
with no renewal) **and** no active `PropertyService`, or
|
||
- `INACTIVITY`: `MAX(Transaction.transactionDate)` across all their
|
||
transactions is more than 1 year ago (or no transactions at all and
|
||
`customerSince` is more than 1 year ago).
|
||
|
||
This can be a plain query (no new job/queue needed — it's a read, not a
|
||
mutation) run on-demand when staff open a "Clientes para reciclar" screen,
|
||
the same way `/billing/balances` is computed live rather than
|
||
materialized.
|
||
- **New endpoint** `POST /customers/:id/release-number`
|
||
(`customer:recycle`, MANAGER). Body: `{ reason: NumberReleaseReason }`.
|
||
- Closes the open `CustomerNumberHistory` row (`releasedAt = now,
|
||
releaseReason, releasedById`).
|
||
- Sets `Customer.customerNumber = null`, `numberReleasedAt = now`.
|
||
- **Archives** the customer (`archivedAt = now`) — does **not** hard-delete
|
||
or scrub PII by default. See the purge question below.
|
||
|
||
### Auto-assignment at creation
|
||
|
||
- **`customers.service.ts` create path**: before insert, compute
|
||
`SELECT MIN(n) candidate FROM (SELECT customerNumber+1 AS n FROM
|
||
customers) WHERE n NOT IN (SELECT customerNumber FROM customers WHERE
|
||
customerNumber IS NOT NULL)` — i.e., the lowest positive integer not
|
||
currently held by any customer (released numbers, being `NULL` again,
|
||
automatically qualify; no separate "available pool" table needed, which
|
||
keeps this consistent with "vacancy = not currently claimed" rather than
|
||
a second source of truth that can drift). Simplify at build time with
|
||
whatever the DB makes cheapest (a gaps-and-islands query, or maintaining a
|
||
running `MAX` + a small released-numbers cache — pick based on real
|
||
customer-count scale, which is ~1,700, trivially small for a live scan).
|
||
Open a new `CustomerNumberHistory` row (`assignedAt = now`) for the new
|
||
assignment.
|
||
- **Concurrency**: `customers.service.ts`'s `create()` today is a single
|
||
unguarded `prisma.customer.create()` — no transaction, no locking, which
|
||
is fine for arbitrary fields but not for a "pick the lowest unclaimed
|
||
integer" computation, where two staff creating a customer at the same
|
||
moment can both compute the same candidate number before either insert
|
||
lands. The `customerNumber` unique constraint turns that race into a
|
||
Prisma unique-violation error rather than silent data corruption, but the
|
||
create path needs to actually handle it — wrap the compute-and-insert in
|
||
a `prisma.$transaction` and retry once on a unique-constraint failure
|
||
(catch `P2002` on `customerNumber`, recompute, re-insert), rather than
|
||
letting the second staff member's creation just fail.
|
||
- **Web**: `/clientes/nuevo` (`CustomerForm.tsx`) shows the assigned number
|
||
as soon as the form loads (read-only, "Número de cliente: 214
|
||
(reciclado)" if it's a reused slot, so staff know to expect the old
|
||
physical folder) — server-assigns it on submit, doesn't let staff type an
|
||
arbitrary one, which is what prevents the collisions manual assignment
|
||
risks today. `CreateCustomerDto` deliberately gains **no** `customerNumber`
|
||
field — the whole point is the client can't set it.
|
||
|
||
### The purge question — needs Jorge's decision
|
||
|
||
The office's paper-world habit is literally "purge their history, info,
|
||
etc." when recycling a folder. This codebase's established convention is
|
||
the opposite — **never hard-delete migrated/business data**, only archive
|
||
(`archivedAt`), specifically so mistakes are reversible and there's always
|
||
an audit trail (see `Customer.archivedAt`, `Policy.archivedAt`,
|
||
`Property.archivedAt`, and the `OpsJob`/`ActivityLog` audit models already
|
||
in the schema).
|
||
|
||
Recommend: **archive by default, never hard-delete.** The `customerNumber`
|
||
release already solves the actual operational need (the number is free to
|
||
reuse); keeping the old customer's data around under a freed number costs
|
||
nothing and preserves history for the inevitable case where "definitely
|
||
cancelled" turns out to be wrong. If Jorge specifically wants literal
|
||
data purge (e.g. for a data-retention/privacy policy reason, not just
|
||
paper-world habit), that should be a **separate, explicit, `ADMIN`-only**
|
||
action (`customer:purge`) taken well after release — not bundled into
|
||
`release-number` — so the two decisions ("this number is reusable" vs.
|
||
"permanently destroy this person's records") aren't accidentally coupled.
|
||
|
||
### Abilities (new)
|
||
|
||
| Ability | Min role | Notes |
|
||
|---|---|---|
|
||
| `customer:recycle` | MANAGER | flag a candidate reviewed, release their number, archive the record |
|
||
| `customer:purge` | ADMIN | **only if** Jorge wants literal PII destruction, kept separate from release |
|
||
|
||
### Open questions
|
||
|
||
- Confirm the "1 year of no activity" clock: measured from last
|
||
`Transaction.transactionDate`, or should a customer with an *expired but
|
||
never-renewed* policy count as cancelled immediately rather than waiting
|
||
out the year? (Spec above treats these as two independent triggers,
|
||
`CANCELLED` vs. `INACTIVITY` — confirm that's the right split.)
|
||
- Does Jorge want true data purge at all, or is archive-and-hide
|
||
sufficient? (See above — recommend archive-only unless there's a
|
||
compliance reason for real deletion.)
|
||
- Should insurance-only customers (no legacy `NUM id`) share the same
|
||
numbering sequence as utilities customers, or get their own? Recommend
|
||
one shared sequence — it's one physical-folder system per the notes, not
|
||
two.
|
||
|
||
---
|
||
|
||
## Build sequencing
|
||
|
||
1. **§1.1 + §1.2 + §1.3 (receipt capture completion)** — smallest, builds
|
||
directly on existing `billing/` code, no new tables. Ship first; it's
|
||
also a prerequisite for §2.
|
||
2. **§4 (customer-number recycling)** — independent of the others,
|
||
touches `customers/` only. Can be built in parallel with §1.
|
||
3. **§3 (multi-bank chequera)** — independent of §1/§2, touches `bank/`
|
||
only. Needs the US bank name from Jorge before the migration step can
|
||
run; the schema/API work can start before that answer arrives.
|
||
4. **§2 (PDF/OCR auto-capture)** — largest, depends on §1 being done (it
|
||
posts through the batch-capture path) and on the OCR-provider decision.
|
||
Build last, and prototype the extraction accuracy against a handful of
|
||
real CFE statements before committing to the provider choice.
|
||
|
||
## Open questions to take back to Jorge (collected)
|
||
|
||
- OCR provider/budget for §2 (self-hosted vs. managed API, given 300+
|
||
pages/month/company).
|
||
- Whether source PDFs arrive pre-split per customer or as one bundled file
|
||
needing page-range detection (§2).
|
||
- Whether "Clave Catastral" and the already-migrated `PREDIAL`-sourced
|
||
`PROPERTY_TAX.accountNumber` are the same number — blocks OCR matching
|
||
for predial statements specifically until confirmed (§2).
|
||
- Whether phone billing is really one service per phone number on file, or
|
||
one per property regardless of how many numbers are recorded — decides
|
||
how the new `TELEPHONE` service kind gets backfilled (§2).
|
||
- The actual bank name/currency/details for the Seguros USD account, and
|
||
whether any historical Seguros bank data exists to migrate (§3).
|
||
- Whether `BankAccount.businessLine` should be enforced or a soft hint
|
||
(§3).
|
||
- The exact "1 year inactivity" / "cancelled" recycling triggers (§4).
|
||
- Whether customer-number recycling should ever include *true* data purge,
|
||
or archive-and-reuse-the-number is sufficient (§4).
|