feat(policies): capture the full premium breakdown
The capture form only ever had prima neta, derecho de póliza and comisión.
The Access form it replaces has seven figures, and the four that were missing
are the ones that make a policy paid in installments add up.
Adds recargo, IVA, prima total and forma de pago to the policy header, the
same breakdown per installment, and a per-line-of-business IVA rate.
IVA and prima total are the only derived figures:
base = prima neta + recargo + derecho de póliza
IVA = round(base * tasa)
total = base + IVA
The recargo is inside the taxable base. That is not a guess — policy 7006785
prints IVA 52.03 on 610.86 + 8.55 + 31.00, and leaving the recargo out gives
51.35, which matches nothing on the page. Both of its money rows are asserted
in premium.spec.ts. The recargo itself is never derived: the carrier quotes it,
so staff key it in, and the field is disabled on ANNUAL/SINGLE. Both derived
figures are stored rather than recomputed on read, and stay editable, because
the printed policy is the record of truth and a later rate change must not
silently restate what was issued.
The rate lives on PolicyType (seeded to 0.08, editable in Catálogos), which is
the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made configurable. The
rate applied is stamped on the policy so an old one reads back at its original
rate.
Per-installment, not two fixed slots on the header: a policy split into several
exhibiciones prices each payment separately — that is why the Access form drew
the money row twice — and a trimestral policy needs four, which the Access
layout could not hold.
Also fixes two losses in the ETL, which is how these went missing:
- `forma_pago` was marked consumed by the coverage sweep and then never
written to any column, so FORMA PAGO existed nowhere in the platform.
- `recargo` and the whole second money row fell into `coveragesJson` as
loose strings, mislabeled as coverage amounts.
transform_policies.py now writes all of it directly;
backfill_policy_premium_breakdown.py recovers it on a database that must not be
re-imported, and strips the migrated keys back out of coveragesJson. Both are
COALESCE-only, so a figure a human has corrected in the app wins.
IVA and TOTAL are NOT backfilled: they were unbound calculated controls on the
Access form, never columns, so there is nothing to recover and every migrated
policy reads null until it is edited.
The backfill warns on 5 annual policies that carry a non-zero recargo — a
contradiction that predates this change and is left for a human, not silently
corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+37
@@ -0,0 +1,37 @@
|
||||
-- Premium breakdown the Access capture form had and this schema did not:
|
||||
-- RECARGO, IVA and PRIMA TOTAL on the policy header, the same six figures per
|
||||
-- payment on the installments, and the FORMA PAGO that decides whether a
|
||||
-- surcharge applies at all.
|
||||
--
|
||||
-- IVA and TOTAL were never columns in Access — they were unbound calculated
|
||||
-- controls on the form — so there is nothing to backfill for them here and
|
||||
-- every migrated row stays null until somebody edits the policy. RECARGO and
|
||||
-- the per-installment figures DO exist in the legacy data; they are currently
|
||||
-- stranded inside `policies.coveragesJson` (the migration swept every column
|
||||
-- it did not model into that blob) and are recovered by
|
||||
-- `migration/backfill_policy_premium_breakdown.py`, not by this migration.
|
||||
|
||||
ALTER TABLE `policy_types`
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL;
|
||||
|
||||
ALTER TABLE `policies`
|
||||
ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `tax` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
|
||||
ADD COLUMN `paymentFrequency` ENUM('ANNUAL', 'SEMIANNUAL', 'QUARTERLY', 'MONTHLY', 'SINGLE') NULL;
|
||||
|
||||
ALTER TABLE `policy_payment_installments`
|
||||
ADD COLUMN `netPremium` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `policyFee` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `tax` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
|
||||
ADD COLUMN `total` DECIMAL(12, 2) NULL,
|
||||
ADD COLUMN `commission` DECIMAL(12, 2) NULL;
|
||||
|
||||
-- Seed the rate the books actually use. The legacy IMPUESTOS and
|
||||
-- IMPUESTOS_AUTOS tables each held exactly one row, both 0.0800, covering the
|
||||
-- home and auto lines respectively; applying it to every existing type
|
||||
-- reproduces current behaviour rather than changing it. Types created later
|
||||
-- start null and fall back to the API default.
|
||||
UPDATE `policy_types` SET `taxRate` = 0.0800 WHERE `taxRate` IS NULL;
|
||||
@@ -158,10 +158,31 @@ model InsuranceProvider {
|
||||
@@map("insurance_providers")
|
||||
}
|
||||
|
||||
/// How the premium is split into payments. Drives whether a surcharge
|
||||
/// applies at all: the legacy books only ever charge `recargo` on a policy
|
||||
/// paid in more than one exhibición, never on an annual one. Values come from
|
||||
/// the Access `FORMA PAGO` column (ANNUAL / SEMESTRAL / CONTADO) plus the
|
||||
/// quarterly option Jorge sells today but never recorded in Access.
|
||||
enum PaymentFrequency {
|
||||
ANNUAL
|
||||
SEMIANNUAL
|
||||
QUARTERLY
|
||||
MONTHLY
|
||||
/// Legacy "CONTADO" — the whole premium in one payment, no schedule.
|
||||
SINGLE
|
||||
}
|
||||
|
||||
model PolicyType {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
shortDescription String?
|
||||
/// IVA rate charged on this line of business, as a fraction (0.08 = 8%).
|
||||
/// Replaces the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables, which
|
||||
/// held exactly one rate each — per line of business, editable without a
|
||||
/// deploy, because the rate is a tax rule and tax rules change. Null falls
|
||||
/// back to DEFAULT_TAX_RATE in the API rather than to "no tax", so a type
|
||||
/// nobody has configured still computes the same 8% the books use today.
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
policies Policy[]
|
||||
|
||||
@@map("policy_types")
|
||||
@@ -185,10 +206,32 @@ model Policy {
|
||||
policyTo DateTime?
|
||||
coveragePeriodDays Int? @default(365)
|
||||
netPremium Decimal? @db.Decimal(12, 2)
|
||||
/// "Recargo" — the financing surcharge for paying in installments. Entered
|
||||
/// by hand, never derived: it is quoted by the carrier, not computed here.
|
||||
/// Only ever set when `paymentFrequency` is not ANNUAL/SINGLE, and it IS
|
||||
/// part of the taxable base (verified against the Access books: policy
|
||||
/// 7006785 only reconciles as (610.86 + 8.55 + 31.00) * 0.08 = 52.03).
|
||||
surcharge Decimal? @db.Decimal(12, 2)
|
||||
policyFee Decimal? @db.Decimal(12, 2)
|
||||
brokerFee Decimal? @db.Decimal(12, 2)
|
||||
commission Decimal? @db.Decimal(12, 2)
|
||||
/// IVA. Access never stored this — it was an unbound calculated control on
|
||||
/// the form — so every legacy row starts null and is filled going forward.
|
||||
/// Stored rather than computed on read because the printed policy is the
|
||||
/// record of truth and its rounding must survive a later rate change.
|
||||
tax Decimal? @db.Decimal(12, 2)
|
||||
/// The rate actually applied when `tax` was written, as a fraction. Kept on
|
||||
/// the row so a policy issued at 8% still reads back as 8% after somebody
|
||||
/// edits the PolicyType to a new rate.
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
/// Prima total = netPremium + surcharge + policyFee + tax. Populated by the
|
||||
/// capture form from now on. NOTE the legacy rows: `total` is 0 or null on
|
||||
/// all but 2 of 2378 migrated policies, so list/sort code must keep using
|
||||
/// netPremium as the headline (see policies.service.ts).
|
||||
total Decimal? @db.Decimal(12, 2)
|
||||
/// ANNUAL on all but 31 legacy rows — and the migration used to drop the
|
||||
/// column entirely, so every pre-2026 policy reads null here.
|
||||
paymentFrequency PaymentFrequency?
|
||||
currency Currency @default(MXN)
|
||||
observations String? @db.Text
|
||||
notes String? @db.Text
|
||||
@@ -267,6 +310,23 @@ model PolicyPaymentInstallment {
|
||||
checkNumber String?
|
||||
isCash Boolean @default(false)
|
||||
|
||||
// Per-payment premium breakdown. A policy paid in more than one exhibición
|
||||
// prices EACH payment separately — its own net premium, its own surcharge,
|
||||
// its own IVA — which is why the Access form printed the whole money row
|
||||
// twice (P NETA / RECARGO / D POL / IVA / TOTAL / COM, once per pago) and
|
||||
// why these cannot live on the policy header alone. `amount` stays the
|
||||
// authoritative figure actually collected: it is what the cheque was
|
||||
// written for and it drifts from `total` by a peso or two in the books
|
||||
// (policy 7006785: amount 702.73 vs total 702.44), so it is deliberately
|
||||
// NOT recomputed from this breakdown.
|
||||
netPremium Decimal? @db.Decimal(12, 2)
|
||||
surcharge Decimal? @db.Decimal(12, 2)
|
||||
policyFee Decimal? @db.Decimal(12, 2)
|
||||
tax Decimal? @db.Decimal(12, 2)
|
||||
taxRate Decimal? @db.Decimal(6, 4)
|
||||
total Decimal? @db.Decimal(12, 2)
|
||||
commission Decimal? @db.Decimal(12, 2)
|
||||
|
||||
@@map("policy_payment_installments")
|
||||
}
|
||||
|
||||
@@ -430,25 +490,25 @@ model PolicyOcrDocument {
|
||||
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)
|
||||
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 and ANA's numbered risk sections — preserved verbatim so a
|
||||
/// missing premium receipt still leaves the coverages auditable.
|
||||
extractedCoveragesJson Json?
|
||||
extractedPremiumPayment String?
|
||||
extractedCoveragesJson Json?
|
||||
extractedPremiumPayment String?
|
||||
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
|
||||
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
|
||||
/// a weekend policy by a year.
|
||||
@@ -456,26 +516,26 @@ model PolicyOcrDocument {
|
||||
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
|
||||
/// Written to `Vehicle` rows on confirm; kept here so the review screen
|
||||
/// shows what was read before anything is applied.
|
||||
extractedVehiclesJson Json?
|
||||
extractedVehiclesJson Json?
|
||||
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
|
||||
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
|
||||
/// rows on confirm.
|
||||
extractedDriversJson Json?
|
||||
extractedDriversJson Json?
|
||||
/// The `PolicyType.name` the parser read the product as ("AUTO",
|
||||
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
|
||||
/// database, so confirm resolves it against `policy_types` and leaves
|
||||
/// `Policy.policyTypeId` null if there is no such row.
|
||||
extractedPolicyTypeName String?
|
||||
extractedPolicyTypeName 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])
|
||||
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?
|
||||
matchCandidates Json?
|
||||
/// `CustomerNameSuggestion[]` — customers whose name matches the printed
|
||||
/// insured name, ranked. A SUGGESTION, never a match: it is deliberately
|
||||
/// kept out of `matchCandidates` so the review screen cannot mistake a
|
||||
@@ -487,7 +547,7 @@ model PolicyOcrDocument {
|
||||
/// a multi-section ANA policy runs past 191 characters routinely. Silently
|
||||
/// truncating it drops the tail notes, which are the ones that say what
|
||||
/// could NOT be read.
|
||||
matchNote String? @db.Text
|
||||
matchNote String? @db.Text
|
||||
|
||||
reviewedById String?
|
||||
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
|
||||
@@ -1021,8 +1081,8 @@ enum EmailNotificationStatus {
|
||||
/// 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())
|
||||
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")
|
||||
@@ -1040,7 +1100,7 @@ model EmailNotificationLog {
|
||||
/// 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])
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
customerName String
|
||||
customerEmail String
|
||||
/// Subject line of the email we attempted to send.
|
||||
@@ -1048,16 +1108,16 @@ model EmailNotificationLog {
|
||||
/// 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
|
||||
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
|
||||
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)
|
||||
debug Boolean @default(false)
|
||||
/// SES SendEmail MessageId, when we actually got one back. Null on
|
||||
/// failures, skipped rows, and dev/mock transport.
|
||||
providerMessageId String?
|
||||
@@ -1065,7 +1125,7 @@ model EmailNotificationLog {
|
||||
/// insert so a verbose SES bounce payload can't blow the column.
|
||||
providerResponse String?
|
||||
status EmailNotificationStatus
|
||||
error String? @db.Text
|
||||
error String? @db.Text
|
||||
|
||||
@@index([sendDate])
|
||||
@@index([notificationType, sendDate])
|
||||
|
||||
Reference in New Issue
Block a user