Files
jorgecuadros-platform/packages/database/prisma/schema.prisma
T
rmancinasandClaude Opus 5 d645ba51d3
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m39s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m21s
feat(policy-ocr): read A.N.A. Seguros' two policy faces
A.N.A. is the Rosarito office's tourist auto book and the second carrier
the policy OCR pipeline reads. It ships two unrelated faces, and the split
is different from GMX's: GMX ships two documents about one policy, A.N.A.
ships two products.

  AUTOMOBILE (SPECIAL POLICY FOR TOURISTS)  insures a car; vehicle table,
                                            9 numbered sections, one
                                            LIMIT OF LIABILITY column
  DRIVER'S POLICY (the office: "licencia")  insures up to 5 named drivers;
                                            no vehicle at all, 6 unnumbered
                                            sections in a different order,
                                            SUM INSURED + PREMIUM columns

The four automobile products the office sells (amplia / responsabilidad
civil, annual / by-the-day) are the same layout with different numbers, so
they get one parser rather than four.

These are born-digital portal PDFs, so pdftotext -layout returns exact
columns and the driver's-policy parser uses that: its two value columns
print the same shape (100,000.00 usd. / 18.70 usd.) with no per-row label,
so horizontal position is the only thing separating them. The split comes
from the header's own offsets, not a constant, because they shift between
products; when it can't be read every amount is reported as a sum insured
and the reviewer is told, rather than half the premiums being filed as
coverage limits.

Three things the layout will punish a naive read for:

- Each PDF prints its face two or three times (ORIGINAL, AGENT COPY, then
  a receipt and three travel cards) and the pipeline concatenates every
  page before parsing. The coverage walk is bounded to the first copy and
  the driver list to the first POLICY HOLDER block. Unbounded, the licencia
  returns the same person three times, which reads as a three-driver policy
  rather than as a bug.
- The money row is read positionally off its header. An unused DISCOUNT
  prints as a bare "-", so "find the six amounts" shifts every value one
  column left on a discounted policy.
- Two five-digit numbers sit in the header band and only one is the agent
  clave; the agent's street address is "BENITO JUAREZ 25 No.50 INT 38",
  three lines above the No. cell holding the policy number.

Sections 6-8 print a PREMIUM where the others print a limit, so
ParsedCoverage gains an optional `premium` (GMX never fills it) and the
review table a column: $40 is what legal aid cost, not a $40 liability
limit. Exclusions follow the GMX rule and go in the risk label with a null
amount -- which matters more here, since a responsabilidad-civil policy
prints 0.00 for material damage and the two are identical on the page.

Also in this change:

- coveragePeriodDays is parsed and written. A.N.A. sells 3- and 4-day
  policies; Policy.coveragePeriodDays defaults to 365, so a weekend policy
  left at the default sits in the renewals window a year out. Derived from
  the dates, cross-checked against the printed DAYS cell, disagreement
  noted not resolved.
- Vehicles and named drivers are parsed, shown read-only in review, and
  written as Vehicle / InsuredDriver rows on confirm, skipping any already
  on the policy (VIN then plate; licence then name). The case that forces
  the skip is confirming a renewal onto an existing policy. Nothing is ever
  updated or deleted -- a changed plate lands as a second row for a human.
- Batch.provider is set from what the parsers actually claimed instead of
  being hardcoded "GMX", so a mixed upload is labelled as mixed and the
  header can never contradict its own documents. PolicyDocument.documentType
  follows the same rule (was hardcoded GMX_POLICY).
- matchNote becomes TEXT. It was VARCHAR(191) and the note trail was sliced
  to 190 chars, which cut the tail notes -- the "could not read X" ones.
- The policy detail page renders an array coveragesJson as a table. Both
  shapes have always been possible there, but the object renderer was the
  only one, so an OCR-confirmed policy showed a row per array index labelled
  "0", "1", "2" with [object Object] as the value. ANA makes that routine.

GMX is untouched behaviourally; its two parsers now spread a shared empty
base instead of listing every null field. 29 new parser cases against
verbatim pdftotext output of three real ANA PDFs, 53 in the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:09:16 -07:00

1174 lines
45 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")
}
model PolicyType {
id String @id @default(uuid())
name String @unique
shortDescription String?
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)
policyFee Decimal? @db.Decimal(12, 2)
brokerFee Decimal? @db.Decimal(12, 2)
commission Decimal? @db.Decimal(12, 2)
total Decimal? @db.Decimal(12, 2)
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)
@@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)
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?
// 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?
/// 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")
}