Files
jorgecuadros-platform/packages/database/prisma/schema.prisma
T
rmancinasandClaude Opus 5 4d5008b545
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m41s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s
feat(statements): OCR intake for scanned utility bills
Staff key 300+ utility statements per company per month by hand. This adds
the ingest -> split -> OCR -> match -> review pipeline that proposes customer
and amount per page instead (RECEIPT_CAPTURE_SPEC §2), posting through the
existing BillingService.createBatch seam with source=OCR and a per-document
captureRef so machine and hand capture share one write path and audit trail.

Everything was designed against 10 real scanned statements (46 pages of CFE,
CESPT and Telnor bills) rather than from the sample-free spec. The scans have
no text layer at all — they are camera images — so OCR is mandatory, and they
arrive bundled one customer per page. Measured on those pages the parser
identifies the provider 46/46 and reads an account reference 43/46; against
the dev database that is 39/46 (85%) exact auto-match, 40/46 identified, with
the rest genuine review cases. That closes the OCR-provider question in favour
of self-hosted Tesseract: it clears the bar for a queue where a human confirms
every row, and OcrProvider keeps a managed API a one-line swap.

The samples corrected three things the spec had wrong or unknown:

- Clave catastral is NOT predial. DATMEX.clave (934 rows) is what CESPT and
  predial bills print; DATMEX.predial, which PROPERTY_TAX.accountNumber holds,
  has 663 distinct values across 1135 rows and appears on no statement. The
  clave now lives on Property.cadastralKey as the matcher's secondary key;
  predial is left untouched. This had been blocking predial matching.
- Gas was recoverable: 160 of 334 DATMEX.gas values are real account numbers
  (the rest are ESTACIONARIO/CILINDRO descriptors), now in GAS.meterNumber.
- Phone is one billed line per property (534/18/1 across phone1/2/3), so the
  new TELEPHONE ServiceKind backfills from phone1 only, not three rows.

Matching is scoped to one column per service kind and never reads the customer
name — a CESPT receipt prints ARNAIZ ROSAS ELSA AURORA for an account this
office holds under CATT, RANDY, because the printed name is the registrant,
not the current owner. Where a provider prints a payment barcode it beats the
printed label (one CFE label OCR'd a digit too many while its barcode was
correct) and the two cross-check, with disagreement forcing review.

Confirming a document whose service had no reference writes it back, so gas
and any other cold start is a one-time cost rather than a permanent queue.

Verified end to end against the live dev API and MinIO: real scans uploaded
over HTTP, matched, confirmed against a check, and the resulting rows checked
in MySQL (negative amounts, captureSource=OCR, concept derived from the batch
kind, captureRef linking back to each page). Re-confirming a posted batch is
refused. Test data was removed afterwards.

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

832 lines
29 KiB
Plaintext

// Unified customer / insurance / utilities data model.
// See C:\Users\ricar\.claude\plans\logical-yawning-tome.md for the migration
// plan this schema implements (source: UTILITIES.accdb, SEGUROS 16_be.mdb,
// SCOTHIA.mdb). Every model that originates from a legacy Access table
// carries legacySource* provenance columns so migrated rows are traceable
// back to their Access original and the ETL can be re-run idempotently.
generator client {
provider = "prisma-client-js"
output = "../generated/client"
// "native" covers local dev. The musl target is declared EXPLICITLY because
// Prisma picks the engine by sniffing the build environment: the Docker build
// stage has no openssl, so it detected plain "linux-musl", while the runtime
// stage (which needs openssl for other reasons) then demanded
// "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
// engine that ships independent of what happens to be installed at build time.
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
enum Currency {
USD
MXN
}
enum TransactionDomain {
UTILITY
INSURANCE
TRUST
}
/// How a ledger row entered the system. Every capture path funnels through
/// BillingService (single write path, single audit trail); this records which
/// one, so an auto-captured receipt is auditable without joining the statement
/// tables. `OCR` is reserved for the statement auto-capture pipeline
/// (docs/RECEIPT_CAPTURE_SPEC.md §2), which posts through the same batch path
/// as hand-keyed check batches.
enum TransactionCaptureSource {
MANUAL
BATCH
OCR
}
enum ServiceKind {
WATER
ELECTRIC
GAS
CABLE
PROPERTY_TAX
FEDERAL_ZONE
ALARM
/// Telephone was never unpivoted out of DATMEX — the numbers sat on
/// `Property.phone1/2/3` as contact fields even though the legacy ledger
/// billed phone as its own transaction type. OCR matching needs a real
/// service row to match a Telnor bill against, so it becomes one; see
/// `migration/backfill_statement_match_fields.py`.
TELEPHONE
OTHER
}
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
/// read-only role; STAFF and above can write. Enforced by the API's ability
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
enum UserRole {
ADMIN
MANAGER
STAFF
VIEWER
}
// ---------------------------------------------------------------------------
// Identity — the actual point of the project: one customer record shared by
// both business lines.
// ---------------------------------------------------------------------------
model Customer {
id String @id @default(uuid())
name String
// Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the
// normal case. Anything else means DATGRAL's name was blank and the name was
// recovered from a secondary table (see migration/transform_customers.py),
// so staff can tell a reconstructed name from an original one.
nameSource String?
// True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists
// can sort nameless records last — ordering by `name` alone puts them first,
// since "(" sorts before every letter.
nameMissing Boolean @default(false)
addressLine1 String?
addressLine2 String?
city String?
state String?
zipCode String?
country String?
phone String?
mobile String?
fax String?
email String?
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[]
@@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 <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?
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?
/// 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?
@@index([cadastralKey])
@@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
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
}
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?
@@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")
}
/// 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")
@@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")
}