// 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" } 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 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? 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[] @@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[] @@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? 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") } // --------------------------------------------------------------------------- // 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? // 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]) @@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 @@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") } /// 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()) @@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]) @@unique([legacySourceDb, legacySourceTable, legacyId]) @@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") } /// 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()) 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? @@unique([legacySourceTable, legacyId]) @@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) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt activityLogs ActivityLog[] @@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") } // --------------------------------------------------------------------------- // 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") }