// 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 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" today; /// future providers (AXA, GNP, …) 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 — preserved verbatim so a missing premium receipt still leaves /// the coverages auditable. extractedCoveragesJson Json? extractedPremiumPayment 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? matchNote String? 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") }