The office still issues cash receipts from Access (EFE MENU -> EFE). This is that form: cliente, fecha, monto + moneda, concepto, elaboró (the logged-in user), fecha de depósito, and a folio that continues the legacy book. - cash_receipts, folio unique. Next folio = one past the highest of the platform's receipts and the imported utilities EFECTIVO journal, so after the cutover import it lands on the office's next number (13935 on prod today). A typed folio the legacy book already used is refused. - Issuing a receipt posts its credit in the same database transaction, as legacy posted to DATOS2: reference C<folio>, CASH DEPOSIT, pseudo check CASH<MMDD><P|D>. Dollars with a rate credit a pesos ledger (1,500 USD at 16.80 -> 25,200 MXN, as C13830 did). Voiding that row voids the receipt. - /efectivo: issue, search, record the deposit date; /efectivo/<folio> prints the receipt. - Reporte de efectivo now lists platform receipts by receipt, not by check, so each is counted once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1325 lines
53 KiB
Plaintext
1325 lines
53 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)
|
||
// The number on the customer's paper folder on the office shelf. A label,
|
||
// not an identity: nothing joins on it. The office hands a number back out
|
||
// once an account is closed, so it is unique only among customers that are
|
||
// not archived — enforced by CustomersService, not by an index, because
|
||
// Prisma cannot model a partial/functional unique and would drop one.
|
||
// Imported customers start with their utilities NUMid.
|
||
folderNumber Int?
|
||
addressLine1 String?
|
||
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[]
|
||
statementSplits StatementDocumentSplit[]
|
||
cashReceipts CashReceipt[]
|
||
policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")
|
||
|
||
emailNotificationLogs EmailNotificationLog[]
|
||
accountStatusHistory AccountStatusHistory[]
|
||
|
||
@@index([folderNumber])
|
||
@@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.
|
||
/// One customer's share of a split receipt — a water bill two neighbours
|
||
/// share, an HOA fee spread across a building. Free amounts, no stored
|
||
/// percentages: the office splits unevenly as often as not.
|
||
model StatementDocumentSplit {
|
||
id String @id @default(uuid())
|
||
documentId String
|
||
document StatementDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
/// 1-based; also the `#n` in the posted row's captureRef.
|
||
sequence Int
|
||
customerId String
|
||
customer Customer @relation(fields: [customerId], references: [id])
|
||
amount Decimal @db.Decimal(12, 2)
|
||
|
||
postedTransactionId String? @unique
|
||
postedTransaction Transaction? @relation("StatementDocumentSplitTransaction", fields: [postedTransactionId], references: [id])
|
||
|
||
@@unique([documentId, sequence])
|
||
@@map("statement_document_splits")
|
||
}
|
||
|
||
model StatementDocument {
|
||
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])
|
||
|
||
/// Sin fondos for this receipt alone: posted as outstanding even when the
|
||
/// rest of the check is funded.
|
||
outstanding Boolean @default(false)
|
||
|
||
/// When set, the receipt is paid for by several customers and posts one
|
||
/// charge per part instead of one to `matchedCustomer`. The parts always sum
|
||
/// to `extractedAmount`.
|
||
splits StatementDocumentSplit[]
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@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?
|
||
statementSplit StatementDocumentSplit? @relation("StatementDocumentSplitTransaction")
|
||
cashReceipt CashReceipt?
|
||
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")
|
||
cashReceipts CashReceipt[] @relation("CashReceiptPreparer")
|
||
|
||
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")
|
||
}
|
||
|
||
/// The EFE cash receipt book (legacy EFE form / EFECTIVO table): the paper the
|
||
/// office hands a customer who pays in cash. A journal, not a ledger — the
|
||
/// money reaches the customer's balance through `transaction`, posted with it
|
||
/// as `C<folio>` exactly as legacy posted to DATOS2. Voiding that row voids
|
||
/// the receipt.
|
||
model CashReceipt {
|
||
id String @id @default(uuid())
|
||
/// Printed receipt number. Continues the legacy book's sequence.
|
||
folio Int @unique
|
||
customerId String
|
||
customer Customer @relation(fields: [customerId], references: [id])
|
||
receivedAt DateTime
|
||
amount Decimal @db.Decimal(12, 2)
|
||
currency Currency
|
||
/// Set when dollars are credited to a pesos ledger, as legacy did; the
|
||
/// posted row then carries amount × rate in MXN.
|
||
exchangeRate Decimal? @db.Decimal(10, 4)
|
||
concept String @db.Text
|
||
/// ELABORÓ — whoever was logged in.
|
||
preparedById String
|
||
preparedBy User @relation("CashReceiptPreparer", fields: [preparedById], references: [id])
|
||
/// FECHA DEPÓSITO — filled in when the cash reaches the bank.
|
||
depositedAt DateTime?
|
||
transactionId String @unique
|
||
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([customerId])
|
||
@@index([receivedAt])
|
||
@@map("cash_receipts")
|
||
}
|