// Unified customer / insurance / utilities data model.
// See C:\Users\ricar\.claude\plans\logical-yawning-tome.md for the migration
// plan this schema implements (source: UTILITIES.accdb, SEGUROS 16_be.mdb,
// SCOTHIA.mdb). Every model that originates from a legacy Access table
// carries legacySource* provenance columns so migrated rows are traceable
// back to their Access original and the ETL can be re-run idempotently.

generator client {
  provider      = "prisma-client-js"
  output        = "../generated/client"
  // "native" covers local dev. The musl target is declared EXPLICITLY because
  // Prisma picks the engine by sniffing the build environment: the Docker build
  // stage has no openssl, so it detected plain "linux-musl", while the runtime
  // stage (which needs openssl for other reasons) then demanded
  // "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
  // engine that ships independent of what happens to be installed at build time.
  binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

enum Currency {
  USD
  MXN
}

enum TransactionDomain {
  UTILITY
  INSURANCE
  TRUST
}

/// How a ledger row entered the system. Every capture path funnels through
/// BillingService (single write path, single audit trail); this records which
/// one, so an auto-captured receipt is auditable without joining the statement
/// tables. `OCR` is reserved for the statement auto-capture pipeline
/// (docs/RECEIPT_CAPTURE_SPEC.md §2), which posts through the same batch path
/// as hand-keyed check batches.
enum TransactionCaptureSource {
  MANUAL
  BATCH
  OCR
}

enum ServiceKind {
  WATER
  ELECTRIC
  GAS
  CABLE
  PROPERTY_TAX
  FEDERAL_ZONE
  ALARM
  /// Telephone was never unpivoted out of DATMEX — the numbers sat on
  /// `Property.phone1/2/3` as contact fields even though the legacy ledger
  /// billed phone as its own transaction type. OCR matching needs a real
  /// service row to match a Telnor bill against, so it becomes one; see
  /// `migration/backfill_statement_match_fields.py`.
  TELEPHONE
  OTHER
}

/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
/// read-only role; STAFF and above can write. Enforced by the API's ability
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
enum UserRole {
  ADMIN
  MANAGER
  STAFF
  VIEWER
}

// ---------------------------------------------------------------------------
// Identity — the actual point of the project: one customer record shared by
// both business lines.
// ---------------------------------------------------------------------------

model Customer {
  id                       String    @id @default(uuid())
  name                     String
  // Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the
  // normal case. Anything else means DATGRAL's name was blank and the name was
  // recovered from a secondary table (see migration/transform_customers.py),
  // so staff can tell a reconstructed name from an original one.
  nameSource               String?
  // True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists
  // can sort nameless records last — ordering by `name` alone puts them first,
  // since "(" sorts before every letter.
  nameMissing              Boolean   @default(false)
  addressLine1             String?
  addressLine2             String?
  city                     String?
  state                    String?
  zipCode                  String?
  country                  String?
  phone                    String?
  mobile                   String?
  fax                      String?
  email                    String?
  emailOptOut              Boolean   @default(false)
  notes                    String?   @db.Text
  identificationType       String?
  identificationNumber     String?
  identificationExpiration DateTime?
  customerSince            DateTime?
  status                   Boolean   @default(true)
  minimumBalance           Decimal?  @db.Decimal(12, 2)
  feeAmount                Decimal?  @db.Decimal(12, 2)
  preferredCurrency        Currency  @default(USD)
  // Soft-delete marker. Distinct from `status` (a legacy business flag): a
  // non-null archivedAt hides the row from default lists while preserving it
  // and its legacy provenance. Never hard-delete migrated data.
  archivedAt               DateTime?
  createdAt                DateTime  @default(now())
  updatedAt                DateTime  @updatedAt

  legacyRefs   CustomerLegacyRef[]
  properties   Property[]
  policies     Policy[]
  vehicles     Vehicle[]
  transactions Transaction[]

  statementDocuments StatementDocument[]
  policyOcrDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCustomer")

  emailNotificationLogs EmailNotificationLog[]
  accountStatusHistory  AccountStatusHistory[]

  @@map("customers")
}

/// Generalizes the old app's customer_mapping table: one row per legacy
/// record folded into this customer, from either source system.
model CustomerLegacyRef {
  id           String   @id @default(uuid())
  customerId   String
  customer     Customer @relation(fields: [customerId], references: [id])
  sourceSystem String // "utilities" | "insurance"
  sourceTable  String // e.g. "DATGRAL", "COBRO3"
  legacyId     String // stringified legacy id (source columns are often DOUBLE)
  createdAt    DateTime @default(now())

  @@unique([sourceSystem, sourceTable, legacyId])
  @@map("customer_legacy_refs")
}

// ---------------------------------------------------------------------------
// Insurance domain
// ---------------------------------------------------------------------------

model InsuranceProvider {
  id       String   @id @default(uuid())
  name     String   @unique
  policies Policy[]

  @@map("insurance_providers")
}

model PolicyType {
  id               String   @id @default(uuid())
  name             String   @unique
  shortDescription String?
  policies         Policy[]

  @@map("policy_types")
}

/// Consolidates INCENDIO/MULT/M EMPR/all auto-table variants/LICENCIAS into
/// one table with a policyType discriminator, instead of one Access table
/// per line of business.
model Policy {
  id                  String             @id @default(uuid())
  policyNumber        String
  customerId          String
  customer            Customer           @relation(fields: [customerId], references: [id])
  policyTypeId        String?
  policyType          PolicyType?        @relation(fields: [policyTypeId], references: [id])
  insuranceProviderId String?
  insuranceProvider   InsuranceProvider? @relation(fields: [insuranceProviderId], references: [id])
  agentName           String?
  policyDate          DateTime?
  policyFrom          DateTime?
  policyTo            DateTime?
  coveragePeriodDays  Int?               @default(365)
  netPremium          Decimal?           @db.Decimal(12, 2)
  policyFee           Decimal?           @db.Decimal(12, 2)
  brokerFee           Decimal?           @db.Decimal(12, 2)
  commission          Decimal?           @db.Decimal(12, 2)
  total               Decimal?           @db.Decimal(12, 2)
  currency            Currency           @default(MXN)
  observations        String?            @db.Text
  notes               String?            @db.Text
  coveragesJson       Json?
  endorsement         Boolean            @default(false)
  liquidated          Boolean            @default(false)
  liquidationNumber   String?
  liquidationDate     DateTime?
  // Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
  // policy data; archiving hides it from default lists.
  archivedAt          DateTime?
  legacySourceDb      String?
  legacySourceTable   String?
  legacyId            String?
  createdAt           DateTime           @default(now())
  updatedAt           DateTime           @updatedAt

  installments   PolicyPaymentInstallment[]
  vehicles       Vehicle[]
  insuredDrivers InsuredDriver[]
  beneficiaries  PolicyBeneficiary[]
  claims         Claim[]
  documents      PolicyDocument[]
  properties     Property[]
  renewalNotices RenewalNotice[]

  ocrMatchedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentPolicy")
  ocrCreatedDocuments PolicyOcrDocument[] @relation("PolicyOcrDocumentCreatedPolicy")

  @@unique([legacySourceDb, legacySourceTable, legacyId])
  @@index([policyNumber])
  @@map("policies")
}

enum RenewalNoticeChannel {
  MAIL
  EMAIL
}

/// Replaces the legacy `CONTROL <ramo> RENEW[2/3] X MES` reports — a
/// per-batch printed checklist of who'd been sent which reminder. One row
/// per notice generation actually sent for a policy, so "who got a 1st/2nd/
/// 3rd notice and when" is a query instead of a paper trail. See
/// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces.
model RenewalNotice {
  id                String               @id @default(uuid())
  policyId          String
  policy            Policy               @relation(fields: [policyId], references: [id])
  // 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming.
  generation        Int
  channel           RenewalNoticeChannel @default(MAIL)
  sentAt            DateTime?
  sentById          String?
  providerMessageId String?
  notes             String?              @db.Text
  createdAt         DateTime             @default(now())

  // One row per generation per policy — matches the legacy's 1st/2nd/3rd
  // notice cadence; re-running the same generation for a policy updates it
  // rather than duplicating a log entry.
  @@unique([policyId, generation])
  @@map("renewal_notices")
}

/// Unpivots the 4 hardcoded payment-installment columns found on every
/// legacy policy table (1ER PAGO/FECHA PAGO/NO CHEQUE, ...2, ...3, ...4).
model PolicyPaymentInstallment {
  id          String    @id @default(uuid())
  policyId    String
  policy      Policy    @relation(fields: [policyId], references: [id])
  sequence    Int
  amount      Decimal?  @db.Decimal(12, 2)
  currency    Currency  @default(MXN)
  dueDate     DateTime?
  paidDate    DateTime?
  checkNumber String?
  isCash      Boolean   @default(false)

  @@map("policy_payment_installments")
}

/// Unpivots MCA2's 3 hardcoded vehicle slots (and the single-vehicle auto
/// tables) into one row per vehicle.
model Vehicle {
  id                String    @id @default(uuid())
  customerId        String?
  customer          Customer? @relation(fields: [customerId], references: [id])
  policyId          String?
  policy            Policy?   @relation(fields: [policyId], references: [id])
  make              String?
  model             String?
  modelYear         String?
  bodyType          String?
  engineNumber      String?
  licensePlate      String?
  vinNumber         String?
  stateCode         String?
  notes             String?   @db.Text
  // One legacy policy row can carry up to 3 vehicles, so they share a
  // legacyId (the source row number) — provenance is NOT unique per vehicle.
  // Sync rebuilds legacy vehicles by scoped delete + reinsert instead of upsert.
  legacySourceTable String?
  legacyId          String?

  @@map("vehicles")
}

/// Unpivots the repeated named-insured/license columns in MCA2/LICENCIAS.
model InsuredDriver {
  id            String    @id @default(uuid())
  policyId      String
  policy        Policy    @relation(fields: [policyId], references: [id])
  fullName      String?
  birthDate     DateTime?
  sex           String?
  occupation    String?
  licenseNumber String?
  licenseState  String?

  @@map("insured_drivers")
}

/// From BENEF — already a clean child table in the source data.
model PolicyBeneficiary {
  id       String  @id @default(uuid())
  policyId String
  policy   Policy  @relation(fields: [policyId], references: [id])
  name     String?
  address  String?
  phone    String?
  email    String?

  @@map("policy_beneficiaries")
}

/// From DATOS (siniestros).
model Claim {
  id              String    @id @default(uuid())
  policyId        String
  policy          Policy    @relation(fields: [policyId], references: [id])
  claimType       String?
  incidentDate    DateTime?
  reportedDate    DateTime?
  description     String?   @db.Text
  adjusterId      String?
  adjuster        Adjuster? @relation(fields: [adjusterId], references: [id])
  claimedAmount   Decimal?  @db.Decimal(12, 2)
  settledAmount   Decimal?  @db.Decimal(12, 2)
  settlementDate  DateTime?
  checkNumber     String?
  resolved        Boolean   @default(false)
  resolutionNotes String?   @db.Text

  @@map("claims")
}

/// From AJUSTADORES / AJUSTADORESATLAS.
model Adjuster {
  id      String  @id @default(uuid())
  company String?
  city    String?
  name    String?
  phone   String?
  beeper  String?
  claims  Claim[]

  @@map("adjusters")
}

/// Extracted LONGBINARY blobs from the policy tables — file lives in object
/// storage, only the pointer + type lives here.
model PolicyDocument {
  id             String   @id @default(uuid())
  policyId       String
  policy         Policy   @relation(fields: [policyId], references: [id])
  documentType   String
  storageKey     String
  originalColumn String?
  createdAt      DateTime @default(now())

  @@map("policy_documents")
}

/// Insurance OCR intake (mirrors statement_batches / statement_documents for
/// the utility side). One upload session of policy PDFs from a provider
/// portal (GMX, etc.) — the parser proposes policyNumber → existing Policy
/// (or "new, pick customer"), staff confirms, and the system attaches the
/// source PDF and optionally writes a premium Transaction.
model PolicyOcrBatch {
  id           String               @id @default(uuid())
  /// Which insurance provider portal the batch came from. "GMX" 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")
}
