Files
jorgecuadros-platform/packages/database/prisma/schema.prisma
T
rmancinasandClaude Opus 5 9ba5d2d09a
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m43s
Build and Push Images / Build jorgecuadros-api (push) Successful in 1m59s
feat(bank): multi-bank chequera — required bankAccountId, per-account scoping
The office keeps more than one operating account (Utilities banks in MXN,
Seguros in USD), but bank_transactions was a single implicit MXN register by
design. Adds Bank/BankAccount and makes every read and write in the module
scoped to exactly one account.

Schema:
- Bank / BankAccount. Currency is fixed per account and BankTransaction has
  no currency column of its own — a movement inherits its account's, the way
  a real bank account doesn't mix currencies.
- BankTransaction.bankAccountId, required. A movement with no known account
  isn't reconcilable against a statement.
- @@index([bankAccountId, transactionDate]): every read now filters by
  account and orders/groups by date.

Migration:
- backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)"
  and backfills all 22,669 existing rows onto it, then promotes the column to
  NOT NULL and attaches the FK. Standalone because prisma db push cannot add
  a required column to a populated table. Idempotent; re-running once a second
  account exists does not re-point rows.
- run_all.py runs it (both modes) before transform_bank.py, which now resolves
  the account by label and fails fast if it is missing.

API:
- ?bankAccountId= required on list/stats/facets/summary — not optional with an
  "all accounts" default, since summing an MXN and a USD register repeats the
  currency-collapsing mistake the billing module exists to prevent. Missing is
  400, unknown is 404.
- facets() had no account clause at all and summary() has two raw-SQL rollups;
  all three are now parameterised. Scoping only one of summary's queries would
  leave the year list and its drill-down describing different books.
- New bank/accounts + bank/banks sub-resource under a MANAGER
  bank:manage-accounts ability. currency is absent from the update DTO: booked
  movements are denominated in it, so editing would re-denominate history.
  Capture into a closed account is rejected.

Web:
- /banco gains an account picker (remembered per browser) and reads every
  figure in the selected account's currency; the "single currency (MXN)"
  doc-comment and the hardcoded MXN formatting are gone.
- New /banco/cuentas for banks and accounts. Accounts are closed, never
  deleted — the FK is required, so deleting one would destroy its register.
- /inicio's chequera card names the account it is reading instead of implying
  a single register.

Verified against dev + browser: a second USD account showed full read/write
isolation from the MXN register, whose totals were unchanged (22,669
movements, net 1,014,266.97).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:54:16 -07:00

680 lines
23 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"
}
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 <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?
// 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")
}
/// 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[]
@@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")
}