Files
rmancinasandClaude Opus 5 75e9f582b4
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
feat(policy-ocr): store the IVA A.N.A. already prints
The parser has been reading A.N.A.'s `TAX` cell since the ANA layout landed,
but `ParsedPolicy` had nowhere to put it, so the figure only ever reached a
review note and every OCR-confirmed policy was written with `tax` null — even
though the paper states it.

`TAX` now flows parser -> `extractedTax` -> `Policy.tax`, alongside the premium
fields beside it. GMX stays null: its certificate carries no premium at all, so
there is no tax on it either.

The other two cells stay out, for reasons worth keeping:

  - `LOCAL TAX` is a separate levy with no destination column, and summing it
    into `tax` would produce an IVA that no longer divides back to a rate —
    which is the whole reason to store the figure. It reads 0.00 on every
    policy seen so far; a non-zero one now raises a note saying the total will
    not reconcile, instead of quietly inflating the IVA.
  - `DISCOUNT` has no column and prints as a bare "-" when unused, which is
    what makes the row positional rather than "find six amounts".

`taxRate` is left null by confirm. A.N.A. prints the amount, not the rate, and
back-dividing it would mint a rate the document never stated; the capture form
resolves one from the line of business instead.

The review screen gains derecho de póliza next to the new IVA field. It was
already parsed and already written on confirm, but never shown — and an IVA
with no fee beside it leaves the reviewer unable to see why premium + fee + tax
equals the printed total.

The spec's assertion moved off the note and onto the field, plus a check that
the row reconciles: 298.61 + 30.00 at 8% is 26.29, totalling 354.90. That
agreement is what proves the positional mapping landed on the right cells
rather than merely on six numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:38:23 -07:00

1251 lines
50 KiB
Plaintext

// Unified customer / insurance / utilities data model.
// See C:\Users\ricar\.claude\plans\logical-yawning-tome.md for the migration
// plan this schema implements (source: UTILITIES.accdb, SEGUROS 16_be.mdb,
// SCOTHIA.mdb). Every model that originates from a legacy Access table
// carries legacySource* provenance columns so migrated rows are traceable
// back to their Access original and the ETL can be re-run idempotently.
generator client {
provider = "prisma-client-js"
output = "../generated/client"
// "native" covers local dev. The musl target is declared EXPLICITLY because
// Prisma picks the engine by sniffing the build environment: the Docker build
// stage has no openssl, so it detected plain "linux-musl", while the runtime
// stage (which needs openssl for other reasons) then demanded
// "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
// engine that ships independent of what happens to be installed at build time.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
enum Currency {
USD
MXN
}
enum TransactionDomain {
UTILITY
INSURANCE
TRUST
}
/// How a ledger row entered the system. Every capture path funnels through
/// BillingService (single write path, single audit trail); this records which
/// one, so an auto-captured receipt is auditable without joining the statement
/// tables. `OCR` is reserved for the statement auto-capture pipeline
/// (docs/RECEIPT_CAPTURE_SPEC.md §2), which posts through the same batch path
/// as hand-keyed check batches.
enum TransactionCaptureSource {
MANUAL
BATCH
OCR
}
enum ServiceKind {
WATER
ELECTRIC
GAS
CABLE
PROPERTY_TAX
FEDERAL_ZONE
ALARM
/// Telephone was never unpivoted out of DATMEX — the numbers sat on
/// `Property.phone1/2/3` as contact fields even though the legacy ledger
/// billed phone as its own transaction type. OCR matching needs a real
/// service row to match a Telnor bill against, so it becomes one; see
/// `migration/backfill_statement_match_fields.py`.
TELEPHONE
OTHER
}
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
/// read-only role; STAFF and above can write. Enforced by the API's ability
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
enum UserRole {
ADMIN
MANAGER
STAFF
VIEWER
}
// ---------------------------------------------------------------------------
// Identity — the actual point of the project: one customer record shared by
// both business lines.
// ---------------------------------------------------------------------------
model Customer {
id String @id @default(uuid())
name String
// Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the
// normal case. Anything else means DATGRAL's name was blank and the name was
// recovered from a secondary table (see migration/transform_customers.py),
// so staff can tell a reconstructed name from an original one.
nameSource String?
// True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists
// can sort nameless records last — ordering by `name` alone puts them first,
// since "(" sorts before every letter.
nameMissing Boolean @default(false)
addressLine1 String?
addressLine2 String?
city String?
state String?
zipCode String?
country String?
phone String?
mobile String?
fax String?
email String?
emailOptOut Boolean @default(false)
notes String? @db.Text
identificationType String?
identificationNumber String?
identificationExpiration DateTime?
customerSince DateTime?
status Boolean @default(true)
minimumBalance Decimal? @db.Decimal(12, 2)
feeAmount Decimal? @db.Decimal(12, 2)
preferredCurrency Currency @default(USD)
// Soft-delete marker. Distinct from `status` (a legacy business flag): a
// non-null archivedAt hides the row from default lists while preserving it
// and its legacy provenance. Never hard-delete migrated data.
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
legacyRefs CustomerLegacyRef[]
properties Property[]
policies Policy[]
vehicles Vehicle[]
transactions Transaction[]
statementDocuments StatementDocument[]
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
emailNotificationLogs EmailNotificationLog[]
accountStatusHistory AccountStatusHistory[]
@@map("customers")
}
/// Generalizes the old app's customer_mapping table: one row per legacy
/// record folded into this customer, from either source system.
model CustomerLegacyRef {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
sourceSystem String // "utilities" | "insurance"
sourceTable String // e.g. "DATGRAL", "COBRO3"
legacyId String // stringified legacy id (source columns are often DOUBLE)
createdAt DateTime @default(now())
@@unique([sourceSystem, sourceTable, legacyId])
@@map("customer_legacy_refs")
}
// ---------------------------------------------------------------------------
// Insurance domain
// ---------------------------------------------------------------------------
model InsuranceProvider {
id String @id @default(uuid())
name String @unique
policies Policy[]
@@map("insurance_providers")
}
/// How the premium is split into payments. Drives whether a surcharge
/// applies at all: the legacy books only ever charge `recargo` on a policy
/// paid in more than one exhibición, never on an annual one. Values come from
/// the Access `FORMA PAGO` column (ANNUAL / SEMESTRAL / CONTADO) plus the
/// quarterly option Jorge sells today but never recorded in Access.
enum PaymentFrequency {
ANNUAL
SEMIANNUAL
QUARTERLY
MONTHLY
/// Legacy "CONTADO" — the whole premium in one payment, no schedule.
SINGLE
}
model PolicyType {
id String @id @default(uuid())
name String @unique
shortDescription String?
/// IVA rate charged on this line of business, as a fraction (0.08 = 8%).
/// Replaces the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables, which
/// held exactly one rate each — per line of business, editable without a
/// deploy, because the rate is a tax rule and tax rules change. Null falls
/// back to DEFAULT_TAX_RATE in the API rather than to "no tax", so a type
/// nobody has configured still computes the same 8% the books use today.
taxRate Decimal? @db.Decimal(6, 4)
policies Policy[]
@@map("policy_types")
}
/// Consolidates INCENDIO/MULT/M EMPR/all auto-table variants/LICENCIAS into
/// one table with a policyType discriminator, instead of one Access table
/// per line of business.
model Policy {
id String @id @default(uuid())
policyNumber String
customerId String
customer Customer @relation(fields: [customerId], references: [id])
policyTypeId String?
policyType PolicyType? @relation(fields: [policyTypeId], references: [id])
insuranceProviderId String?
insuranceProvider InsuranceProvider? @relation(fields: [insuranceProviderId], references: [id])
agentName String?
policyDate DateTime?
policyFrom DateTime?
policyTo DateTime?
coveragePeriodDays Int? @default(365)
netPremium Decimal? @db.Decimal(12, 2)
/// "Recargo" — the financing surcharge for paying in installments. Entered
/// by hand, never derived: it is quoted by the carrier, not computed here.
/// Only ever set when `paymentFrequency` is not ANNUAL/SINGLE, and it IS
/// part of the taxable base (verified against the Access books: policy
/// 7006785 only reconciles as (610.86 + 8.55 + 31.00) * 0.08 = 52.03).
surcharge Decimal? @db.Decimal(12, 2)
policyFee Decimal? @db.Decimal(12, 2)
brokerFee Decimal? @db.Decimal(12, 2)
commission Decimal? @db.Decimal(12, 2)
/// IVA. Access never stored this — it was an unbound calculated control on
/// the form — so every legacy row starts null and is filled going forward.
/// Stored rather than computed on read because the printed policy is the
/// record of truth and its rounding must survive a later rate change.
tax Decimal? @db.Decimal(12, 2)
/// The rate actually applied when `tax` was written, as a fraction. Kept on
/// the row so a policy issued at 8% still reads back as 8% after somebody
/// edits the PolicyType to a new rate.
taxRate Decimal? @db.Decimal(6, 4)
/// Prima total = netPremium + surcharge + policyFee + tax. Populated by the
/// capture form from now on. NOTE the legacy rows: `total` is 0 or null on
/// all but 2 of 2378 migrated policies, so list/sort code must keep using
/// netPremium as the headline (see policies.service.ts).
total Decimal? @db.Decimal(12, 2)
/// ANNUAL on all but 31 legacy rows — and the migration used to drop the
/// column entirely, so every pre-2026 policy reads null here.
paymentFrequency PaymentFrequency?
currency Currency @default(MXN)
observations String? @db.Text
notes String? @db.Text
coveragesJson Json?
endorsement Boolean @default(false)
liquidated Boolean @default(false)
liquidationNumber String?
liquidationDate DateTime?
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
// policy data; archiving hides it from default lists.
archivedAt DateTime?
legacySourceDb String?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
installments PolicyPaymentInstallment[]
vehicles Vehicle[]
insuredDrivers InsuredDriver[]
beneficiaries PolicyBeneficiary[]
claims Claim[]
documents PolicyDocument[]
properties Property[]
renewalNotices RenewalNotice[]
ocrMatchedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentPolicy")
ocrCreatedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCreatedPolicy")
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([policyNumber])
@@map("policies")
}
enum RenewalNoticeChannel {
MAIL
EMAIL
}
/// Replaces the legacy `CONTROL <ramo> RENEW[2/3] X MES` reports — a
/// per-batch printed checklist of who'd been sent which reminder. One row
/// per notice generation actually sent for a policy, so "who got a 1st/2nd/
/// 3rd notice and when" is a query instead of a paper trail. See
/// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces.
model RenewalNotice {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
// 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming.
generation Int
channel RenewalNoticeChannel @default(MAIL)
sentAt DateTime?
sentById String?
providerMessageId String?
notes String? @db.Text
createdAt DateTime @default(now())
// One row per generation per policy — matches the legacy's 1st/2nd/3rd
// notice cadence; re-running the same generation for a policy updates it
// rather than duplicating a log entry.
@@unique([policyId, generation])
@@map("renewal_notices")
}
/// Unpivots the 4 hardcoded payment-installment columns found on every
/// legacy policy table (1ER PAGO/FECHA PAGO/NO CHEQUE, ...2, ...3, ...4).
model PolicyPaymentInstallment {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
sequence Int
amount Decimal? @db.Decimal(12, 2)
currency Currency @default(MXN)
dueDate DateTime?
paidDate DateTime?
checkNumber String?
isCash Boolean @default(false)
// Per-payment premium breakdown. A policy paid in more than one exhibición
// prices EACH payment separately — its own net premium, its own surcharge,
// its own IVA — which is why the Access form printed the whole money row
// twice (P NETA / RECARGO / D POL / IVA / TOTAL / COM, once per pago) and
// why these cannot live on the policy header alone. `amount` stays the
// authoritative figure actually collected: it is what the cheque was
// written for and it drifts from `total` by a peso or two in the books
// (policy 7006785: amount 702.73 vs total 702.44), so it is deliberately
// NOT recomputed from this breakdown.
netPremium Decimal? @db.Decimal(12, 2)
surcharge Decimal? @db.Decimal(12, 2)
policyFee Decimal? @db.Decimal(12, 2)
tax Decimal? @db.Decimal(12, 2)
taxRate Decimal? @db.Decimal(6, 4)
total Decimal? @db.Decimal(12, 2)
commission Decimal? @db.Decimal(12, 2)
@@map("policy_payment_installments")
}
/// Unpivots MCA2's 3 hardcoded vehicle slots (and the single-vehicle auto
/// tables) into one row per vehicle.
model Vehicle {
id String @id @default(uuid())
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
policyId String?
policy Policy? @relation(fields: [policyId], references: [id])
make String?
model String?
modelYear String?
bodyType String?
engineNumber String?
licensePlate String?
vinNumber String?
stateCode String?
notes String? @db.Text
// One legacy policy row can carry up to 3 vehicles, so they share a
// legacyId (the source row number) — provenance is NOT unique per vehicle.
// Sync rebuilds legacy vehicles by scoped delete + reinsert instead of upsert.
legacySourceTable String?
legacyId String?
@@map("vehicles")
}
/// Unpivots the repeated named-insured/license columns in MCA2/LICENCIAS.
model InsuredDriver {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
fullName String?
birthDate DateTime?
sex String?
occupation String?
licenseNumber String?
licenseState String?
@@map("insured_drivers")
}
/// From BENEF — already a clean child table in the source data.
model PolicyBeneficiary {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
name String?
address String?
phone String?
email String?
@@map("policy_beneficiaries")
}
/// From DATOS (siniestros).
model Claim {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
claimType String?
incidentDate DateTime?
reportedDate DateTime?
description String? @db.Text
adjusterId String?
adjuster Adjuster? @relation(fields: [adjusterId], references: [id])
claimedAmount Decimal? @db.Decimal(12, 2)
settledAmount Decimal? @db.Decimal(12, 2)
settlementDate DateTime?
checkNumber String?
resolved Boolean @default(false)
resolutionNotes String? @db.Text
@@map("claims")
}
/// From AJUSTADORES / AJUSTADORESATLAS.
model Adjuster {
id String @id @default(uuid())
company String?
city String?
name String?
phone String?
beeper String?
claims Claim[]
@@map("adjusters")
}
/// Extracted LONGBINARY blobs from the policy tables — file lives in object
/// storage, only the pointer + type lives here.
model PolicyDocument {
id String @id @default(uuid())
policyId String
policy Policy @relation(fields: [policyId], references: [id])
documentType String
storageKey String
originalColumn String?
createdAt DateTime @default(now())
@@map("policy_documents")
}
/// Insurance OCR intake (mirrors statement_batches / statement_documents for
/// the utility side). One upload session of policy PDFs from a provider
/// portal (GMX, etc.) — the parser proposes policyNumber → existing Policy
/// (or "new, pick customer"), staff confirms, and the system attaches the
/// source PDF and optionally writes a premium Transaction.
model PolicyOcrBatch {
id String @id @default(uuid())
/// Which insurance provider portal the batch came from — "GMX", "ANA", or
/// "GMX + ANA" when one upload mixed them. Set by the pipeline from what
/// the parsers actually claimed, not asked of the uploader, so it can
/// never contradict the documents. Future providers extend the parser,
/// not this table.
provider String @default("GMX")
status PolicyOcrBatchStatus @default(UPLOADED)
uploadedById String
uploadedBy User @relation("PolicyOcrBatchUploader", fields: [uploadedById], references: [id])
label String?
fileCount Int @default(0)
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
error String? @db.Text
createdAt DateTime @default(now())
completedAt DateTime?
documents PolicyOcrDocument[]
@@index([status, createdAt])
@@map("policy_ocr_batches")
}
enum PolicyOcrBatchStatus {
UPLOADED
PROCESSING
READY_FOR_REVIEW
COMPLETED
FAILED
/// Abandoned by staff before anything was applied — a bad scan, the wrong
/// PDFs, a duplicate upload. Distinct from COMPLETED so the queue can tell
/// "we did the work" from "we threw it away".
DISCARDED
}
/// One parsed policy page — one Policy → one Customer (after staff confirms).
model PolicyOcrDocument {
id String @id @default(uuid())
batchId String
batch PolicyOcrBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
pageNumber Int
/// The rendered page image in object storage. Source PDF kept too on the
/// batch (statement pattern) so re-running a corrected parser is possible.
storageKey String
status PolicyOcrDocumentStatus @default(PENDING_OCR)
ocrRawText String? @db.Text
ocrConfidence Decimal? @db.Decimal(4, 3)
/// Which parser claimed the page ("GMX" today).
provider String?
// Extracted header fields, all staff-editable in review.
extractedPolicyNumber String?
extractedInsuredName String?
extractedAdditionalInsured String?
extractedAgentName String?
extractedLegalAddress String? @db.Text
extractedZip String?
extractedPolicyFrom DateTime?
extractedPolicyTo DateTime?
extractedPolicyDate DateTime?
extractedCurrency String?
extractedNetPremium Decimal? @db.Decimal(12, 2)
extractedPolicyFee Decimal? @db.Decimal(12, 2)
extractedBrokerFee Decimal? @db.Decimal(12, 2)
/// IVA off A.N.A.'s `TAX` cell. Null on GMX, whose certificate carries no
/// premium at all. The adjacent `LOCAL TAX` is a separate levy with no
/// column of its own and is NOT summed in — it would make the figure stop
/// dividing back to a rate; the parser reports a non-zero one as a note.
extractedTax Decimal? @db.Decimal(12, 2)
extractedTotal Decimal? @db.Decimal(12, 2)
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
/// tables and ANA's numbered risk sections — preserved verbatim so a
/// missing premium receipt still leaves the coverages auditable.
extractedCoveragesJson Json?
extractedPremiumPayment String?
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
/// a weekend policy by a year.
extractedCoveragePeriodDays Int?
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
/// Written to `Vehicle` rows on confirm; kept here so the review screen
/// shows what was read before anything is applied.
extractedVehiclesJson Json?
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
/// rows on confirm.
extractedDriversJson Json?
/// The `PolicyType.name` the parser read the product as ("AUTO",
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
/// database, so confirm resolves it against `policy_types` and leaves
/// `Policy.policyTypeId` null if there is no such row.
extractedPolicyTypeName String?
// Match by `Policy.policyNumber` → existing Policy / Customer.
matchedPolicyId String?
matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
matchedCustomerId String?
matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
/// All policies carrying the same number, with their customer. One is
/// normal; >1 means the policy number is shared across customers and a
/// human must pick.
matchCandidates Json?
/// `CustomerNameSuggestion[]` — customers whose name matches the printed
/// insured name, ranked. A SUGGESTION, never a match: it is deliberately
/// kept out of `matchCandidates` so the review screen cannot mistake a
/// name hint for a policy-number hit, and it never sets
/// `matchedCustomerId`. Only populated when the policy number found
/// nothing, which is exactly when staff have to pick a customer by hand.
customerSuggestions Json?
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and
/// a multi-section ANA policy runs past 191 characters routinely. Silently
/// truncating it drops the tail notes, which are the ones that say what
/// could NOT be read.
matchNote String? @db.Text
reviewedById String?
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
reviewedAt DateTime?
createdPolicyId String? @unique
createdPolicy Policy? @relation("PolicyOcrDocumentCreatedPolicy", fields: [createdPolicyId], references: [id])
postedTransactionId String? @unique
postedTransaction Transaction? @relation("PolicyOcrDocumentTransaction", fields: [postedTransactionId], references: [id])
createdAt DateTime @default(now())
@@unique([batchId, pageNumber])
@@index([status])
@@index([matchedCustomerId])
@@map("policy_ocr_documents")
}
enum PolicyOcrDocumentStatus {
PENDING_OCR
OCR_FAILED
NEEDS_REVIEW
MATCHED
CONFIRMED
POSTED
REJECTED
}
// ---------------------------------------------------------------------------
// Utilities domain
// ---------------------------------------------------------------------------
/// From DATMEX — shared with the insurance domain so a property can carry
/// both a home-insurance policy and utility service enrollments.
model Property {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
policyId String?
policy Policy? @relation(fields: [policyId], references: [id])
addressLine1 String?
addressLine2 String?
phone1 String?
phone2 String?
phone3 String?
zone String?
/// Clave catastral (DATMEX.clave) — the cadastral key, format `KA903009`.
/// Property-level, not per-service: it is printed on both the CESPT water
/// bill and the predial statement, which is exactly why it is a useful
/// secondary match key when a bill's account number does not OCR cleanly.
/// Distinct from the numeric DATMEX.predial that `PROPERTY_TAX.accountNumber`
/// carries — that column is not unique (663 distinct across 1135 rows) and
/// is not what any statement prints.
cadastralKey String?
// Soft-delete marker (see Customer.archivedAt).
archivedAt DateTime?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
services PropertyService[]
documents ServiceDocument[]
trustAccount TrustAccount?
@@unique([legacySourceTable, legacyId])
@@index([cadastralKey])
@@map("properties")
}
/// Unpivots DATMEX's inline service columns and PROFILE's enrollment flags
/// into one row per enrolled service per property.
model PropertyService {
id String @id @default(uuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id])
kind ServiceKind
accountNumber String?
meterNumber String?
route String?
dueDay String?
active Boolean @default(true)
notes String? @db.Text
statementDocuments StatementDocument[]
// The OCR matcher looks a service up by (kind, accountNumber) — always
// scoped to one kind, never fuzzily across every identifier column, so a
// water account number cannot collide with an unrelated phone number.
@@index([kind, accountNumber])
@@index([kind, meterNumber])
@@map("property_services")
}
model ServiceDocument {
id String @id @default(uuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id])
documentType String
storageKey String
createdAt DateTime @default(now())
@@map("service_documents")
}
// ---------------------------------------------------------------------------
// Statement OCR intake (RECEIPT_CAPTURE_SPEC §2)
//
// Each utility company mails 300+ paper statements a month, one per customer,
// which staff key in by hand. These two tables are the intake side of removing
// that: a batch of scanned PDFs is split per page, OCR'd, matched to a
// PropertyService by its scoped account number, and queued for review. Nothing
// here writes to the ledger — confirming a document posts it through
// `BillingService.createBatch`, the same path hand-keyed batches take.
//
// Everything ingested is a CHARGE (a bill awaiting payment), never a proof of
// payment: the office scans what it must pay, and settles it by check through
// the existing capture flow.
// ---------------------------------------------------------------------------
enum StatementBatchStatus {
UPLOADED
PROCESSING
READY_FOR_REVIEW
COMPLETED
FAILED
/// Abandoned by staff before anything was posted — a bad scan, the wrong
/// PDFs, a duplicate upload. Distinct from COMPLETED so the queue can tell
/// "we did the work" from "we threw it away".
DISCARDED
}
enum StatementDocumentStatus {
PENDING_OCR
OCR_FAILED
/// No confident match, or the extraction itself was low-confidence.
NEEDS_REVIEW
/// Confident auto-match, awaiting a human confirm.
MATCHED
/// Staff confirmed; not yet posted.
CONFIRMED
POSTED
/// Duplicate, unreadable, or wrong batch.
REJECTED
}
/// One upload session — e.g. "October CFE statements".
model StatementBatch {
id String @id @default(uuid())
/// What kind of service every statement in this batch bills. The parser
/// still detects the provider per page and flags any page that disagrees,
/// rather than trusting the uploader's label.
serviceKind ServiceKind
status StatementBatchStatus @default(UPLOADED)
uploadedById String
uploadedBy User @relation("StatementBatchUploader", fields: [uploadedById], references: [id])
label String?
fileCount Int @default(0)
/// Set when the pipeline fails as a whole (bad PDF, OCR binaries missing).
error String? @db.Text
createdAt DateTime @default(now())
completedAt DateTime?
documents StatementDocument[]
@@index([status, createdAt])
@@map("statement_batches")
}
/// One statement — one customer, one period — after splitting the batch.
model StatementDocument {
id String @id @default(uuid())
batchId String
batch StatementBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
/// 1-based page of the source PDF this was split from.
pageNumber Int
/// The rendered page image in object storage. The source PDF is kept too, so
/// a reviewer can always see exactly what the parser read.
storageKey String
status StatementDocumentStatus @default(PENDING_OCR)
/// Raw OCR text, kept even after a manual correction so a mismatch between
/// what the machine read and what staff entered stays auditable.
ocrRawText String? @db.Text
/// Mean per-word confidence reported by the OCR engine, 0..1.
ocrConfidence Decimal? @db.Decimal(4, 3)
/// Which parser claimed the page ("CFE", "CESPT", "TELNOR").
provider String?
// Extracted, then staff-corrected in place. `extractedAccountRef` is already
// normalised for matching (CFE leading zeros stripped, Telnor LADA removed).
extractedAccountRef String?
extractedAmount Decimal? @db.Decimal(12, 2)
extractedPeriod String?
extractedDueDate DateTime?
/// Clave catastral when the statement prints one — a second key to match on
/// when the account number is unreadable.
extractedCadastralKey String?
matchedPropertyServiceId String?
matchedPropertyService PropertyService? @relation(fields: [matchedPropertyServiceId], references: [id])
matchedCustomerId String?
matchedCustomer Customer? @relation(fields: [matchedCustomerId], references: [id])
/// Why this landed where it did — "exact account match", "no candidate",
/// "2 candidates". Shown in the review queue so staff can trust or distrust
/// the suggestion without opening the image.
matchNote String?
reviewedById String?
reviewedBy User? @relation("StatementDocumentReviewer", fields: [reviewedById], references: [id])
reviewedAt DateTime?
postedTransactionId String? @unique
postedTransaction Transaction? @relation(fields: [postedTransactionId], references: [id])
createdAt DateTime @default(now())
@@unique([batchId, pageNumber])
@@index([status])
@@index([matchedCustomerId])
@@map("statement_documents")
}
/// From TRUSTVENCE.
model TrustAccount {
id String @id @default(uuid())
propertyId String @unique
property Property @relation(fields: [propertyId], references: [id])
bankName String?
trustNumber String?
bankFee Decimal? @db.Decimal(12, 2)
dueDate1 DateTime?
dueDate2 DateTime?
@@map("trust_accounts")
}
// ---------------------------------------------------------------------------
// Shared financial ledger — one office, one set of books.
// ---------------------------------------------------------------------------
/// ES/EN transaction-type lookup, carried over from the old schema.
model TypeTransaction {
id String @id @default(uuid())
nameEn String
nameEs String?
isService Boolean @default(false)
transactions Transaction[]
@@map("type_transactions")
}
/// Unifies utilities' EFECTIVO/EFECTIVO FM3/EFECTIVO_BACKUP/FEE ANUAL/
/// datos2/fee15/billing/CHEQUE FM3/IVA 2015 and insurance's EFECTIVO.
model Transaction {
id String @id @default(uuid())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
domain TransactionDomain
typeId String?
type TypeTransaction? @relation(fields: [typeId], references: [id])
transactionDate DateTime
period String?
reference String?
amount Decimal @db.Decimal(12, 2)
currency Currency @default(MXN)
exchangeRate Decimal? @db.Decimal(10, 4)
checkNumber String?
message String? @db.Text
outstanding Boolean @default(false)
/// How this row was captured. NULL = migrated from Access (the legacy*
/// columns below say which table). Set explicitly on everything the app
/// books, so an OCR-posted receipt is distinguishable from a hand-keyed one
/// without joining the statement tables.
captureSource TransactionCaptureSource?
/// Back-pointer to the artifact that produced this row — a
/// `StatementDocument.id` for OCR captures (see RECEIPT_CAPTURE_SPEC §2).
/// Unique among live rows via the app's duplicate guard, not a DB constraint,
/// because a voided row must not block a corrected re-post of the same
/// document.
captureRef String?
// Append + void: booked rows are never edited or hard-deleted. A non-null
// voidedAt reverses the movement — it MUST be excluded from every balance
// and total (SUM/count) so a voided amount stops affecting the books.
voidedAt DateTime?
voidedById String?
legacySourceDb String?
legacySourceTable String?
legacyId String?
createdAt DateTime @default(now())
/// Set only on OCR-posted rows — the statement page this came from.
statementDocument StatementDocument?
policyOcrDocument PolicyOcrDocument? @relation("PolicyOcrDocumentTransaction")
@@unique([legacySourceDb, legacySourceTable, legacyId])
@@index([customerId, transactionDate])
// By-check reconciliation (billing.byCheck / the cheque-count report) looks
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
@@index([checkNumber])
// Drives the duplicate-post guard in BillingService.createBatch.
@@index([captureRef])
@@map("transactions")
}
/// From TIPO HIST.
model ExchangeRate {
id String @id @default(uuid())
rate Decimal @db.Decimal(10, 4)
effectiveDate DateTime
effectiveHour DateTime?
@@map("exchange_rates")
}
// ---------------------------------------------------------------------------
// Company bank register (SCOTHIA.mdb) — the office's own operating account,
// deliberately separate from customer-facing Transaction records.
// ---------------------------------------------------------------------------
/// From TABLA RAMODOS ("ramo" = line of business).
model BusinessLineCategory {
id String @id @default(uuid())
name String @unique
bankTransactions BankTransaction[]
@@map("business_line_categories")
}
/// The institution a chequera is held at. Purely a grouping label for the
/// accounts under it — no money hangs off a Bank directly.
model Bank {
id String @id @default(uuid())
name String @unique
/// "MX" | "US" — informational, used only to label the account picker.
country String?
accounts BankAccount[]
@@map("banks")
}
/// One physical chequera. Currency is fixed per account, because a real bank
/// account is: there is deliberately NO currency column on BankTransaction, a
/// movement inherits its account's. This is what keeps the MXN (Utilities /
/// Scotiabank) and USD (Seguros) registers from ever being summed together,
/// the same rule the customer ledger follows per currency.
model BankAccount {
id String @id @default(uuid())
bankId String
bank Bank @relation(fields: [bankId], references: [id])
/// Staff-facing name, e.g. "Utilities — Scotiabank (MXN)".
label String
currency Currency
/// Hint only, never enforced — one chequera can pay for more than one line.
businessLine TransactionDomain?
active Boolean @default(true)
movements BankTransaction[]
@@map("bank_accounts")
}
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
/// signed-amount table: income positive, expense negative.
model BankTransaction {
id String @id @default(uuid())
// Required: a movement with no known account isn't reconcilable against a
// statement. Every migrated row is SCOTHIA = the Utilities MXN account.
bankAccountId String
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
transactionDate DateTime
transactionType String?
reference String?
concept String?
amount Decimal @db.Decimal(12, 2)
categoryId String?
category BusinessLineCategory? @relation(fields: [categoryId], references: [id])
cleared Boolean @default(false)
transferred Boolean @default(false)
notes String? @db.Text
amountInWords String?
// Append + void (see Transaction.voidedAt): excluded from income/expense/net.
voidedAt DateTime?
voidedById String?
legacySourceTable String?
legacyId String?
// Provenance stays globally unique: every legacy row belongs to the one
// Scotiabank account, so adding accounts never collides here.
@@unique([legacySourceTable, legacyId])
@@index([bankAccountId, transactionDate])
@@map("bank_transactions")
}
// ---------------------------------------------------------------------------
// Admin / shared
// ---------------------------------------------------------------------------
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
role UserRole @default(STAFF)
active Boolean @default(true)
// UI text-size preference, so it follows the person between machines
// instead of living only in one browser's localStorage. Range is clamped
// API-side (see UpdatePreferencesDto) to match the web's presets.
uiScale Float @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
activityLogs ActivityLog[]
statementBatches StatementBatch[] @relation("StatementBatchUploader")
statementsReviewed StatementDocument[] @relation("StatementDocumentReviewer")
policyOcrBatches PolicyOcrBatch[] @relation("PolicyOcrBatchUploader")
policyOcrReviewed PolicyOcrDocument[] @relation("PolicyOcrDocumentReviewer")
@@map("users")
}
model ActivityLog {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id])
event String
level String
message Json?
createdAt DateTime @default(now())
@@map("activity_logs")
}
model EmailTemplate {
id String @id @default(uuid())
name String
subject String
templateSource String @db.Text
@@map("email_templates")
}
model EmailCampaign {
id String @id @default(uuid())
campaignName String
subject String?
body String? @db.Text
status String @default("in_progress")
emailSentCount Int @default(0)
createdAt DateTime @default(now())
@@map("email_campaigns")
}
model EmailLog {
id String @id @default(uuid())
customerId String?
emailAddress String?
emailType String?
requestBody String? @db.Text
responseBody String? @db.Text
sentAt DateTime @default(now())
@@map("email_log")
}
// ---------------------------------------------------------------------------
// Mass email notifications — the modern replacement for the legacy
// `email_alert_log` + `send_account_status_history` tables on utility_dbo and
// the four PHP scripts under `email.notifications/`. See
// docs/MASS_EMAIL_NOTIFICATIONS.md for the full design.
//
// The two legacy tables are not imported into this schema: the unified
// `customers` model replaces `datosfreak` (no more NUMid-as-string), so the
// rows would no longer carry their meaning. New tables follow the unified
// shape (FK to `customers`, signed Decimal balance, proper enums) and the
// four notification kinds are one `notificationType` enum rather than four
// parallel column families.
// ---------------------------------------------------------------------------
/// Which bulk-notification script produced a row. Mirrors the four PHP
/// jobs in `email.notifications/send*.php`:
/// - OUTSTANDING_PAYMENT → sendOutstandingPaymentAlerts.php
/// - PAYMENT_CONFIRMATION → sendPaymentConfirmation.php (pagosemail)
/// - ACCOUNT_STATUS → sendAccountStatus.php (datosfreak, both
/// red and yellow; the threshold is in the
/// `level` column, 0=yellow / 1=red)
/// - TRUST_PAYMENT_CONFIRMATION → sendConfirmTrustPayment.php (TRUSTHFEE)
///
/// RENEWAL_NOTICE has no PHP ancestor — it is the insurance renewal aviso
/// (`RenewalsService`), logged here so every outbound email the platform
/// sends lands in one table. `RenewalNotice` remains the per-policy
/// "already notified" record that drives the pending list; this log is the
/// send history, including the failures and skips `RenewalNotice` cannot
/// represent.
enum EmailNotificationType {
OUTSTANDING_PAYMENT
PAYMENT_CONFIRMATION
ACCOUNT_STATUS
TRUST_PAYMENT_CONFIRMATION
RENEWAL_NOTICE
}
/// Which "servicio" (line of business) the notification draws its recipients
/// from. CUSTOMERS = the unified customers ledger (replaces `datosfreak`);
/// TRUST = the trust-fee account table (replaces `TRUSTHFEE`);
/// POLICIES = the insurance book (renewal avisos). Keeping the services
/// tagged makes a per-line report trivial — and lets the /notificaciones
/// tabs each show their own slice of the one log.
enum EmailNotificationServicio {
CUSTOMERS
TRUST
POLICIES
}
/// Outcome of a single send attempt. SENT / FAILED are the meaningful ones;
/// SKIPPED_NO_EMAIL records the dry-run path and the legacy's
/// "EMAIL IS NULL" exclusion, SKIPPED_GATE records the Mon/Wed/Fri day gate
/// on the red branch (and the Wed gate on yellow) — so a sweep that ran on
/// the wrong day shows up as skipped rows, not as missing rows.
enum EmailNotificationStatus {
SENT
FAILED
SKIPPED_NO_EMAIL
SKIPPED_GATE
}
/// One row per send attempt. Captures both the deliverable (subject + body
/// snapshot + provider message id) and the diagnostic (URL we would have
/// fetched in the PHP version, SES response, error string). The body snapshot
/// is intentionally kept: the PHP scripts only stored it on the error path;
/// we store it always, so a customer reply quoting an old email can be traced
/// to the exact letter that was sent.
model EmailNotificationLog {
id String @id @default(uuid())
sendDate DateTime @default(now())
notificationType EmailNotificationType
/// Per-type discriminator, null where the type has none:
/// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO")
/// RENEWAL_NOTICE → the aviso generation (1 = 30d before, 2 = 15d
/// before, 3 = 7d after expiry)
/// Null on the remaining jobs. Readers MUST branch on notificationType
/// before interpreting it.
level Int?
/// Which servicio sourced the recipient list. CUSTOMERS for jobs 1/2/3,
/// TRUST for job 4, POLICIES for renewal avisos. Tagged here so a per-line
/// audit doesn't need to join — and so the /notificaciones Servicios tab
/// (CUSTOMERS + TRUST) and Pólizas tab (POLICIES) can filter one log.
servicio EmailNotificationServicio
/// FK to the customer that triggered the send. Trust-account notifications
/// resolve the owner through `Property.customerId`, so this stays set on
/// job 4 too. Null only on skipped rows where the lookup itself failed.
customerId String?
customer Customer? @relation(fields: [customerId], references: [id])
customerName String
customerEmail String
/// Subject line of the email we attempted to send.
subject String
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
/// fetched (kept verbatim so the legacy format is reproducible). Null on
/// the other three jobs — the body is built inline.
bodyRequestUrl String? @db.Text
/// The HTML body that was sent (or that would have been sent, for SKIPPED
/// rows). Stored verbatim so audit/customer-service can read the exact
/// letter that went out without re-running the render.
bodySnapshot String @db.Text
/// True when `debug` was passed — the recipient was overridden to the
/// admin address and no real customer received the mail. Kept here so a
/// "where did all these emails go" investigation finds the answer in one
/// place instead of "who ran what with what flags" archaeology.
debug Boolean @default(false)
/// SES SendEmail MessageId, when we actually got one back. Null on
/// failures, skipped rows, and dev/mock transport.
providerMessageId String?
/// Free-form provider response (or error). Trimmed to 4k chars before
/// insert so a verbose SES bounce payload can't blow the column.
providerResponse String?
status EmailNotificationStatus
error String? @db.Text
@@index([sendDate])
@@index([notificationType, sendDate])
@@index([customerId, sendDate])
@@map("email_notification_log")
}
/// Mirrors the legacy `utility_dbo.send_account_status_history` table — one
/// row per ACCOUNT_STATUS send, capturing the inputs the PHP version logged
/// for audit ("what balance, what threshold, what category of alert did we
/// fire"). Kept separate from `EmailNotificationLog` so the audit query
/// ("every red alert we ever sent this customer") doesn't have to filter by
/// notificationType; a one-row-per-send history is the whole point of the
/// legacy table.
model AccountStatusHistory {
id String @id @default(uuid())
sendDate DateTime @default(now())
customerId String
customer Customer @relation(fields: [customerId], references: [id])
customerName String
customerEmail String
/// "DEBAJO DEL TIPO" or "EN ROJO" — the legacy literal strings. Kept
/// verbatim (not an enum) because the PHP scripts and downstream reports
/// filter by them, and "preserve legacy semantics" is the stated goal.
tipo String
/// Exchange rate at send time, kept for currency conversions downstream.
/// Null when the customer has no exchange-rate context (no FX movement).
tCambio Decimal? @db.Decimal(10, 4)
/// Customer's balance at send time, in the customer's currency. Negative
/// for red; 0..min for yellow.
balance Decimal @db.Decimal(12, 2)
/// Legacy formula: `0 - TIPO - BALANCE` — the amount the customer needs to
/// deposit to clear the threshold. Preserved verbatim even though it
/// double-subtracts; downstream reports depend on the exact figure.
solicitado Decimal @db.Decimal(12, 2)
/// 0 = yellow, 1 = red. Mirrors the legacy `level` column.
level Int
@@index([sendDate])
@@index([customerId, sendDate])
@@index([level, sendDate])
@@map("account_status_history")
}
// ---------------------------------------------------------------------------
// Admin database operations (Operaciones): backup / restore / re-import / sync.
// Each long-running op is one OpsJob row so the web UI can poll status + tail
// the captured log. Rows are the audit trail for who ran a destructive op.
// ---------------------------------------------------------------------------
enum OpsJobKind {
BACKUP
RESTORE
REIMPORT
SYNC
}
enum OpsJobStatus {
RUNNING
SUCCESS
FAILED
}
model OpsJob {
id String @id @default(uuid())
kind OpsJobKind
status OpsJobStatus @default(RUNNING)
// Combined stdout+stderr of the spawned process, appended as it runs.
log String @db.LongText
// Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on
// createdById — the actor id is stored flat, like activity_logs' userId use.
params Json?
createdById String?
startedAt DateTime @default(now())
finishedAt DateTime?
@@index([status])
@@index([startedAt])
@@map("ops_jobs")
}
model ScheduledJobState {
name String @id
lockedUntil DateTime?
lastSuccessfulAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("scheduled_job_states")
}
/// Operator-editable configuration — the settings that staff must be able to
/// change without a redeploy.
///
/// Deliberately NOT a home for everything in the environment. Credentials and
/// endpoints (SES keys, DATABASE_URL, S3) stay in env: they are deployment
/// identity, they must exist before the app can talk to its own database, and
/// putting a secret in a table only widens who can read it. What belongs here
/// is the opposite kind of value — no secret, changes on office business
/// rhythm rather than deploy rhythm, and wrong far more often than the
/// deployment is.
///
/// `value` is TEXT holding whatever encoding the owning feature defines
/// (a comma-separated list, a JSON blob). Each setting has exactly one reader,
/// which owns parsing and validation; there is no generic typed accessor,
/// because a schema-less bag with a typed façade is just a schema with the
/// checks moved somewhere easier to forget.
model AppSetting {
key String @id
value String @db.Text
updatedAt DateTime @updatedAt
/// Who last changed it. Null for rows written before the UI existed or by
/// a migration. Not an FK: a setting must outlive the user who set it.
updatedById String?
@@map("app_settings")
}