Compare commits

...
9 Commits
Author SHA1 Message Date
gitea-actions 8b8de0fdca chore(release): v1.0.23
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m19s
Deploy on tag / Deploy to galactus (push) Successful in 29s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.23.
2026-08-19 03:10:16 +00:00
rmancinasandClaude Opus 5 bc749055e7 feat(statements): scope the estado de cuenta to the current year, oldest-first
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m11s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
The office's EDO CUENTA sheet has always been a *year* statement: a balance
forward line dated January 1st, then that year's movements in the order they
happened. Both of ours read the other way — every year the customer ever had,
newest first — so staff comparing the screen against the printed sheet were
reading two different documents.

Movements are now bounded to the calendar year and returned ascending, on the
screen (/estado-cuenta/[id]) and in the printable `edo-cuenta-datos` report
alike.

Earlier rows are dropped from the *list*, not from the arithmetic. The balance
floor normally lands on January 1st already, so for most customers nothing
extra is dropped at all; when it doesn't — a customer the last legacy publish
skipped, or one that never had an opening balance — the earlier rows are
folded into a carried balance and shown as a single "saldo anterior" line.
Discarding them instead would restart every balance at zero on January 1st and
nothing would throw; the numbers would just be wrong, which is how the
double-counting bug survived for years. `opening` is exposed per currency and
per business line so the totals still reconcile against the last running
balance printed.

Two things the report was missing on its own are fixed while it is being
touched, since it must agree with the screen to the peso:

  - it never applied the balance floor, so every pre-cutover row was counted
    twice — once inside the opening balance and once as itself;
  - its source-table exclusion used a bare `notIn`, and `NULL NOT IN (...)` is
    NULL rather than true, so every app-captured row (which has no
    legacySourceTable) silently vanished from the printout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 08:45:15 -07:00
rmancinasandClaude Opus 5 75e9f582b4 feat(policy-ocr): store the IVA A.N.A. already prints
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
The parser has been reading A.N.A.'s `TAX` cell since the ANA layout landed,
but `ParsedPolicy` had nowhere to put it, so the figure only ever reached a
review note and every OCR-confirmed policy was written with `tax` null — even
though the paper states it.

`TAX` now flows parser -> `extractedTax` -> `Policy.tax`, alongside the premium
fields beside it. GMX stays null: its certificate carries no premium at all, so
there is no tax on it either.

The other two cells stay out, for reasons worth keeping:

  - `LOCAL TAX` is a separate levy with no destination column, and summing it
    into `tax` would produce an IVA that no longer divides back to a rate —
    which is the whole reason to store the figure. It reads 0.00 on every
    policy seen so far; a non-zero one now raises a note saying the total will
    not reconcile, instead of quietly inflating the IVA.
  - `DISCOUNT` has no column and prints as a bare "-" when unused, which is
    what makes the row positional rather than "find six amounts".

`taxRate` is left null by confirm. A.N.A. prints the amount, not the rate, and
back-dividing it would mint a rate the document never stated; the capture form
resolves one from the line of business instead.

The review screen gains derecho de póliza next to the new IVA field. It was
already parsed and already written on confirm, but never shown — and an IVA
with no fee beside it leaves the reviewer unable to see why premium + fee + tax
equals the printed total.

The spec's assertion moved off the note and onto the field, plus a check that
the row reconciles: 298.61 + 30.00 at 8% is 26.29, totalling 354.90. That
agreement is what proves the positional mapping landed on the right cells
rather than merely on six numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:38:23 -07:00
rmancinasandClaude Opus 5 48e01ddd21 feat(policies): capture the full premium breakdown
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m19s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
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>
2026-08-18 00:24:22 -07:00
gitea-actions 8c144fe8c4 chore(release): v1.0.22
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m6s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Deploy on tag / Deploy to galactus (push) Successful in 9s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.22.
2026-08-15 20:06:28 +00:00
rmancinasandClaude Opus 5 4f2f064955 fix(web): OCR batch review returns to capture, not the policy list
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m3s
"Volver a pólizas" on /polizas/captura/[id] dropped the reviewer at
/polizas, so getting back to the batch queue meant navigating in again.
Points at /polizas/captura and reads "Volver a captura", matching the
statement review screen, which returns to /recibos.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:04:14 -07:00
rmancinasandClaude Opus 5 2f99bd5f98 fix(web): define the layout utilities the screens were already using
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m5s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m50s
The policy OCR review header read
"Para revisarPágina 1700489616· PAMELA DENISE WAGONERLICENCIASANA".

`.row`, `.stack`, `.tag`, `.page-sub` and `.state-warn` are used across the
app but no rule ever defined them. Without `display: flex` the `gap` those
call sites pass does nothing, and JSX drops the newline between sibling
elements, so the header's spans concatenated. `.tag` rendered as prose rather
than as a chip for the same reason.

Defined against the existing design tokens: `.tag` takes `.badge`'s shape,
`.state-warn` is the gold sibling of `.state-error`, and `p.page-sub` keeps
the block margin while the inline form drops it so a flex row still centres.

Also dropped the hand-rolled "· " separator and margin from the OCR header —
the flex gap does that now, and the literal dot was left floating in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:58:25 -07:00
gitea-actions 458b2b272d chore(release): v1.0.21
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m13s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s
Deploy on tag / Deploy to galactus (push) Successful in 8s
Cut by rmancinas via the "Cut release" workflow. Pushing the tag triggers build.yml; deploy separately with tag=1.0.21.
2026-08-15 19:41:02 +00:00
rmancinasandClaude Opus 5 81938877ed feat(policy-ocr): suggest the customer from the printed insured name
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m57s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m5s
The office books customers surname-first ("WAGONER, PAMELA") and carriers
print them given-name-first ("PAMELA DENISE WAGONER"), so the review screen
made staff retype a name the parser had already read. Comparing normalized
token sets makes the two orderings the same thing.

Only on the zero-hit path, where the policy number found nothing and a human
has to pick a customer anyway. The suggestions are written to a new
`customerSuggestions` column rather than `matchCandidates`, which the review
screen reads as policy-number hits, and they never set `matchedCustomerId` or
`confident` — matching on `Policy.policyNumber` is unchanged.

Two tiers, drawn where the real book has cliffs: EXACT (identical token sets)
and PARTIAL (containment, >=2 shared tokens, surname present). Of 1536
customers, 1487 have a distinct token set, so EXACT cross-person collisions
are ~0; loosen to surname + first given name and 131 (8.5%) collide, and 185
surnames are shared by 524 customers, which is why one token is never enough
and the surname must be printed explicitly. Replaying every book row as a
carrier would print it: 97.9% top-ranked correct, 1.2% a different row, all
but two of those the same human on a duplicate or variant row.

Normalization folds accents (OCR's MUNOZ reaches the book's MUÑOZ), drops
initials, Spanish particles, JR/S.A. DE C.V., and any token with a digit —
ANA prints the phone hard against the name as `Ph.3102001538`. Names over 8
tokens or 80 characters are refused outright, because GMX's especificación
has no field labels and the parser has handed its whole first page over as
`insuredName`.

Not used for utility statements: there the registrant genuinely is not the
customer, so the same trick would be wrong rather than noisy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:37:23 -07:00
40 changed files with 2302 additions and 130 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/api", "name": "@jorgecuadros/api",
"version": "1.0.20", "version": "1.0.23",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
+100 -27
View File
@@ -701,11 +701,14 @@ export class BillingService {
/** /**
* One customer's statement across both business lines. * One customer's statement across both business lines.
* *
* Returns the *whole* ledger rather than a page of it: the heaviest customer * Scoped to the current calendar year and listed oldest-first, matching the
* legacy EDO CUENTA report the office has printed for years: an opening
* balance at the top, then the year's movements in the order they happened.
*
* Returns the *whole* year rather than a page of it: the heaviest customer
* carries 365 movements (mean 26), and a running balance is meaningless if * carries 365 movements (mean 26), and a running balance is meaningless if
* the client only holds a slice. The running balance is accumulated per * the client only holds a slice. The running balance is accumulated per
* currency in chronological order, then the list is handed back newest-first * currency in chronological order, with each row's balance-after attached.
* with each row's balance-after already attached.
*/ */
async statement(customerId: string) { async statement(customerId: string) {
const customer = await this.prisma.customer.findUnique({ const customer = await this.prisma.customer.findUnique({
@@ -790,15 +793,52 @@ export class BillingService {
}, },
}); });
// The statement covers one calendar year. The floor above normally lands on
// January 1st of it already — the legacy publish writes one BALANCE FORWARD
// per customer per year — in which case nothing extra is dropped here. When
// it doesn't (a customer the last publish skipped, or one that never had an
// opening balance), the earlier rows still have to be *counted* or every
// balance below is wrong, so they are folded into `opening` rather than
// listed. That is the same thing a BALANCE FORWARD row does, just computed.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
const running = new Map<string, Prisma.Decimal>(); const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => { /** Balance carried into `yearStart`, per currency. */
const opening = new Map<string, Prisma.Decimal>();
/** The same carried balance split by business line, keyed `domain|currency`. */
const openingByDomain = new Map<
string,
{ domain: TransactionDomain; currency: string; amount: Prisma.Decimal }
>();
/** The rows the statement lists — this year's. Totals are built from these. */
const visible: typeof rows = [];
const movements = rows.flatMap((r) => {
const voided = r.voidedAt != null; const voided = r.voidedAt != null;
const prev = running.get(r.currency) ?? new Prisma.Decimal(0); const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
// Neither a voided row nor an outstanding (unpaid) one moves the running // Neither a voided row nor an outstanding (unpaid) one moves the running
// balance — both show tagged, with the balance unchanged from the previous // balance — both show tagged, with the balance unchanged from the previous
// live movement. Outstanding rows start counting once resolved. // live movement. Outstanding rows start counting once resolved.
const next = voided || r.outstanding ? prev : prev.plus(r.amount); const counted = !voided && !r.outstanding;
const next = counted ? prev.plus(r.amount) : prev;
running.set(r.currency, next); running.set(r.currency, next);
if (r.transactionDate < yearStart) {
if (counted) {
opening.set(r.currency, next);
const dk = `${r.domain}|${r.currency}`;
const od = openingByDomain.get(dk) ?? {
domain: r.domain,
currency: r.currency,
amount: new Prisma.Decimal(0),
};
od.amount = od.amount.plus(r.amount);
openingByDomain.set(dk, od);
}
return [];
}
visible.push(r);
return { return {
id: r.id, id: r.id,
transactionDate: r.transactionDate, transactionDate: r.transactionDate,
@@ -818,7 +858,6 @@ export class BillingService {
balanceAfter: next.toFixed(2), balanceAfter: next.toFixed(2),
}; };
}); });
movements.reverse();
// Per-currency summary, and the same split by business line so the two // Per-currency summary, and the same split by business line so the two
// ledgers are visibly one statement without being illegally added up. // ledgers are visibly one statement without being illegally added up.
@@ -846,7 +885,29 @@ export class BillingService {
} }
>(); >();
for (const r of rows) { for (const [currency] of opening) {
perCurrency.set(currency, {
currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
chargeCount: 0,
creditCount: 0,
count: 0,
first: null,
last: null,
});
}
for (const [key, o] of openingByDomain) {
perDomain.set(key, {
domain: o.domain,
currency: o.currency,
charges: new Prisma.Decimal(0),
credits: new Prisma.Decimal(0),
count: 0,
});
}
for (const r of visible) {
// Voided rows never enter a total; outstanding rows don't either until // Voided rows never enter a total; outstanding rows don't either until
// they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`). // they're resolved (legacy SALDOS ULTIMO 0's `HAVING NOPAGO = 0`).
if (r.voidedAt != null || r.outstanding) continue; if (r.voidedAt != null || r.outstanding) continue;
@@ -896,7 +957,7 @@ export class BillingService {
string, string,
{ name: string; currency: string; total: Prisma.Decimal; count: number } { name: string; currency: string; total: Prisma.Decimal; count: number }
>(); >();
for (const r of rows) { for (const r of visible) {
if (r.voidedAt != null || r.outstanding) continue; if (r.voidedAt != null || r.outstanding) continue;
if (!r.amount.lessThan(0)) continue; if (!r.amount.lessThan(0)) continue;
const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar"; const name = r.type?.nameEs || r.type?.nameEn || "Sin clasificar";
@@ -915,25 +976,37 @@ export class BillingService {
propertyCount: customer._count.properties, propertyCount: customer._count.properties,
policyCount: customer._count.policies, policyCount: customer._count.policies,
}, },
summary: [...perCurrency.values()].map((c) => ({ year: yearStart.getUTCFullYear(),
currency: c.currency, summary: [...perCurrency.values()].map((c) => {
charges: c.charges.toFixed(2), const open = opening.get(c.currency) ?? new Prisma.Decimal(0);
credits: c.credits.toFixed(2), return {
balance: c.charges.plus(c.credits).toFixed(2), currency: c.currency,
chargeCount: c.chargeCount, /** Balance carried in from before this year — legacy's BALANCE FORWARD. */
creditCount: c.creditCount, opening: open.toFixed(2),
count: c.count, charges: c.charges.toFixed(2),
firstMovement: c.first, credits: c.credits.toFixed(2),
lastMovement: c.last, balance: open.plus(c.charges).plus(c.credits).toFixed(2),
})), chargeCount: c.chargeCount,
byDomain: [...perDomain.values()].map((d) => ({ creditCount: c.creditCount,
domain: d.domain, count: c.count,
currency: d.currency, firstMovement: c.first,
charges: d.charges.toFixed(2), lastMovement: c.last,
credits: d.credits.toFixed(2), };
balance: d.charges.plus(d.credits).toFixed(2), }),
count: d.count, byDomain: [...perDomain.values()].map((d) => {
})), const open =
openingByDomain.get(`${d.domain}|${d.currency}`)?.amount ??
new Prisma.Decimal(0);
return {
domain: d.domain,
currency: d.currency,
opening: open.toFixed(2),
charges: d.charges.toFixed(2),
credits: d.credits.toFixed(2),
balance: open.plus(d.charges).plus(d.credits).toFixed(2),
count: d.count,
};
}),
byType: [...byType.values()] byType: [...byType.values()]
.map((t) => ({ .map((t) => ({
name: t.name, name: t.name,
+145
View File
@@ -0,0 +1,145 @@
import { Prisma } from "@jorgecuadros/database";
import { BillingService } from "./billing.service";
/**
* The statement is a *year* statement, like the EDO CUENTA report the office
* prints: this year's movements, oldest-first, opening on the balance carried
* in from before it.
*
* The carrying is the part worth testing. Dropping earlier rows from the list
* is easy; dropping them from the arithmetic too would restart every balance at
* zero on January 1st, and nothing would throw — the numbers would just be
* wrong, which is exactly how the double-counting bug lived for years.
*/
describe("statement year scoping", () => {
const YEAR = new Date().getUTCFullYear();
function d(iso: string) {
return new Date(`${iso}T00:00:00.000Z`);
}
type RowSpec = {
id: string;
date: Date;
amount: string;
currency?: string;
domain?: string;
voidedAt?: Date | null;
outstanding?: boolean;
};
function row(r: RowSpec) {
return {
id: r.id,
transactionDate: r.date,
domain: r.domain ?? "UTILITY",
amount: new Prisma.Decimal(r.amount),
currency: r.currency ?? "MXN",
reference: null,
period: null,
checkNumber: null,
message: null,
legacySourceTable: null,
voidedAt: r.voidedAt ?? null,
outstanding: r.outstanding ?? false,
type: { nameEn: "WATER", nameEs: "AGUA" },
};
}
/** No BALANCE FORWARD row, so the floor is null and every row is fetched. */
function serviceWith(rows: RowSpec[]) {
const prisma = {
customer: {
findUnique: jest.fn().mockResolvedValue({
id: "c1",
name: "CUADROS, JORGE H.",
preferredCurrency: "MXN",
_count: { properties: 0, policies: 0 },
}),
},
transaction: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue(rows.map(row)),
},
};
return new BillingService(prisma as never);
}
it("lists the year's movements oldest-first", async () => {
const s = await serviceWith([
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
{ id: "b", date: d(`${YEAR}-03-04`), amount: "250" },
{ id: "c", date: d(`${YEAR}-07-16`), amount: "-40" },
]).statement("c1");
expect(s.movements.map((m) => m.id)).toEqual(["a", "b", "c"]);
});
it("leaves earlier years off the list", async () => {
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-11-30`), amount: "-500" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-100" },
]).statement("c1");
expect(s.movements.map((m) => m.id)).toEqual(["new"]);
});
it("carries the earlier years' balance instead of discarding it", async () => {
// 1,000 credit left over from last year, 300 charged this year: the
// customer is 700 in credit, not 300 in debt.
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
]).statement("c1");
const mxn = s.summary.find((x) => x.currency === "MXN");
expect(mxn?.opening).toBe("1000.00");
expect(mxn?.charges).toBe("-300.00");
expect(mxn?.balance).toBe("700.00");
// The running balance on the listed row picks up where last year left off.
expect(s.movements[0].balanceAfter).toBe("700.00");
});
it("carries it per business line as well", async () => {
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", domain: "INSURANCE" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300", domain: "INSURANCE" },
]).statement("c1");
const line = s.byDomain.find((x) => x.domain === "INSURANCE");
expect(line?.opening).toBe("1000.00");
expect(line?.balance).toBe("700.00");
});
it("still reports a currency that only moved in earlier years", async () => {
// Otherwise a customer sitting on a dollar credit they haven't touched all
// year would appear to have no dollar balance at all.
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 2}-05-01`), amount: "180.83", currency: "USD" },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
]).statement("c1");
const usd = s.summary.find((x) => x.currency === "USD");
expect(usd?.balance).toBe("180.83");
expect(usd?.count).toBe(0);
});
it("does not carry a voided earlier row", async () => {
const s = await serviceWith([
{ id: "old", date: d(`${YEAR - 1}-12-15`), amount: "1000", voidedAt: d(`${YEAR - 1}-12-16`) },
{ id: "new", date: d(`${YEAR}-02-11`), amount: "-300" },
]).statement("c1");
const mxn = s.summary.find((x) => x.currency === "MXN");
expect(mxn?.opening).toBe("0.00");
expect(mxn?.balance).toBe("-300.00");
});
it("reports the year it covers", async () => {
const s = await serviceWith([
{ id: "a", date: d(`${YEAR}-01-02`), amount: "-100" },
]).statement("c1");
expect(s.year).toBe(YEAR);
});
});
+18
View File
@@ -12,6 +12,10 @@ import { Currency } from "@jorgecuadros/database";
// Each child DTO covers create; updates reuse the same shape with all fields // Each child DTO covers create; updates reuse the same shape with all fields
// optional via the corresponding Update class. Route supplies the policyId. // optional via the corresponding Update class. Route supplies the policyId.
// A policy split into several exhibiciones prices each payment on its own —
// the Access form printed the whole money row once per pago — so the premium
// breakdown repeats here. `amount` remains what was actually collected and is
// never recomputed from the breakdown; the two differ by rounding in the books.
export class InstallmentDto { export class InstallmentDto {
@IsInt() sequence!: number; @IsInt() sequence!: number;
@IsOptional() @IsNumber() amount?: number; @IsOptional() @IsNumber() amount?: number;
@@ -20,6 +24,13 @@ export class InstallmentDto {
@IsOptional() @IsString() paidDate?: string; @IsOptional() @IsString() paidDate?: string;
@IsOptional() @IsString() checkNumber?: string; @IsOptional() @IsString() checkNumber?: string;
@IsOptional() @IsBoolean() isCash?: boolean; @IsOptional() @IsBoolean() isCash?: boolean;
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() surcharge?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() taxRate?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsNumber() commission?: number;
} }
export class UpdateInstallmentDto { export class UpdateInstallmentDto {
@IsOptional() @IsInt() sequence?: number; @IsOptional() @IsInt() sequence?: number;
@@ -29,6 +40,13 @@ export class UpdateInstallmentDto {
@IsOptional() @IsString() paidDate?: string; @IsOptional() @IsString() paidDate?: string;
@IsOptional() @IsString() checkNumber?: string; @IsOptional() @IsString() checkNumber?: string;
@IsOptional() @IsBoolean() isCash?: boolean; @IsOptional() @IsBoolean() isCash?: boolean;
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() surcharge?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() taxRate?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsNumber() commission?: number;
} }
export class VehicleDto { export class VehicleDto {
+7 -1
View File
@@ -1,4 +1,4 @@
import { IsOptional, IsString, MinLength } from "class-validator"; import { IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator";
export class ProviderDto { export class ProviderDto {
@IsString() @MinLength(1) name!: string; @IsString() @MinLength(1) name!: string;
@@ -7,13 +7,19 @@ export class UpdateProviderDto {
@IsOptional() @IsString() @MinLength(1) name?: string; @IsOptional() @IsString() @MinLength(1) name?: string;
} }
// `taxRate` is the IVA fraction for this line of business (0.08 = 8%), the
// legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made editable. Bounded at
// 1 because a rate is a fraction, not a percentage: 8 entered here would tax a
// $600 premium $4,800, and the mistake is easy to make.
export class PolicyTypeDto { export class PolicyTypeDto {
@IsString() @MinLength(1) name!: string; @IsString() @MinLength(1) name!: string;
@IsOptional() @IsString() shortDescription?: string; @IsOptional() @IsString() shortDescription?: string;
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
} }
export class UpdatePolicyTypeDto { export class UpdatePolicyTypeDto {
@IsOptional() @IsString() @MinLength(1) name?: string; @IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() shortDescription?: string; @IsOptional() @IsString() shortDescription?: string;
@IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
} }
export class AdjusterDto { export class AdjusterDto {
+31 -2
View File
@@ -250,7 +250,16 @@ export class PoliciesService {
const [types, providers] = await this.prisma.$transaction([ const [types, providers] = await this.prisma.$transaction([
this.prisma.policyType.findMany({ this.prisma.policyType.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } }, select: {
id: true,
name: true,
shortDescription: true,
// The capture form computes IVA client-side as the operator types,
// so the rate has to travel with the type list it already loads —
// an extra round-trip per keystroke is not an option.
taxRate: true,
_count: { select: { policies: true } },
},
}), }),
this.prisma.insuranceProvider.findMany({ this.prisma.insuranceProvider.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
@@ -259,7 +268,13 @@ export class PoliciesService {
]); ]);
return { return {
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })), types: types.map((t) => ({
id: t.id,
name: t.name,
shortDescription: t.shortDescription,
taxRate: t.taxRate,
count: t._count.policies,
})),
providers: providers.map((p) => ({ providers: providers.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
@@ -397,6 +412,13 @@ export class PoliciesService {
paidDate: toDate(dto.paidDate) ?? undefined, paidDate: toDate(dto.paidDate) ?? undefined,
checkNumber: dto.checkNumber, checkNumber: dto.checkNumber,
isCash: dto.isCash, isCash: dto.isCash,
netPremium: dto.netPremium,
surcharge: dto.surcharge,
policyFee: dto.policyFee,
tax: dto.tax,
taxRate: dto.taxRate,
total: dto.total,
commission: dto.commission,
}, },
}); });
} }
@@ -412,6 +434,13 @@ export class PoliciesService {
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }), ...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
checkNumber: dto.checkNumber, checkNumber: dto.checkNumber,
isCash: dto.isCash, isCash: dto.isCash,
netPremium: dto.netPremium,
surcharge: dto.surcharge,
policyFee: dto.policyFee,
tax: dto.tax,
taxRate: dto.taxRate,
total: dto.total,
commission: dto.commission,
}, },
}); });
} }
+14 -2
View File
@@ -6,12 +6,16 @@ import {
IsString, IsString,
MinLength, MinLength,
} from "class-validator"; } from "class-validator";
import { Currency } from "@jorgecuadros/database"; import { Currency, PaymentFrequency } from "@jorgecuadros/database";
import { IsEnum } from "class-validator"; import { IsEnum } from "class-validator";
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not /** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
* exposed for editing. Dates arrive as ISO strings and are coerced by the * exposed for editing. Dates arrive as ISO strings and are coerced by the
* service. `total` is legacy-dead data — the UI uses netPremium. */ * service. `total` is legacy-dead data on migrated rows — list and sort code
* still uses netPremium — but the capture form writes it going forward, along
* with `tax`, from the arithmetic in premium.ts. Both arrive as plain numbers
* rather than being recomputed server-side: the printed policy is the record
* of truth and staff must be able to key its rounding verbatim. */
export class CreatePolicyDto { export class CreatePolicyDto {
@IsString() @MinLength(1) policyNumber!: string; @IsString() @MinLength(1) policyNumber!: string;
@IsString() @MinLength(1) customerId!: string; @IsString() @MinLength(1) customerId!: string;
@@ -24,10 +28,14 @@ export class CreatePolicyDto {
@IsOptional() @IsString() policyTo?: string; @IsOptional() @IsString() policyTo?: string;
@IsOptional() @IsInt() coveragePeriodDays?: number; @IsOptional() @IsInt() coveragePeriodDays?: number;
@IsOptional() @IsNumber() netPremium?: number; @IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() surcharge?: number;
@IsOptional() @IsNumber() policyFee?: number; @IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number; @IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() commission?: number; @IsOptional() @IsNumber() commission?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() taxRate?: number;
@IsOptional() @IsNumber() total?: number; @IsOptional() @IsNumber() total?: number;
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
@IsOptional() @IsEnum(Currency) currency?: Currency; @IsOptional() @IsEnum(Currency) currency?: Currency;
@IsOptional() @IsString() observations?: string; @IsOptional() @IsString() observations?: string;
@IsOptional() @IsString() notes?: string; @IsOptional() @IsString() notes?: string;
@@ -48,10 +56,14 @@ export class UpdatePolicyDto {
@IsOptional() @IsString() policyTo?: string; @IsOptional() @IsString() policyTo?: string;
@IsOptional() @IsInt() coveragePeriodDays?: number; @IsOptional() @IsInt() coveragePeriodDays?: number;
@IsOptional() @IsNumber() netPremium?: number; @IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() surcharge?: number;
@IsOptional() @IsNumber() policyFee?: number; @IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number; @IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() commission?: number; @IsOptional() @IsNumber() commission?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() taxRate?: number;
@IsOptional() @IsNumber() total?: number; @IsOptional() @IsNumber() total?: number;
@IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
@IsOptional() @IsEnum(Currency) currency?: Currency; @IsOptional() @IsEnum(Currency) currency?: Currency;
@IsOptional() @IsString() observations?: string; @IsOptional() @IsString() observations?: string;
@IsOptional() @IsString() notes?: string; @IsOptional() @IsString() notes?: string;
+90
View File
@@ -0,0 +1,90 @@
import {
DEFAULT_TAX_RATE,
computeTax,
computeTotal,
resolveTaxRate,
surchargeApplies,
taxableBase,
} from "./premium";
/**
* The reference case is policy 7006785 (MULT, semestral, GMX, two payments) as
* it stands in the Access books — the screen Jorge sent. Both of its money
* rows are asserted, because the second one is the case that proves the
* surcharge belongs in the taxable base and that a zero policy fee is a real
* value rather than a missing one.
*/
describe("premium arithmetic", () => {
it("matches the first payment of policy 7006785", () => {
const parts = { netPremium: 610.86, surcharge: 8.55, policyFee: 31.0 };
expect(taxableBase(parts)).toBe(650.41);
expect(computeTax(parts, 0.08)).toBe(52.03);
expect(computeTotal(parts, 0.08)).toBe(702.44);
});
it("matches the second payment of policy 7006785", () => {
const parts = { netPremium: 589.71, surcharge: 8.26, policyFee: 0 };
expect(computeTax(parts, 0.08)).toBe(47.84);
expect(computeTotal(parts, 0.08)).toBe(645.81);
});
it("excluding the surcharge does NOT reconcile", () => {
// Guards the one decision in this module that is easy to get wrong: the
// spoken-language version of the rule ("prima neta + derecho * 8%") gives
// 51.35, and the printed policy says 52.03.
const withoutSurcharge = { netPremium: 610.86, surcharge: 0, policyFee: 31.0 };
expect(computeTax(withoutSurcharge, 0.08)).not.toBe(52.03);
});
it("treats blank and null money as zero, not NaN", () => {
expect(taxableBase({ netPremium: "610.86", surcharge: null, policyFee: "" })).toBe(
610.86,
);
expect(computeTax({ netPremium: undefined, surcharge: null, policyFee: null }, 0.08))
.toBe(0);
});
it("rounds half-up to cents", () => {
// 100.06 * 0.08 = 8.0048 -> 8.00; 100.13 * 0.08 = 8.0104 -> 8.01.
expect(computeTax({ netPremium: 100.06, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8);
expect(computeTax({ netPremium: 100.13, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8.01);
});
describe("surchargeApplies", () => {
it("is false for the two single-payment frequencies", () => {
expect(surchargeApplies("ANNUAL")).toBe(false);
expect(surchargeApplies("SINGLE")).toBe(false);
});
it("is true for every split frequency", () => {
expect(surchargeApplies("SEMIANNUAL")).toBe(true);
expect(surchargeApplies("QUARTERLY")).toBe(true);
expect(surchargeApplies("MONTHLY")).toBe(true);
});
it("allows it when the frequency is unknown", () => {
// Every migrated policy is null here — the original ETL dropped FORMA
// PAGO — and those rows DO carry recargo figures in the legacy data.
expect(surchargeApplies(null)).toBe(true);
expect(surchargeApplies(undefined)).toBe(true);
});
});
describe("resolveTaxRate", () => {
it("prefers the rate the policy was issued at", () => {
expect(resolveTaxRate(0.16, 0.08)).toBe(0.16);
});
it("falls back to the line of business", () => {
expect(resolveTaxRate(null, 0.08)).toBe(0.08);
});
it("falls back to the default when nothing is configured", () => {
expect(resolveTaxRate(null, null)).toBe(DEFAULT_TAX_RATE);
expect(resolveTaxRate(undefined, "")).toBe(DEFAULT_TAX_RATE);
});
it("accepts a zero rate as a real choice, not as absent", () => {
// An exempt line of business must read 0, not silently fall through to 8%.
expect(resolveTaxRate(null, 0)).toBe(0);
});
it("accepts Prisma's decimal strings", () => {
expect(resolveTaxRate(null, "0.0800")).toBe(0.08);
});
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* The premium arithmetic the Access capture form did in unbound calculated
* controls, moved somewhere it can be tested.
*
* Two figures are derived, everything else is keyed by hand:
*
* base = netPremium + surcharge + policyFee
* tax = round(base * rate)
* total = base + tax
*
* The surcharge IS part of the taxable base. That is not an assumption — it is
* the only reading that reconciles the books. Policy 7006785 (MULT, semestral,
* two payments) prints IVA 52.03 and 47.84 against net premiums 610.86 / 589.71,
* surcharges 8.55 / 8.26 and policy fees 31.00 / 0.00; excluding the surcharge
* gives 51.35, which matches nothing on the page.
*
* The surcharge itself is NEVER derived. It is the carrier's financing charge
* for paying in installments, quoted per policy, so staff key it in. It only
* ever appears on a policy that is not paid annually or in a single exhibición
* — `surchargeApplies` is what the UI uses to grey the field out.
*/
/** Used when neither the policy nor its type carries a rate. Matches the
* single row both legacy IMPUESTOS tables held (0.08 = 8%). */
export const DEFAULT_TAX_RATE = 0.08;
export type PaymentFrequencyValue =
| "ANNUAL"
| "SEMIANNUAL"
| "QUARTERLY"
| "MONTHLY"
| "SINGLE";
/** Paying in more than one exhibición is what earns a surcharge. A null
* frequency (every migrated row — Access's FORMA PAGO was dropped by the
* original ETL) is treated as "unknown, allow it" rather than "annual":
* refusing to show a figure that is sitting in the legacy data would hide it. */
export function surchargeApplies(
frequency: PaymentFrequencyValue | null | undefined,
): boolean {
return frequency !== "ANNUAL" && frequency !== "SINGLE";
}
function num(v: unknown): number {
if (v === null || v === undefined || v === "") return 0;
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
/** Half-up to cents, the way the printed policy rounds. */
export function round2(n: number): number {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
export interface PremiumParts {
netPremium?: unknown;
surcharge?: unknown;
policyFee?: unknown;
}
export function taxableBase(p: PremiumParts): number {
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
}
export function computeTax(p: PremiumParts, rate: number): number {
return round2(taxableBase(p) * rate);
}
export function computeTotal(p: PremiumParts, rate: number): number {
return round2(taxableBase(p) + computeTax(p, rate));
}
/** Rate ladder: the figure stored on the policy (so an old policy keeps the
* rate it was issued at even after the catalog changes), else the rate on its
* line of business, else the shipped default. */
export function resolveTaxRate(
policyRate: unknown,
policyTypeRate: unknown,
): number {
for (const candidate of [policyRate, policyTypeRate]) {
if (candidate === null || candidate === undefined || candidate === "") continue;
const n = Number(candidate);
if (Number.isFinite(n) && n >= 0) return n;
}
return DEFAULT_TAX_RATE;
}
@@ -0,0 +1,161 @@
import {
nameTokens,
suggestCustomersByName,
suggestionNote,
type CustomerNameRow,
} from "./name-matcher";
/**
* Every row here is a real name out of the customer book (1536 rows, dev
* mirror of production), chosen because it is one of the shapes that breaks
* naive matching: surname-first ordering, a middle initial, a Spanish double
* surname, a joint account, a missing comma, and the `(SIN NOMBRE)`
* placeholder the migration left for customers whose DATGRAL row had no name.
*/
const BOOK: CustomerNameRow[] = [
{ id: "c1", name: "WAGONER, PAMELA" },
{ id: "c2", name: "MCWILLIAMS, BRIAN MICHAEL" },
{ id: "c3", name: "MCWILLIAMS, BRIAN" },
{ id: "c4", name: "WEAKLAND, RICHARD E." },
{ id: "c5", name: "ESTRADA, JERRY & MARILYN" },
{ id: "c6", name: "CABALLERO PRIETO, GUILLERMO" },
{ id: "c7", name: "GREENE STEPHANIE" },
{ id: "c8", name: "(SIN NOMBRE)" },
{ id: "c9", name: "MUÑOZ, LUIS ALBERTO" },
{ id: "c10", name: "SMITH, DANIEL" },
{ id: "c11", name: "SMITH, JOHN" },
];
describe("nameTokens", () => {
it("makes the two orderings the same set", () => {
expect(nameTokens("PAMELA WAGONER").sort()).toEqual(
nameTokens("WAGONER, PAMELA").sort(),
);
});
it("drops initials, particles and corporate suffixes", () => {
expect(nameTokens("WEAKLAND, RICHARD E.")).toEqual(["WEAKLAND", "RICHARD"]);
expect(nameTokens("GARCIA DE LA TORRE, ANA")).toEqual(["GARCIA", "TORRE", "ANA"]);
expect(nameTokens("CONSTRUCTORA BAJA S.A. DE C.V.")).toEqual([
"CONSTRUCTORA",
"BAJA",
]);
});
it("folds accents so OCR's MUNOZ reaches the book's MUÑOZ", () => {
expect(nameTokens("MUÑOZ")).toEqual(["MUNOZ"]);
});
it("drops the phone number ANA prints against the insured name", () => {
// Observed verbatim from the ANA automobile face.
expect(nameTokens("MARIA GARCIA Ph.3102001538")).toEqual([
"MARIA",
"GARCIA",
"PH",
]);
});
});
describe("suggestCustomersByName", () => {
it("matches the reversed name exactly", () => {
const [top] = suggestCustomersByName("PAMELA WAGONER", BOOK);
expect(top).toMatchObject({ customerId: "c1", tier: "EXACT", score: 1 });
});
it("treats a printed middle name the book lacks as a partial hit", () => {
const hits = suggestCustomersByName("PAMELA DENISE WAGONER", BOOK);
expect(hits[0]).toMatchObject({ customerId: "c1", tier: "PARTIAL" });
expect(hits[0].score).toBeCloseTo(2 / 3);
});
it("ranks the exact row above the row that merely contains it", () => {
// Both MCWILLIAMS rows are reachable from this name; the one that holds
// the middle name is the exact set and must come first.
const hits = suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK);
expect(hits.map((h) => h.customerId)).toEqual(["c2", "c3"]);
expect(hits[0].tier).toBe("EXACT");
expect(hits[1].tier).toBe("PARTIAL");
});
it("reaches a joint account from the one spouse the carrier printed", () => {
const hits = suggestCustomersByName("JERRY ESTRADA", BOOK);
expect(hits[0]).toMatchObject({ customerId: "c5", tier: "PARTIAL" });
});
it("will not reach a joint account on given names alone", () => {
// No surname printed: `JERRY MARILYN` overlaps ESTRADA, JERRY & MARILYN
// on two tokens, and matching on that would book a stranger's policy.
expect(suggestCustomersByName("JERRY MARILYN", BOOK)).toEqual([]);
});
it("matches a Spanish double surname regardless of where the comma fell", () => {
const [top] = suggestCustomersByName("GUILLERMO CABALLERO PRIETO", BOOK);
expect(top).toMatchObject({ customerId: "c6", tier: "EXACT" });
});
it("still matches a book row that has no comma", () => {
const [top] = suggestCustomersByName("STEPHANIE GREENE", BOOK);
expect(top).toMatchObject({ customerId: "c7", tier: "EXACT" });
});
it("never suggests the (SIN NOMBRE) placeholder", () => {
expect(suggestCustomersByName("SIN NOMBRE", BOOK)).toEqual([]);
expect(suggestCustomersByName("NOMBRE DEL ASEGURADO", BOOK)).toEqual([]);
});
it("returns nothing on a shared surname alone", () => {
// 185 surnames are shared by 524 customers; one token is not evidence.
expect(suggestCustomersByName("SMITH", BOOK)).toEqual([]);
});
it("returns nothing for a different person with the same surname", () => {
expect(suggestCustomersByName("ROBERT SMITH", BOOK)).toEqual([]);
});
it("refuses a page-sized blob", () => {
// GMX's especificación has no field labels and the parser has handed its
// whole first page over as the insured name.
const blob =
"ESPECIFICACION DE LA POLIZA DE SEGURO DE RESPONSABILIDAD CIVIL " +
"EXPEDIDA A FAVOR DE PAMELA WAGONER CON VIGENCIA DEL 01 DE ENERO";
expect(suggestCustomersByName(blob, BOOK)).toEqual([]);
});
it("caps the list", () => {
expect(suggestCustomersByName("BRIAN MICHAEL MCWILLIAMS", BOOK, 1)).toHaveLength(1);
});
it("handles a null insured name", () => {
expect(suggestCustomersByName(null, BOOK)).toEqual([]);
});
});
describe("suggestionNote", () => {
it("says nothing when there is nothing", () => {
expect(suggestionNote([])).toBeNull();
});
it("names a single exact hit", () => {
expect(suggestionNote(suggestCustomersByName("PAMELA WAGONER", BOOK))).toBe(
"posible cliente por nombre: WAGONER, PAMELA",
);
});
it("reports a tie rather than picking one", () => {
// The book really does hold EMERY, LAURA twice and KIRCHHOFF, CINDY
// three times.
const dupes: CustomerNameRow[] = [
{ id: "d1", name: "EMERY, LAURA" },
{ id: "d2", name: "EMERY, LAURA" },
];
expect(suggestionNote(suggestCustomersByName("LAURA EMERY", dupes))).toBe(
"2 clientes tienen ese mismo nombre; elija cuál",
);
});
it("lists partial hits", () => {
expect(suggestionNote(suggestCustomersByName("PAMELA DENISE WAGONER", BOOK))).toBe(
"posibles clientes por nombre: WAGONER, PAMELA",
);
});
});
+199
View File
@@ -0,0 +1,199 @@
/**
* Suggests which existing customer a printed insured name belongs to.
*
* The office books customers surname-first ("WAGONER, PAMELA") and carriers
* print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare
* never hits. Comparing *token sets* does, and it is order-insensitive by
* construction — which is the whole trick.
*
* **These are suggestions, never matches.** Nothing here sets
* `matchedCustomerId` or `confident`; the review screen offers the ranked
* names and a human picks. That line is not caution, it is what the book
* measures out to: of 1536 customers, 1487 have a distinct normalized token
* set — but loosen the rule to surname + first given name only and 131 of
* them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL`
* *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three
* `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a
* surname alone carries no information at all.
*
* The two tiers below are drawn at the two places that measurement puts a
* cliff: full token-set equality, where cross-person collisions are
* effectively zero, and strict containment, where they are common enough
* that the result can only ever be a hint.
*/
/** A customer row as the matcher needs it — id and the book's name. */
export interface CustomerNameRow {
id: string;
name: string;
}
export type NameMatchTier = "EXACT" | "PARTIAL";
export interface CustomerNameSuggestion {
customerId: string;
customerName: string;
/**
* `EXACT` — the two names carry the same tokens, in any order.
* `PARTIAL` — one name's tokens are all present in the other's, plus the
* surname. A printed middle name the book does not hold, or a joint
* account where the carrier named one spouse, both land here.
*/
tier: NameMatchTier;
/** Shared tokens over the longer name's token count, 0..1. */
score: number;
}
/**
* Words that carry no identity. Spanish particles and the ampersand joining
* a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.`
* does not make every company look alike.
*/
const NOISE = new Set([
"DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA",
"JR", "SR", "II", "III", "IV",
"SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO",
]);
/**
* Placeholder rows the migration left behind. Fourteen customers are named
* literally `(SIN NOMBRE)`; without this they would be one 14-way tie on
* every unreadable name.
*/
const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]);
/**
* A name blob longer than this is not a name. GMX's PVL especificación has
* no field labels, and the parser has been seen handing its entire first
* page over as `insuredName`; matching that against the book would find
* a surname somewhere in the prose and suggest a stranger.
*/
const MAX_TOKENS = 8;
const MAX_CHARS = 80;
/**
* Splits a name into comparable tokens.
*
* Accents go first, and deliberately in both directions: the book holds
* `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same
* ASCII makes that a hit rather than a miss.
*
* Tokens containing digits are dropped outright. ANA's automobile face
* prints the phone number hard against the insured name — the parser has
* emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise
* be an extra token forever blocking `EXACT`.
*
* Single letters are dropped as initials: the book is full of
* `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in
* full should still match the row that abbreviates it.
*/
export function nameTokens(raw: string): string[] {
const cleaned = raw
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, " ")
.trim();
const tokens = cleaned
.split(" ")
.filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t));
return [...new Set(tokens)];
}
/** The surname tokens — everything before the comma the book writes. */
function surnameTokens(bookName: string): string[] {
const comma = bookName.indexOf(",");
// 54 of 1536 rows have no comma at all ("GREENE STEPHANIE",
// "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring
// a surname we cannot identify would silently exclude those rows, so they
// fall back to requiring nothing beyond the containment rule.
if (comma < 0) return [];
return nameTokens(bookName.slice(0, comma));
}
function isPlaceholder(tokens: string[]): boolean {
return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" "));
}
function containsAll(haystack: Set<string>, needles: string[]): boolean {
return needles.every((n) => haystack.has(n));
}
/**
* Ranks the book against one printed name.
*
* Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher
* score first. An empty array means the printed name was unusable (too
* long, too few real tokens) or nothing in the book came close — both of
* which leave the review screen exactly as it is today.
*/
export function suggestCustomersByName(
printedName: string | null | undefined,
customers: CustomerNameRow[],
limit = 3,
): CustomerNameSuggestion[] {
if (!printedName || printedName.length > MAX_CHARS) return [];
const printed = nameTokens(printedName);
// One usable token is a surname or a given name on its own, and 34% of the
// book shares a surname with someone. Nothing useful can come of it.
if (printed.length < 2 || printed.length > MAX_TOKENS) return [];
const printedSet = new Set(printed);
const out: CustomerNameSuggestion[] = [];
for (const c of customers) {
const book = nameTokens(c.name);
if (isPlaceholder(book) || book.length < 2) continue;
const bookSet = new Set(book);
const overlap = printed.filter((t) => bookSet.has(t)).length;
// Two shared tokens is the floor: one is a bare surname collision.
if (overlap < 2) continue;
const bookInPrinted = containsAll(printedSet, book);
const printedInBook = containsAll(bookSet, printed);
if (!bookInPrinted && !printedInBook) continue;
// When the book's name is the shorter one, containment already proves
// the surname was printed. When the printed name is shorter — the book
// holds a middle name or a second spouse the carrier omitted — the
// surname must be there explicitly, or `JERRY MARILYN` would match
// `ESTRADA, JERRY & MARILYN` on given names alone.
if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue;
out.push({
customerId: c.id,
customerName: c.name,
tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL",
score: overlap / Math.max(book.length, printed.length),
});
}
out.sort((a, b) => {
if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1;
if (b.score !== a.score) return b.score - a.score;
return a.customerName.localeCompare(b.customerName);
});
return out.slice(0, limit);
}
/** Review-queue wording for what the suggestions amount to. */
export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null {
if (suggestions.length === 0) return null;
const exact = suggestions.filter((s) => s.tier === "EXACT");
// More than one exact hit is the duplicate-customer case the book really
// has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is
// more useful than naming whichever one sorted first.
if (exact.length > 1) {
return `${exact.length} clientes tienen ese mismo nombre; elija cuál`;
}
if (exact.length === 1) {
return `posible cliente por nombre: ${exact[0].customerName}`;
}
return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`;
}
@@ -689,8 +689,23 @@ describe("parsePolicy / ANA automobile", () => {
// reading would shift every value one column left. // reading would shift every value one column left.
expect(p.netPremium).toBe(298.61); expect(p.netPremium).toBe(298.61);
expect(p.policyFee).toBe(30); expect(p.policyFee).toBe(30);
expect(p.tax).toBe(26.29);
expect(p.total).toBe(354.9); expect(p.total).toBe(354.9);
expect(p.notes.join(" | ")).toMatch(/impuesto: 26\.29/); });
it("reads a TAX that reconciles against the rest of the row", () => {
// 298.61 + 30.00 = 328.61, taxed at 8% -> 26.29, totalling 354.90. The
// whole row agreeing is what proves the positional mapping landed on the
// right cells rather than merely on six numbers.
const base = p.netPremium! + p.policyFee!;
expect(Math.round(base * 0.08 * 100) / 100).toBe(p.tax);
expect(Math.round((base + p.tax!) * 100) / 100).toBe(p.total);
});
it("does not fold LOCAL TAX into the IVA", () => {
// It prints 0.00 here, so nothing to fold — but the guard is that a
// non-zero one would surface as a note instead of inflating `tax`.
expect(p.notes.join(" | ")).not.toMatch(/impuesto local/);
}); });
it("reads the vehicle by token role, not by column", () => { it("reads the vehicle by token role, not by column", () => {
@@ -36,6 +36,17 @@ export interface ParsedPolicy {
netPremium: number | null; netPremium: number | null;
policyFee: number | null; policyFee: number | null;
brokerFee: number | null; brokerFee: number | null;
/**
* IVA, off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
* premium at all, so there is no tax on it to read either.
*
* The adjacent `LOCAL TAX` cell is deliberately NOT folded in here. It is a
* separate levy with no column of its own, and summing the two would report
* an IVA figure that no longer divides back to a rate — the whole point of
* storing it. It prints 0.00 on every policy seen so far and is surfaced as
* a note when it is not.
*/
tax: number | null;
total: number | null; total: number | null;
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */ /** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
premiumPayment: string | null; premiumPayment: string | null;
@@ -319,6 +330,7 @@ function emptyParsedPolicy(provider: string): ParsedPolicy {
netPremium: null, netPremium: null,
policyFee: null, policyFee: null,
brokerFee: null, brokerFee: null,
tax: null,
total: null, total: null,
premiumPayment: null, premiumPayment: null,
coverages: [], coverages: [],
@@ -1178,6 +1190,7 @@ interface AnaHeader {
coveragePeriodDays: number | null; coveragePeriodDays: number | null;
netPremium: number | null; netPremium: number | null;
policyFee: number | null; policyFee: number | null;
tax: number | null;
total: number | null; total: number | null;
} }
@@ -1253,8 +1266,13 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
// ----- money row --------------------------------------------------------- // ----- money row ---------------------------------------------------------
const row = anaMoneyRow(lines); const row = anaMoneyRow(lines);
if (!row) notes.push("no se pudo leer el renglón de primas"); if (!row) notes.push("no se pudo leer el renglón de primas");
if (row?.tax) notes.push(`impuesto: ${row.tax.toFixed(2)}`);
if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`); if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`);
// LOCAL TAX has no destination column and prints 0.00 on every A.N.A. policy
// seen so far. A non-zero one means the total will not reconcile against the
// stored IVA, so say so rather than folding it in and hiding the difference.
if (row?.localTax) {
notes.push(`impuesto local ${row.localTax.toFixed(2)} no capturado`);
}
return { return {
policyNumber, policyNumber,
@@ -1266,6 +1284,7 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
coveragePeriodDays, coveragePeriodDays,
netPremium: row?.netPremium ?? null, netPremium: row?.netPremium ?? null,
policyFee: row?.policyFee ?? null, policyFee: row?.policyFee ?? null,
tax: row?.tax ?? null,
total: row?.total ?? null, total: row?.total ?? null,
}; };
} }
@@ -1455,6 +1474,7 @@ function parseAnaAutomobile(lines: string[]): ParsedPolicy {
currency: anaCurrency(text, notes), currency: anaCurrency(text, notes),
netPremium: header.netPremium, netPremium: header.netPremium,
policyFee: header.policyFee, policyFee: header.policyFee,
tax: header.tax,
total: header.total, total: header.total,
premiumPayment: paymentDeadline, premiumPayment: paymentDeadline,
coverages, coverages,
@@ -1854,6 +1874,7 @@ function parseAnaDriverPolicy(lines: string[]): ParsedPolicy {
currency: anaCurrency(text, notes), currency: anaCurrency(text, notes),
netPremium: header.netPremium, netPremium: header.netPremium,
policyFee: header.policyFee, policyFee: header.policyFee,
tax: header.tax,
total: header.total, total: header.total,
coverages, coverages,
coveragePeriodDays: header.coveragePeriodDays, coveragePeriodDays: header.coveragePeriodDays,
@@ -0,0 +1,100 @@
import { PolicyMatcherService } from "./policy-matcher.service";
import type { PrismaService } from "../prisma/prisma.service";
import type { ParsedPolicy } from "./parsers/policy-parser";
function parsed(over: Partial<ParsedPolicy> = {}): ParsedPolicy {
return {
provider: "GMX",
policyNumber: null,
insuredName: null,
notes: [],
coverages: [],
vehicles: [],
drivers: [],
...over,
} as unknown as ParsedPolicy;
}
function prismaStub(policies: unknown[], customers: { id: string; name: string }[]) {
const findManyPolicy = jest.fn().mockResolvedValue(policies);
const findManyCustomer = jest.fn().mockResolvedValue(customers);
return {
prisma: {
policy: { findMany: findManyPolicy },
customer: { findMany: findManyCustomer },
} as unknown as PrismaService,
findManyPolicy,
findManyCustomer,
};
}
const BOOK = [
{ id: "cust-1", name: "WAGONER, PAMELA" },
{ id: "cust-2", name: "SMITH, JOHN" },
];
describe("PolicyMatcherService name suggestions", () => {
it("suggests a customer when the policy number is new", async () => {
const { prisma } = prismaStub([], BOOK);
const svc = new PolicyMatcherService(prisma);
const r = await svc.match(
parsed({ policyNumber: "P-999", insuredName: "PAMELA DENISE WAGONER" } as never),
);
expect(r.customerSuggestions).toEqual([
expect.objectContaining({ customerId: "cust-1", tier: "PARTIAL" }),
]);
// The suggestion is surfaced, never applied.
expect(r.customerId).toBeNull();
expect(r.confident).toBe(false);
expect(r.note).toContain("posibles clientes por nombre: WAGONER, PAMELA");
});
it("suggests when the policy number could not be read at all", async () => {
const { prisma } = prismaStub([], BOOK);
const svc = new PolicyMatcherService(prisma);
const r = await svc.match(parsed({ insuredName: "PAMELA WAGONER" } as never));
expect(r.customerSuggestions[0]).toMatchObject({ customerId: "cust-1", tier: "EXACT" });
expect(r.customerId).toBeNull();
expect(r.note).toBe(
"no se pudo leer el número de póliza; posible cliente por nombre: WAGONER, PAMELA",
);
});
it("does not touch the book when the policy number hits", async () => {
const { prisma, findManyCustomer } = prismaStub(
[
{
id: "pol-1",
policyNumber: "P-1",
customerId: "cust-2",
customer: { name: "SMITH, JOHN" },
},
],
BOOK,
);
const svc = new PolicyMatcherService(prisma);
const r = await svc.match(
parsed({ policyNumber: "P-1", insuredName: "PAMELA WAGONER" } as never),
);
expect(r.confident).toBe(true);
expect(r.customerId).toBe("cust-2");
expect(r.customerSuggestions).toEqual([]);
expect(findManyCustomer).not.toHaveBeenCalled();
});
it("reads the customer book once across a batch", async () => {
const { prisma, findManyCustomer } = prismaStub([], BOOK);
const svc = new PolicyMatcherService(prisma);
await svc.match(parsed({ policyNumber: "A", insuredName: "PAMELA WAGONER" } as never));
await svc.match(parsed({ policyNumber: "B", insuredName: "JOHN SMITH" } as never));
expect(findManyCustomer).toHaveBeenCalledTimes(1);
});
});
@@ -1,6 +1,12 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import type { ParsedPolicy } from "./parsers/policy-parser"; import type { ParsedPolicy } from "./parsers/policy-parser";
import {
suggestCustomersByName,
suggestionNote,
type CustomerNameRow,
type CustomerNameSuggestion,
} from "./name-matcher";
export interface MatchResult { export interface MatchResult {
policyId: string | null; policyId: string | null;
@@ -14,8 +20,24 @@ export interface MatchResult {
* the policy number is shared across customers and a human must pick. * the policy number is shared across customers and a human must pick.
*/ */
candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[]; candidates: { policyId: string; customerId: string; customerName: string; policyNumber: string }[];
/**
* Customers whose name resembles the printed insured name. Populated only
* when the policy number resolved to nothing, and never used to set
* `customerId` or `confident` — see the class comment.
*/
customerSuggestions: CustomerNameSuggestion[];
} }
/**
* How long the customer book is reused across documents in a batch.
*
* A twenty-page batch would otherwise read all 1536 rows twenty times. The
* only cost of the staleness is that a customer created in the last minute
* is not suggested — the picker still finds them, so nothing is lost that a
* reviewer cannot do in one click.
*/
const BOOK_TTL_MS = 60_000;
/** /**
* Resolves a parsed policy page to an existing Policy (and its customer) the * Resolves a parsed policy page to an existing Policy (and its customer) the
* office already holds. * office already holds.
@@ -33,14 +55,29 @@ export interface MatchResult {
* policy numbers across customers do occur (same group policy bound by two * policy numbers across customers do occur (same group policy bound by two
* related parties), and picking one arbitrarily would silently book the * related parties), and picking one arbitrarily would silently book the
* wrong coverage. * wrong coverage.
*
* On that zero-hit path only, the printed name is used to *rank the picker*
* — see `name-matcher.ts`. That is not a walk-back of the rule above: the
* suggestion never reaches `customerId` or `confident`, a human still picks,
* and the ranking exists because the office writes names surname-first
* ("WAGONER, PAMELA") while carriers print them given-name-first ("PAMELA
* DENISE WAGONER"), so the reviewer is retyping a name the machine could
* have offered.
*/ */
@Injectable() @Injectable()
export class PolicyMatcherService { export class PolicyMatcherService {
private book: { rows: CustomerNameRow[]; loadedAt: number } | null = null;
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async match(parsed: ParsedPolicy): Promise<MatchResult> { async match(parsed: ParsedPolicy): Promise<MatchResult> {
if (!parsed.policyNumber) { if (!parsed.policyNumber) {
return this.unmatched("no se pudo leer el número de póliza"); // No number to search on, so the page goes to review with a picker —
// the same place the name suggestions help.
return this.unmatched(
"no se pudo leer el número de póliza",
await this.suggestByName(parsed.insuredName),
);
} }
const rows = await this.prisma.policy.findMany({ const rows = await this.prisma.policy.findMany({
@@ -61,22 +98,33 @@ export class PolicyMatcherService {
})); }));
if (rows.length === 0) { if (rows.length === 0) {
const suggestions = await this.suggestByName(parsed.insuredName);
const hint = suggestionNote(suggestions);
return { return {
policyId: null, policyId: null,
customerId: null, customerId: null,
note: `no se encontró ninguna póliza con el número ${parsed.policyNumber}`, note: [
`no se encontró ninguna póliza con el número ${parsed.policyNumber}`,
hint,
]
.filter(Boolean)
.join("; "),
confident: false, confident: false,
candidates: [], candidates: [],
customerSuggestions: suggestions,
}; };
} }
if (rows.length > 1) { if (rows.length > 1) {
// The policy number did find rows; the reviewer picks among those, and
// adding name guesses on top would only add noise.
return { return {
policyId: null, policyId: null,
customerId: null, customerId: null,
note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`, note: `${rows.length} pólizas comparten el número ${parsed.policyNumber}`,
confident: false, confident: false,
candidates, candidates,
customerSuggestions: [],
}; };
} }
@@ -86,16 +134,47 @@ export class PolicyMatcherService {
note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`, note: `coincidencia exacta por número de póliza ${parsed.policyNumber}`,
confident: true, confident: true,
candidates, candidates,
customerSuggestions: [],
}; };
} }
private unmatched(note: string): MatchResult { private async suggestByName(
insuredName: string | null | undefined,
): Promise<CustomerNameSuggestion[]> {
if (!insuredName) return [];
return suggestCustomersByName(insuredName, await this.customerBook());
}
/**
* The whole customer book, held briefly. 1536 rows of `{id, name}` is a
* few hundred kilobytes and the comparison is pure token-set work, so
* scanning it beats any SQL approximation — and a `LIKE` search would in
* any case have to guess which token is the surname, which is the one
* thing the office's own data does not agree on.
*/
private async customerBook(): Promise<CustomerNameRow[]> {
if (this.book && Date.now() - this.book.loadedAt < BOOK_TTL_MS) {
return this.book.rows;
}
const rows = await this.prisma.customer.findMany({
select: { id: true, name: true },
});
this.book = { rows, loadedAt: Date.now() };
return rows;
}
private unmatched(
note: string,
customerSuggestions: CustomerNameSuggestion[] = [],
): MatchResult {
const hint = suggestionNote(customerSuggestions);
return { return {
policyId: null, policyId: null,
customerId: null, customerId: null,
note, note: [note, hint].filter(Boolean).join("; "),
confident: false, confident: false,
candidates: [], candidates: [],
customerSuggestions,
}; };
} }
} }
@@ -43,6 +43,7 @@ export class ConfirmPolicyDocumentDto {
@IsOptional() @IsNumber() netPremium?: number; @IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number; @IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number; @IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() total?: number; @IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string; @IsOptional() @IsString() premiumPayment?: string;
/** Printed term in days. Omitted leaves the parsed value (or the schema's /** Printed term in days. Omitted leaves the parsed value (or the schema's
@@ -79,6 +80,7 @@ export class ReviewPolicyDocumentDto {
@IsOptional() @IsNumber() netPremium?: number; @IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number; @IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number; @IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() total?: number; @IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string; @IsOptional() @IsString() premiumPayment?: string;
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number; @IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
@@ -192,6 +192,8 @@ export class PolicyOcrService {
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null, parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
extractedBrokerFee: extractedBrokerFee:
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null, parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
extractedTax:
parsed.tax != null ? new Prisma.Decimal(parsed.tax) : null,
extractedTotal: extractedTotal:
parsed.total != null ? new Prisma.Decimal(parsed.total) : null, parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
extractedCoveragesJson: parsed.coverages.length extractedCoveragesJson: parsed.coverages.length
@@ -211,6 +213,9 @@ export class PolicyOcrService {
matchCandidates: match.candidates.length matchCandidates: match.candidates.length
? (match.candidates as unknown as Prisma.InputJsonValue) ? (match.candidates as unknown as Prisma.InputJsonValue)
: Prisma.DbNull, : Prisma.DbNull,
customerSuggestions: match.customerSuggestions.length
? (match.customerSuggestions as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
matchNote: notes.join("; "), matchNote: notes.join("; "),
}, },
}); });
@@ -362,6 +367,7 @@ export class PolicyOcrService {
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined, dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
extractedBrokerFee: extractedBrokerFee:
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined, dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
extractedTax: dto.tax != null ? new Prisma.Decimal(dto.tax) : undefined,
extractedTotal: extractedTotal:
dto.total != null ? new Prisma.Decimal(dto.total) : undefined, dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
extractedCoveragesJson: dto.coveragesJson extractedCoveragesJson: dto.coveragesJson
@@ -796,6 +802,7 @@ function buildPolicyUpdateFromDoc(
extractedNetPremium: Prisma.Decimal | null; extractedNetPremium: Prisma.Decimal | null;
extractedPolicyFee: Prisma.Decimal | null; extractedPolicyFee: Prisma.Decimal | null;
extractedBrokerFee: Prisma.Decimal | null; extractedBrokerFee: Prisma.Decimal | null;
extractedTax: Prisma.Decimal | null;
extractedTotal: Prisma.Decimal | null; extractedTotal: Prisma.Decimal | null;
extractedCoveragesJson: Prisma.JsonValue | null; extractedCoveragesJson: Prisma.JsonValue | null;
extractedPremiumPayment: string | null; extractedPremiumPayment: string | null;
@@ -842,6 +849,10 @@ function buildPolicyUpdateFromDoc(
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium), netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee), policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee), brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
// the rate, and back-dividing it would mint a rate the document never
// stated — the policy form resolves one from the line of business instead.
tax: numOrUndef(item.tax, doc.extractedTax),
total: numOrUndef(item.total, doc.extractedTotal), total: numOrUndef(item.total, doc.extractedTotal),
// coveragesJson / observations: freeform, keep the GMX data when present. // coveragesJson / observations: freeform, keep the GMX data when present.
coveragesJson: coveragesJson:
@@ -883,6 +894,7 @@ function buildPolicyCreateFromDoc(
extractedNetPremium: Prisma.Decimal | null; extractedNetPremium: Prisma.Decimal | null;
extractedPolicyFee: Prisma.Decimal | null; extractedPolicyFee: Prisma.Decimal | null;
extractedBrokerFee: Prisma.Decimal | null; extractedBrokerFee: Prisma.Decimal | null;
extractedTax: Prisma.Decimal | null;
extractedTotal: Prisma.Decimal | null; extractedTotal: Prisma.Decimal | null;
extractedCoveragesJson: Prisma.JsonValue | null; extractedCoveragesJson: Prisma.JsonValue | null;
extractedPremiumPayment: string | null; extractedPremiumPayment: string | null;
@@ -933,6 +945,10 @@ function buildPolicyCreateFromDoc(
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium), netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee), policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee), brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
// the rate, and back-dividing it would mint a rate the document never
// stated — the policy form resolves one from the line of business instead.
tax: numOrUndef(item.tax, doc.extractedTax),
total: numOrUndef(item.total, doc.extractedTotal), total: numOrUndef(item.total, doc.extractedTotal),
coveragesJson: coveragesJson:
item.coveragesJson !== undefined item.coveragesJson !== undefined
+82 -20
View File
@@ -15,6 +15,7 @@
*/ */
import { Prisma } from "@jorgecuadros/database"; import { Prisma } from "@jorgecuadros/database";
import { BALANCE_FORWARD_TYPE } from "../billing/billing.service";
import { import {
intParam, intParam,
NOT_VOIDED, NOT_VOIDED,
@@ -751,8 +752,8 @@ const edoCuentaDatos: ReportDef = {
title: "Estado de cuenta", title: "Estado de cuenta",
description: description:
"Estado de cuenta de un cliente: saldos por moneda, desglose por " + "Estado de cuenta de un cliente: saldos por moneda, desglose por " +
"ramo y concepto, y el historial completo de movimientos con saldo " + "ramo y concepto, y los movimientos del año en curso con saldo " +
"corrido. El reporte del cliente final.", "corrido, abriendo con el saldo anterior. El reporte del cliente final.",
domain: "estado-cuenta", domain: "estado-cuenta",
legacyName: "EDO CUENTA DATOS", legacyName: "EDO CUENTA DATOS",
format: "statement", format: "statement",
@@ -789,22 +790,42 @@ const edoCuentaDatos: ReportDef = {
}); });
if (!customer) return { rows: [], subtitle: "Cliente no encontrado" }; if (!customer) return { rows: [], subtitle: "Cliente no encontrado" };
// Reuse the same NOT_VOIDED + STATEMENT_EXCLUDED_SOURCE_TABLES filter // The source-table exclusion, the balance floor and the year scope below
// as BillingService.statement so the numbers match what the customer // are BillingService.statement's, because this report and
// already sees in /estado-cuenta/[id]. // /estado-cuenta/[id] are the same statement — one printable, one on
// screen — and a customer holding both must not read two balances.
const floor = await prisma.transaction.findFirst({
where: {
customerId,
voidedAt: null,
type: { nameEn: BALANCE_FORWARD_TYPE },
},
orderBy: { transactionDate: "desc" },
select: { transactionDate: true },
});
const rows = await prisma.transaction.findMany({ const rows = await prisma.transaction.findMany({
where: { where: {
customerId, customerId,
voidedAt: null, voidedAt: null,
legacySourceTable: { ...(floor ? { transactionDate: { gte: floor.transactionDate } } : {}),
notIn: [ // NULL-safe: `NULL NOT IN (...)` is NULL, not true, so a bare `notIn`
"EFECTIVO", // drops every app-captured row (they have no legacySourceTable) — the
"EFECTIVO_BACKUP", // same defect this report's on-screen twin was fixed for.
"EFECTIVO FM3", OR: [
"CHEQUE FM3", { legacySourceTable: null },
"IVA 2015", {
], legacySourceTable: {
}, notIn: [
"EFECTIVO",
"EFECTIVO_BACKUP",
"EFECTIVO FM3",
"CHEQUE FM3",
"IVA 2015",
],
},
},
],
}, },
orderBy: [{ transactionDate: "asc" }, { id: "asc" }], orderBy: [{ transactionDate: "asc" }, { id: "asc" }],
select: { select: {
@@ -822,12 +843,28 @@ const edoCuentaDatos: ReportDef = {
}, },
}); });
// Compute running balance per currency, then return newest-first. // Scoped to the calendar year and listed oldest-first, the way the legacy
// EDO CUENTA sheet reads. Rows from earlier years still move the running
// balance — they are folded into `opening` and printed as a single "saldo
// anterior" line, which is what a BALANCE FORWARD row is.
const yearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
const year = yearStart.getUTCFullYear();
const running = new Map<string, Prisma.Decimal>(); const running = new Map<string, Prisma.Decimal>();
const movements = rows.map((r) => { const opening = new Map<string, Prisma.Decimal>();
const visible: typeof rows = [];
const movements = rows.flatMap((r) => {
const prev = running.get(r.currency) ?? new Prisma.Decimal(0); const prev = running.get(r.currency) ?? new Prisma.Decimal(0);
const next = prev.plus(r.amount); const next = prev.plus(r.amount);
running.set(r.currency, next); running.set(r.currency, next);
if (r.transactionDate < yearStart) {
opening.set(r.currency, next);
return [];
}
visible.push(r);
return { return {
date: r.transactionDate.toISOString().slice(0, 10), date: r.transactionDate.toISOString().slice(0, 10),
domain: r.domain, domain: r.domain,
@@ -840,14 +877,38 @@ const edoCuentaDatos: ReportDef = {
balanceAfter: next.toFixed(2), balanceAfter: next.toFixed(2),
}; };
}); });
movements.reverse();
// Per-currency summary + per-domain breakdown. // The carried balance, printed as the statement's first line — same shape
// as a movement row so it needs nothing special from the renderer.
const carried = [...opening.entries()]
.filter(([, amount]) => !amount.isZero())
.map(([currency, amount]) => ({
date: yearStart.toISOString().slice(0, 10),
domain: "UTILITY",
currency,
reference: "",
period: `Al cierre de ${year - 1}`,
checkNumber: "",
concept: "SALDO ANTERIOR",
amount: amount.toFixed(2),
balanceAfter: amount.toFixed(2),
}));
// Per-currency summary, seeded with the carried balance so it reconciles
// against the last running balance printed below.
const perCurrency = new Map< const perCurrency = new Map<
string, string,
{ currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number } { currency: string; charges: Prisma.Decimal; credits: Prisma.Decimal; count: number }
>(); >();
for (const r of rows) { for (const [currency, amount] of opening) {
perCurrency.set(currency, {
currency,
charges: amount.lessThan(0) ? amount : new Prisma.Decimal(0),
credits: amount.lessThan(0) ? new Prisma.Decimal(0) : amount,
count: 0,
});
}
for (const r of visible) {
const c = const c =
perCurrency.get(r.currency) ?? perCurrency.get(r.currency) ??
{ {
@@ -881,9 +942,10 @@ const edoCuentaDatos: ReportDef = {
count: c.count, count: c.count,
})), })),
{ __kind: "movements-header" }, { __kind: "movements-header" },
...carried,
...movements, ...movements,
], ],
subtitle: `${nameOf(customer)} · ${rows.length} movimientos`, subtitle: `${nameOf(customer)} · ${year} · ${visible.length} movimientos`,
}; };
}, },
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/web", "name": "@jorgecuadros/web",
"version": "1.0.20", "version": "1.0.23",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 4500", "dev": "next dev -p 4500",
+4
View File
@@ -18,6 +18,10 @@ const TYPE: ChildConfig = {
fields: [ fields: [
{ key: "name", label: "Nombre" }, { key: "name", label: "Nombre" },
{ key: "shortDescription", label: "Descripción" }, { key: "shortDescription", label: "Descripción" },
// The rate is stored as a fraction, not a percentage, and the label has to
// say so: 8 typed here would tax a $600 premium $4,800. The API rejects
// anything above 1 rather than trusting the label alone.
{ key: "taxRate", label: "IVA (0.08 = 8%)", type: "number", step: "0.0001" },
], ],
}; };
const ADJUSTER: ChildConfig = { const ADJUSTER: ChildConfig = {
+66 -6
View File
@@ -36,10 +36,12 @@ import type {
* charge and an insurance payment finally sit on the same page, under the same * charge and an insurance payment finally sit on the same page, under the same
* person, with a running balance. * person, with a running balance.
* *
* The running balance is per currency (the API accumulates it chronologically * The running balance is per currency, so the movement table is scoped to one
* before handing the list back newest-first), so the movement table is scoped * currency at a time — a column that alternated between pesos and dollars would
* to one currency at a time — a column that alternated between pesos and * be a meaningless number.
* dollars would be a meaningless number. *
* Like the legacy EDO CUENTA report, the table covers the current year only and
* runs oldest-first, opening on the balance carried in from before it.
*/ */
export default function EstadoCuentaDetailPage({ export default function EstadoCuentaDetailPage({
params, params,
@@ -194,7 +196,7 @@ function StatementView({ id }: { id: string }) {
<section className="section"> <section className="section">
<SectionHead <SectionHead
rule="cuenta" rule="cuenta"
title="Movimientos" title={`Movimientos ${data.year}`}
count={movements.length} count={movements.length}
countSuffix={movements.length === 1 ? "movimiento" : "movimientos"} countSuffix={movements.length === 1 ? "movimiento" : "movimientos"}
right={ right={
@@ -260,7 +262,7 @@ function StatementView({ id }: { id: string }) {
<div className="card"> <div className="card">
{movements.length === 0 ? ( {movements.length === 0 ? (
<div className="empty-inline"> <div className="empty-inline">
Sin movimientos en {currency} Sin movimientos de {data.year} en {currency}
{domain ? ` para ${domainLabel(domain)}` : ""}. {domain ? ` para ${domainLabel(domain)}` : ""}.
</div> </div>
) : ( ) : (
@@ -282,6 +284,26 @@ function StatementView({ id }: { id: string }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{/*
The carried balance, shown the way the legacy report shows
it: a BALANCE FORWARD line above the year's movements. It
only appears when there is something to carry — when the
customer's opening-balance row is itself dated inside this
year (the usual case) it is listed as an ordinary movement
and this row is zero, so it is left out.
Suppressed under a business-line filter: the carried balance
is the customer's, across both lines, and printing it above
one line's rows would read as that line's opening balance.
*/}
{!domain && Number(active?.opening ?? 0) !== 0 && (
<OpeningRow
opening={active!.opening}
currency={currency}
year={data.year}
canVoid={canVoid}
/>
)}
{movements.map((m) => ( {movements.map((m) => (
<StatementRow <StatementRow
key={m.id} key={m.id}
@@ -481,6 +503,44 @@ function ConceptosSection({
); );
} }
/** The balance carried into the statement year — legacy's BALANCE FORWARD. */
function OpeningRow({
opening,
currency,
year,
canVoid,
}: {
opening: string;
currency: LedgerCurrency;
year: number;
canVoid: boolean;
}) {
return (
<tr>
<td className="mono" style={{ whiteSpace: "nowrap" }}>
{formatDate(`${year}-01-01T00:00:00.000Z`)}
</td>
<td className="tx-domain-cell">Ambas líneas</td>
<td>
Saldo anterior
<div className="tx-concept">Al cierre de {year - 1}</div>
</td>
<td className="tx-ref"></td>
<td className="num">
<span className={`tx-amount ${Number(opening) < 0 ? "neg" : "pos"}`}>
{formatMoney(opening, currency)}
</span>
</td>
<td className="num">
<span className={`bal-running ${balanceTone(opening)}`}>
{formatMoney(opening, currency)}
</span>
</td>
{canVoid && <td />}
</tr>
);
}
function StatementRow({ function StatementRow({
m, m,
canVoid, canVoid,
+73
View File
@@ -926,6 +926,15 @@ button {
color: var(--ink-soft); color: var(--ink-soft);
margin-bottom: 0.4375rem; margin-bottom: 0.4375rem;
} }
/* Sub-label under an input: the computed figure behind an override field, or
why a field is disabled. Quiet enough not to compete with .field-label. */
.field-hint {
display: block;
font-size: 0.75rem;
line-height: 1.35;
color: var(--muted-2);
margin-top: 0.3125rem;
}
.input { .input {
width: 100%; width: 100%;
font-family: inherit; font-family: inherit;
@@ -940,6 +949,12 @@ button {
.input::placeholder { .input::placeholder {
color: var(--muted-2); color: var(--muted-2);
} }
.input:disabled,
.select:disabled {
background: var(--surface-2, var(--surface));
color: var(--muted-2);
cursor: not-allowed;
}
.input:focus { .input:focus {
outline: none; outline: none;
border-color: var(--brand-600); border-color: var(--brand-600);
@@ -3114,3 +3129,61 @@ button {
border-color: var(--brand-500); border-color: var(--brand-500);
color: var(--brand-700); color: var(--brand-700);
} }
/* ============================================================================
Layout + text utilities the screens already assumed
Several components were written against these names before any rule
defined them, so they rendered as bare inline spans. The visible symptom
was the policy OCR review header running together —
"Para revisarPágina 1700489616· PAMELA DENISE WAGONERLICENCIASANA" —
because JSX drops the newline between sibling elements and the `gap` those
call sites pass does nothing without a flex container.
========================================================================== */
.row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* The muted line under a page title, and the same voice reused inline. Only
the block form takes a margin — as a flex child it would shift the item
off the row's centre line. */
.page-sub {
color: var(--muted);
font-size: 0.875rem;
}
p.page-sub {
margin: 0.25rem 0 0;
}
/* A neutral chip. Same shape as `.badge` so the OCR statuses, policy type and
carrier read as the labels they are rather than as running prose. */
.tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.1875rem 0.5625rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1.4;
white-space: nowrap;
background: var(--paper-2);
color: var(--muted);
border: 1px solid var(--line-strong);
}
/* The warning sibling of `.state-error`, used where a page needs a human to
choose between candidates rather than reporting a failure. */
.state-warn {
background: var(--servicios-tint);
border: 1px solid rgba(154, 106, 18, 0.25);
color: var(--servicios-ink);
border-radius: var(--radius);
padding: 1rem 1.125rem;
font-size: 0.875rem;
}
+45 -4
View File
@@ -26,7 +26,13 @@ import {
premiumHeadline, premiumHeadline,
SIN_NOMBRE, SIN_NOMBRE,
} from "@/lib/labels"; } from "@/lib/labels";
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types"; import {
PAYMENT_FREQUENCY_LABELS,
type AdjusterRow,
type Installment,
type PolicyDetail,
} from "@/lib/types";
import { formatRate } from "@/lib/premium";
export default function PolizaDetailPage({ export default function PolizaDetailPage({
params, params,
@@ -186,6 +192,10 @@ function ChildrenEditor({
const INSTALLMENTS: ChildConfig = { const INSTALLMENTS: ChildConfig = {
apiKind: "installments", apiKind: "installments",
title: "Pagos", title: "Pagos",
// A policy paid in several exhibiciones prices each payment on its own, so
// the whole premium breakdown repeats per row — that is the two-row money
// block on the Access form. `amount` stays what was actually collected and
// is deliberately separate from `total`; they differ by rounding.
fields: [ fields: [
{ key: "sequence", label: "Sec.", type: "number" }, { key: "sequence", label: "Sec.", type: "number" },
{ key: "amount", label: "Monto", type: "number" }, { key: "amount", label: "Monto", type: "number" },
@@ -195,6 +205,12 @@ function ChildrenEditor({
{ key: "paidDate", label: "Pagado", type: "date" }, { key: "paidDate", label: "Pagado", type: "date" },
{ key: "checkNumber", label: "Cheque" }, { key: "checkNumber", label: "Cheque" },
{ key: "isCash", label: "Efectivo", type: "checkbox" }, { key: "isCash", label: "Efectivo", type: "checkbox" },
{ key: "netPremium", label: "Prima neta", type: "number" },
{ key: "surcharge", label: "Recargo", type: "number" },
{ key: "policyFee", label: "Derecho", type: "number" },
{ key: "tax", label: "IVA", type: "number" },
{ key: "total", label: "Prima total", type: "number" },
{ key: "commission", label: "Comisión", type: "number" },
], ],
}; };
const VEHICLES: ChildConfig = { const VEHICLES: ChildConfig = {
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
} }
/> />
<KV
label="Forma de pago"
value={
data.paymentFrequency
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
: null
}
/>
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} /> <KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
{/* Only ever set on a policy paid in installments, so showing an
empty row on the other 98% would be noise. */}
{data.surcharge != null && Number(data.surcharge) !== 0 && (
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
)}
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} /> <KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
<KV label="Comisión" value={formatMoney(data.commission, cur)} /> {/* Access never stored IVA — it was a calculated control on the form
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} /> — so every migrated policy reads null here until it is edited. */}
{data.tax != null && (
<KV
label={
data.taxRate != null
? `IVA (${formatRate(Number(data.taxRate))})`
: "IVA"
}
value={formatMoney(data.tax, cur)}
/>
)}
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies — {/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
only show it when it actually carries a figure. */} only show it when it actually carries a figure. */}
{data.total != null && Number(data.total) > 0 && ( {data.total != null && Number(data.total) > 0 && (
<KV label="Total" value={formatMoney(data.total, cur)} /> <KV label="Prima total" value={formatMoney(data.total, cur)} />
)} )}
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
<KV <KV
label="Liquidación" label="Liquidación"
value={ value={
+4 -1
View File
@@ -9,6 +9,9 @@ export type FieldDef = {
type?: "text" | "number" | "date" | "checkbox" | "select"; type?: "text" | "number" | "date" | "checkbox" | "select";
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
width?: number; width?: number;
/** Numeric granularity. Defaults to money (0.01); a tax rate stored as a
* fraction needs finer, or the browser rejects 0.0825 as off-step. */
step?: string;
}; };
export type ChildConfig = { export type ChildConfig = {
@@ -158,7 +161,7 @@ export function ChildCollection({
<input <input
className="input" className="input"
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"} type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
step={f.type === "number" ? "0.01" : undefined} step={f.type === "number" ? f.step ?? "0.01" : undefined}
value={String(values[f.key] ?? "")} value={String(values[f.key] ?? "")}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })} onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/> />
+119 -6
View File
@@ -4,12 +4,22 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { CustomerPicker } from "@/components/CustomerPicker"; import { CustomerPicker } from "@/components/CustomerPicker";
import { createPolicy, getLookups, updatePolicy } from "@/lib/api"; import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
import type { import {
Currency, PAYMENT_FREQUENCY_LABELS,
LookupsResponse, type Currency,
PolicyDetail, type LookupsResponse,
PolicyInput, type PaymentFrequency,
type PolicyDetail,
type PolicyInput,
} from "@/lib/types"; } from "@/lib/types";
import {
computeTax,
computeTotal,
formatRate,
resolveTaxRate,
surchargeApplies,
taxableBase,
} from "@/lib/premium";
function toDateInput(v: string | null | undefined): string { function toDateInput(v: string | null | undefined): string {
if (!v) return ""; if (!v) return "";
@@ -36,9 +46,16 @@ type V = {
policyFrom: string; policyFrom: string;
policyTo: string; policyTo: string;
netPremium: string; netPremium: string;
surcharge: string;
policyFee: string; policyFee: string;
brokerFee: string; brokerFee: string;
commission: string; commission: string;
/** Blank means "use the computed figure". Only ever holds a value once the
* operator overrides it, so a later change to prima neta keeps flowing
* through instead of being frozen by a value the form itself wrote. */
tax: string;
total: string;
paymentFrequency: PaymentFrequency | "";
currency: Currency; currency: Currency;
liquidated: boolean; liquidated: boolean;
liquidationNumber: string; liquidationNumber: string;
@@ -58,9 +75,13 @@ function initial(p?: PolicyDetail): V {
policyFrom: toDateInput(p?.policyFrom), policyFrom: toDateInput(p?.policyFrom),
policyTo: toDateInput(p?.policyTo), policyTo: toDateInput(p?.policyTo),
netPremium: p?.netPremium != null ? String(p.netPremium) : "", netPremium: p?.netPremium != null ? String(p.netPremium) : "",
surcharge: p?.surcharge != null ? String(p.surcharge) : "",
policyFee: p?.policyFee != null ? String(p.policyFee) : "", policyFee: p?.policyFee != null ? String(p.policyFee) : "",
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "", brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
commission: p?.commission != null ? String(p.commission) : "", commission: p?.commission != null ? String(p.commission) : "",
tax: p?.tax != null ? String(p.tax) : "",
total: p?.total != null ? String(p.total) : "",
paymentFrequency: p?.paymentFrequency ?? "",
currency: (p?.currency as Currency) ?? "MXN", currency: (p?.currency as Currency) ?? "MXN",
liquidated: p?.liquidated ?? false, liquidated: p?.liquidated ?? false,
liquidationNumber: p?.liquidationNumber ?? "", liquidationNumber: p?.liquidationNumber ?? "",
@@ -101,6 +122,27 @@ export function PolicyForm({
setV((p) => ({ ...p, [k]: val })); setV((p) => ({ ...p, [k]: val }));
} }
// IVA and Total are the only two figures the form derives. Everything else,
// the recargo included, is keyed by hand — the carrier quotes the financing
// charge, we do not compute it.
const selectedType = lookups?.types.find((t) => t.id === v.policyTypeId);
const taxRate = resolveTaxRate(policy?.taxRate, selectedType?.taxRate);
const parts = {
netPremium: v.netPremium,
// A recargo on an annual policy is a data-entry mistake, so it is dropped
// from the arithmetic as well as disabled in the UI. Otherwise switching
// ANNUAL after typing one would leave it silently inflating the IVA.
surcharge: surchargeApplies(v.paymentFrequency || null) ? v.surcharge : "",
policyFee: v.policyFee,
};
const computedTax = computeTax(parts, taxRate);
const computedTotal = computeTotal(parts, taxRate);
// Blank field = take the computed figure. A typed one wins, so staff can key
// the carrier's rounding verbatim when it disagrees with ours by a centavo.
const effectiveTax = v.tax.trim() === "" ? computedTax : Number(v.tax);
const effectiveTotal = v.total.trim() === "" ? computedTotal : Number(v.total);
const showSurcharge = surchargeApplies(v.paymentFrequency || null);
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!customerId) { if (!customerId) {
@@ -118,9 +160,17 @@ export function PolicyForm({
policyFrom: s(v.policyFrom), policyFrom: s(v.policyFrom),
policyTo: s(v.policyTo), policyTo: s(v.policyTo),
netPremium: numOrUndef(v.netPremium), netPremium: numOrUndef(v.netPremium),
surcharge: showSurcharge ? numOrUndef(v.surcharge) : undefined,
policyFee: numOrUndef(v.policyFee), policyFee: numOrUndef(v.policyFee),
brokerFee: numOrUndef(v.brokerFee), brokerFee: numOrUndef(v.brokerFee),
commission: numOrUndef(v.commission), commission: numOrUndef(v.commission),
// The derived figures are persisted, not recomputed on read: the printed
// policy is the record of truth and a later rate change must not silently
// restate what was issued. `taxRate` rides along for the same reason.
tax: Number.isFinite(effectiveTax) ? effectiveTax : undefined,
taxRate,
total: Number.isFinite(effectiveTotal) ? effectiveTotal : undefined,
paymentFrequency: v.paymentFrequency || undefined,
currency: v.currency, currency: v.currency,
liquidated: v.liquidated, liquidated: v.liquidated,
liquidationNumber: s(v.liquidationNumber), liquidationNumber: s(v.liquidationNumber),
@@ -208,7 +258,7 @@ export function PolicyForm({
</div> </div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}> <div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2> <h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia</h2>
<div className="form-grid"> <div className="form-grid">
<label className="field"> <label className="field">
<span className="field-label">Emisión</span> <span className="field-label">Emisión</span>
@@ -225,21 +275,84 @@ export function PolicyForm({
<input className="input" type="date" value={v.policyTo} <input className="input" type="date" value={v.policyTo}
onChange={(e) => set("policyTo", e.target.value)} /> onChange={(e) => set("policyTo", e.target.value)} />
</label> </label>
<label className="field">
<span className="field-label">Forma de pago</span>
<select className="select" value={v.paymentFrequency}
onChange={(e) =>
set("paymentFrequency", e.target.value as PaymentFrequency | "")
}>
<option value=""></option>
{(
Object.keys(PAYMENT_FREQUENCY_LABELS) as PaymentFrequency[]
).map((f) => (
<option key={f} value={f}>{PAYMENT_FREQUENCY_LABELS[f]}</option>
))}
</select>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 4 }}>Primas</h2>
<p className="muted" style={{ fontSize: 12, marginBottom: 14 }}>
IVA y prima total se calculan solos sobre (prima neta + recargo +
derecho de póliza). Puede sobrescribirlos si la póliza impresa
redondea distinto.
</p>
<div className="form-grid">
<label className="field"> <label className="field">
<span className="field-label">Prima neta</span> <span className="field-label">Prima neta</span>
<input className="input" type="number" step="0.01" value={v.netPremium} <input className="input" type="number" step="0.01" value={v.netPremium}
onChange={(e) => set("netPremium", e.target.value)} /> onChange={(e) => set("netPremium", e.target.value)} />
</label> </label>
<label className="field">
<span className="field-label">Recargo</span>
<input className="input" type="number" step="0.01" value={v.surcharge}
disabled={!showSurcharge}
onChange={(e) => set("surcharge", e.target.value)} />
<span className="field-hint">
{showSurcharge
? "Lo cotiza la aseguradora — se captura a mano."
: "No aplica en pago anual ni de contado."}
</span>
</label>
<label className="field"> <label className="field">
<span className="field-label">Derecho de póliza</span> <span className="field-label">Derecho de póliza</span>
<input className="input" type="number" step="0.01" value={v.policyFee} <input className="input" type="number" step="0.01" value={v.policyFee}
onChange={(e) => set("policyFee", e.target.value)} /> onChange={(e) => set("policyFee", e.target.value)} />
</label> </label>
<label className="field">
<span className="field-label">IVA ({formatRate(taxRate)})</span>
<input className="input" type="number" step="0.01"
placeholder={computedTax.toFixed(2)} value={v.tax}
onChange={(e) => set("tax", e.target.value)} />
<span className="field-hint">
Calculado: {computedTax.toFixed(2)} sobre base{" "}
{taxableBase(parts).toFixed(2)}
{selectedType?.taxRate == null &&
policy?.taxRate == null &&
" · tasa por omisión, configúrela en Catálogos"}
</span>
</label>
<label className="field">
<span className="field-label">Prima total</span>
<input className="input" type="number" step="0.01"
placeholder={computedTotal.toFixed(2)} value={v.total}
onChange={(e) => set("total", e.target.value)} />
<span className="field-hint">
Calculado: {computedTotal.toFixed(2)}
</span>
</label>
<label className="field"> <label className="field">
<span className="field-label">Comisión</span> <span className="field-label">Comisión</span>
<input className="input" type="number" step="0.01" value={v.commission} <input className="input" type="number" step="0.01" value={v.commission}
onChange={(e) => set("commission", e.target.value)} /> onChange={(e) => set("commission", e.target.value)} />
</label> </label>
<label className="field">
<span className="field-label">Honorarios</span>
<input className="input" type="number" step="0.01" value={v.brokerFee}
onChange={(e) => set("brokerFee", e.target.value)} />
</label>
</div> </div>
</div> </div>
+65 -7
View File
@@ -21,6 +21,7 @@ import type {
PolicyOcrBatchDetail, PolicyOcrBatchDetail,
PolicyOcrConfirmDocument, PolicyOcrConfirmDocument,
PolicyOcrCoverage, PolicyOcrCoverage,
PolicyOcrCustomerSuggestion,
PolicyOcrDocument, PolicyOcrDocument,
PolicyOcrReviewInput, PolicyOcrReviewInput,
} from "@/lib/types"; } from "@/lib/types";
@@ -180,8 +181,11 @@ export function PolicyOcrReview({ id }: { id: string }) {
{STATUS_LABEL[batch.status] ?? batch.status} {STATUS_LABEL[batch.status] ?? batch.status}
</p> </p>
</div> </div>
<Link className="btn btn-ghost" href="/polizas"> {/* Back to the capture screen this batch was uploaded from, not to
Volver a pólizas the policy list — same as the statement review screen, which
returns to /recibos. */}
<Link className="btn btn-ghost" href="/polizas/captura">
Volver a captura
</Link> </Link>
</header> </header>
@@ -272,6 +276,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "", policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
currency: doc.extractedCurrency ?? "USD", currency: doc.extractedCurrency ?? "USD",
netPremium: doc.extractedNetPremium ?? "", netPremium: doc.extractedNetPremium ?? "",
policyFee: doc.extractedPolicyFee ?? "",
tax: doc.extractedTax ?? "",
total: doc.extractedTotal ?? "", total: doc.extractedTotal ?? "",
premiumPayment: doc.extractedPremiumPayment ?? "", premiumPayment: doc.extractedPremiumPayment ?? "",
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "", coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
@@ -310,6 +316,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
policyDate: v.policyDate || undefined, policyDate: v.policyDate || undefined,
currency, currency,
netPremium: numOrUndef(v.netPremium), netPremium: numOrUndef(v.netPremium),
policyFee: numOrUndef(v.policyFee),
tax: numOrUndef(v.tax),
total: numOrUndef(v.total), total: numOrUndef(v.total),
premiumPayment: trimOrUndef(v.premiumPayment), premiumPayment: trimOrUndef(v.premiumPayment),
coveragePeriodDays: numOrUndef(v.coveragePeriodDays), coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
@@ -332,6 +340,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
policyDate: reviewInput.policyDate, policyDate: reviewInput.policyDate,
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined, currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
netPremium: reviewInput.netPremium, netPremium: reviewInput.netPremium,
policyFee: reviewInput.policyFee,
tax: reviewInput.tax,
total: reviewInput.total, total: reviewInput.total,
premiumPayment: reviewInput.premiumPayment, premiumPayment: reviewInput.premiumPayment,
coveragePeriodDays: reviewInput.coveragePeriodDays, coveragePeriodDays: reviewInput.coveragePeriodDays,
@@ -351,17 +361,19 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
const locked = doc.status === "POSTED" || doc.status === "REJECTED"; const locked = doc.status === "POSTED" || doc.status === "REJECTED";
const matchedExisting = !!doc.matchedPolicy; const matchedExisting = !!doc.matchedPolicy;
const candidates = doc.matchCandidates ?? []; const candidates = doc.matchCandidates ?? [];
const suggestions: PolicyOcrCustomerSuggestion[] = doc.customerSuggestions ?? [];
return ( return (
<article className="card" style={{ padding: 16 }}> <article className="card" style={{ padding: 16 }}>
<header className="row" style={{ gap: 12, alignItems: "center" }}> <header className="row" style={{ gap: 12, alignItems: "center" }}>
<span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span> <span className="tag">{STATUS_LABEL[doc.status] ?? doc.status}</span>
<span className="page-sub">Página {doc.pageNumber}</span> <span className="page-sub">Página {doc.pageNumber}</span>
{doc.extractedPolicyNumber && ( {/* No hand-rolled separators or margins here: `.row` is a flex
<strong style={{ marginLeft: 8 }}>{doc.extractedPolicyNumber}</strong> container and its gap does the spacing. A literal "· " would leave
)} a dot floating in that gap. */}
{doc.extractedPolicyNumber && <strong>{doc.extractedPolicyNumber}</strong>}
{doc.extractedInsuredName && ( {doc.extractedInsuredName && (
<span className="page-sub">· {doc.extractedInsuredName}</span> <span className="page-sub">{doc.extractedInsuredName}</span>
)} )}
{/* Read-only: the parser names the type, the confirm step resolves it {/* Read-only: the parser names the type, the confirm step resolves it
to a policy_types row. Reassigning it is the policy screen's job, to a policy_types row. Reassigning it is the policy screen's job,
@@ -495,7 +507,25 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
onChange={(e) => set("netPremium", e.target.value)} onChange={(e) => set("netPremium", e.target.value)}
/> />
</Field> </Field>
<Field label="Total"> <Field label="Derecho de póliza">
<input
className="input"
type="number"
step="0.01"
value={v.policyFee}
onChange={(e) => set("policyFee", e.target.value)}
/>
</Field>
<Field label="IVA">
<input
className="input"
type="number"
step="0.01"
value={v.tax}
onChange={(e) => set("tax", e.target.value)}
/>
</Field>
<Field label="Prima total">
<input <input
className="input" className="input"
type="number" type="number"
@@ -670,6 +700,34 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
</Field> </Field>
)} )}
{/*
* Name suggestions, never a preselection. The office writes
* customers surname-first and carriers print them given-name-first,
* so without this the reviewer retypes a name the parser already
* read. One click fills the picker above; nothing is chosen until
* they click. Kept outside the <Field> label — a label must not
* wrap other interactive controls.
*/}
{!policyId && !customerId && !locked && canReview && suggestions.length > 0 && (
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<span className="page-sub">Sugerencias por nombre:</span>
{suggestions.map((s) => (
<button
key={s.customerId}
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
setCustomerId(s.customerId);
setCustomerName(s.customerName);
}}
>
{s.customerName}
{s.tier === "PARTIAL" && <span className="page-sub"> · parcial</span>}
</button>
))}
</div>
)}
<label className="field"> <label className="field">
<input <input
type="checkbox" type="checkbox"
+79
View File
@@ -0,0 +1,79 @@
import type { PaymentFrequency } from "./types";
/**
* Client-side twin of apps/api/src/policies/premium.ts. Duplicated rather than
* shared because the API and the web app do not share a package today, and
* both need it: the form computes IVA and Total live as the operator types,
* the API stores what it is sent.
*
* base = prima neta + recargo + derecho de póliza
* IVA = round(base * tasa)
* Total = base + IVA
*
* The recargo is inside the taxable base — that is what reconciles the Access
* books (policy 7006785 prints IVA 52.03 on 610.86 + 8.55 + 31.00; leaving the
* recargo out gives 51.35, which matches nothing on the page).
*/
/** Applied when neither the policy nor its type carries a rate. The single
* row both legacy IMPUESTOS tables held. */
export const DEFAULT_TAX_RATE = 0.08;
/** Paying in more than one exhibición is what earns a recargo. A null
* frequency (every migrated policy) is treated as "unknown, allow it": the
* legacy recargo figures are real and hiding the field would hide them. */
export function surchargeApplies(
frequency: PaymentFrequency | null | undefined,
): boolean {
return frequency !== "ANNUAL" && frequency !== "SINGLE";
}
export function num(v: string | number | null | undefined): number {
if (v === null || v === undefined || v === "") return 0;
const n = typeof v === "number" ? v : Number(String(v).trim());
return Number.isFinite(n) ? n : 0;
}
/** Half-up to cents, matching how the printed policy rounds. */
export function round2(n: number): number {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
export interface PremiumParts {
netPremium: string | number | null | undefined;
surcharge: string | number | null | undefined;
policyFee: string | number | null | undefined;
}
export function taxableBase(p: PremiumParts): number {
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
}
export function computeTax(p: PremiumParts, rate: number): number {
return round2(taxableBase(p) * rate);
}
export function computeTotal(p: PremiumParts, rate: number): number {
return round2(taxableBase(p) + computeTax(p, rate));
}
/** Rate ladder: what the policy was issued at, else its line of business, else
* the default. Keeps an old policy reading back at its original rate after
* somebody edits the catalog. */
export function resolveTaxRate(
policyRate: string | number | null | undefined,
policyTypeRate: string | number | null | undefined,
): number {
for (const candidate of [policyRate, policyTypeRate]) {
if (candidate === null || candidate === undefined || candidate === "") continue;
const n = Number(candidate);
if (Number.isFinite(n) && n >= 0) return n;
}
return DEFAULT_TAX_RATE;
}
/** 0.08 -> "8%". Rates are stored as fractions but read as percentages. */
export function formatRate(rate: number): string {
const pct = round2(rate * 100);
return `${pct}%`;
}
+71
View File
@@ -3,6 +3,24 @@
export type Currency = "USD" | "MXN"; export type Currency = "USD" | "MXN";
/** How the premium is split into payments. Anything other than ANNUAL/SINGLE
* is what earns a recargo. Null on every migrated policy — the original ETL
* dropped Access's FORMA PAGO column entirely. */
export type PaymentFrequency =
| "ANNUAL"
| "SEMIANNUAL"
| "QUARTERLY"
| "MONTHLY"
| "SINGLE";
export const PAYMENT_FREQUENCY_LABELS: Record<PaymentFrequency, string> = {
ANNUAL: "Anual",
SEMIANNUAL: "Semestral",
QUARTERLY: "Trimestral",
MONTHLY: "Mensual",
SINGLE: "Contado",
};
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER"; export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
export type Ability = export type Ability =
@@ -289,6 +307,16 @@ export interface Installment {
paidDate: string | null; paidDate: string | null;
checkNumber: string | null; checkNumber: string | null;
isCash: boolean; isCash: boolean;
/** Per-payment premium breakdown — a policy paid in several exhibiciones
* prices each one separately. `amount` is what was actually collected and
* can differ from `total` by rounding; it is not derived from these. */
netPremium: string | null;
surcharge: string | null;
policyFee: string | null;
tax: string | null;
taxRate: string | null;
total: string | null;
commission: string | null;
} }
export interface Vehicle { export interface Vehicle {
@@ -398,10 +426,14 @@ export interface PolicyInput {
policyTo?: string; policyTo?: string;
coveragePeriodDays?: number; coveragePeriodDays?: number;
netPremium?: number; netPremium?: number;
surcharge?: number;
policyFee?: number; policyFee?: number;
brokerFee?: number; brokerFee?: number;
commission?: number; commission?: number;
tax?: number;
taxRate?: number;
total?: number; total?: number;
paymentFrequency?: PaymentFrequency;
currency?: Currency; currency?: Currency;
observations?: string; observations?: string;
notes?: string; notes?: string;
@@ -419,6 +451,13 @@ export interface InstallmentInput {
paidDate?: string; paidDate?: string;
checkNumber?: string; checkNumber?: string;
isCash?: boolean; isCash?: boolean;
netPremium?: number;
surcharge?: number;
policyFee?: number;
tax?: number;
taxRate?: number;
total?: number;
commission?: number;
} }
export interface VehicleInput { export interface VehicleInput {
make?: string; make?: string;
@@ -469,6 +508,10 @@ export interface PolicyTypeRow {
id: string; id: string;
name: string; name: string;
shortDescription: string | null; shortDescription: string | null;
/** IVA fraction for this line of business, 0.08 = 8%. Null means "not
* configured" and the form falls back to DEFAULT_TAX_RATE — it does NOT
* mean the line is untaxed. Serialized as a decimal string by Prisma. */
taxRate: string | null;
_count?: { policies: number }; _count?: { policies: number };
} }
export interface AdjusterRow { export interface AdjusterRow {
@@ -567,10 +610,14 @@ export interface PolicyDetail {
policyTo: string | null; policyTo: string | null;
coveragePeriodDays: number | null; coveragePeriodDays: number | null;
netPremium: string | null; netPremium: string | null;
surcharge: string | null;
policyFee: string | null; policyFee: string | null;
brokerFee: string | null; brokerFee: string | null;
commission: string | null; commission: string | null;
tax: string | null;
taxRate: string | null;
total: string | null; total: string | null;
paymentFrequency: PaymentFrequency | null;
currency: string | null; currency: string | null;
observations: string | null; observations: string | null;
notes: string | null; notes: string | null;
@@ -994,6 +1041,8 @@ export interface BillingFacets {
export interface StatementSummary { export interface StatementSummary {
currency: LedgerCurrency; currency: LedgerCurrency;
/** Balance carried in from before the statement year — legacy's BALANCE FORWARD. */
opening: string;
charges: string; charges: string;
credits: string; credits: string;
balance: string; balance: string;
@@ -1007,6 +1056,7 @@ export interface StatementSummary {
export interface StatementDomainRow { export interface StatementDomainRow {
domain: TransactionDomain; domain: TransactionDomain;
currency: LedgerCurrency; currency: LedgerCurrency;
opening: string;
charges: string; charges: string;
credits: string; credits: string;
balance: string; balance: string;
@@ -1042,6 +1092,8 @@ export interface Statement {
propertyCount: number; propertyCount: number;
policyCount: number; policyCount: number;
}; };
/** Calendar year the statement covers; movements are scoped to it. */
year: number;
summary: StatementSummary[]; summary: StatementSummary[];
byDomain: StatementDomainRow[]; byDomain: StatementDomainRow[];
byType: StatementTypeRow[]; byType: StatementTypeRow[];
@@ -1477,6 +1529,18 @@ export interface PolicyOcrMatchCandidate {
policyNumber: string; policyNumber: string;
} }
/**
* A customer whose name resembles the printed insured name. A suggestion,
* not a match — the API never preselects one.
*/
export interface PolicyOcrCustomerSuggestion {
customerId: string;
customerName: string;
/** `EXACT` = same name tokens in any order; `PARTIAL` = one contains the other. */
tier: "EXACT" | "PARTIAL";
score: number;
}
export interface PolicyOcrDocument { export interface PolicyOcrDocument {
id: string; id: string;
pageNumber: number; pageNumber: number;
@@ -1496,6 +1560,10 @@ export interface PolicyOcrDocument {
extractedNetPremium: string | null; extractedNetPremium: string | null;
extractedPolicyFee: string | null; extractedPolicyFee: string | null;
extractedBrokerFee: string | null; extractedBrokerFee: string | null;
/** IVA off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
* premium, so no tax either. `LOCAL TAX` is not folded in; a non-zero one
* shows up in `matchNote`. */
extractedTax: string | null;
extractedTotal: string | null; extractedTotal: string | null;
extractedCoveragesJson: PolicyOcrCoverage[] | null; extractedCoveragesJson: PolicyOcrCoverage[] | null;
extractedPremiumPayment: string | null; extractedPremiumPayment: string | null;
@@ -1512,6 +1580,7 @@ export interface PolicyOcrDocument {
} | null; } | null;
matchedCustomer: { id: string; name: string } | null; matchedCustomer: { id: string; name: string } | null;
matchCandidates: PolicyOcrMatchCandidate[] | null; matchCandidates: PolicyOcrMatchCandidate[] | null;
customerSuggestions: PolicyOcrCustomerSuggestion[] | null;
matchNote: string | null; matchNote: string | null;
} }
@@ -1529,6 +1598,7 @@ export interface PolicyOcrReviewInput {
netPremium?: number; netPremium?: number;
policyFee?: number; policyFee?: number;
brokerFee?: number; brokerFee?: number;
tax?: number;
total?: number; total?: number;
premiumPayment?: string; premiumPayment?: string;
coveragePeriodDays?: number; coveragePeriodDays?: number;
@@ -1555,6 +1625,7 @@ export interface PolicyOcrConfirmDocument {
netPremium?: number; netPremium?: number;
policyFee?: number; policyFee?: number;
brokerFee?: number; brokerFee?: number;
tax?: number;
total?: number; total?: number;
premiumPayment?: string; premiumPayment?: string;
coveragePeriodDays?: number; coveragePeriodDays?: number;
+23
View File
@@ -179,6 +179,29 @@ Each of these is a known, deliberate stopping point rather than a bug.
be added. be added.
- No `SKIPPED_NO_EMAIL` worklist (see 1.9). - No `SKIPPED_NO_EMAIL` worklist (see 1.9).
**Captura de pólizas — desglose de primas** (built 2026-08-18)
- **IVA y prima total no existen en los datos legacy.** En Access eran
controles calculados sin campo, así que las 2,378 pólizas migradas leen
`tax` y `total` en null hasta que alguien las edite. No es recuperable: no
hay de dónde.
- **`LOCAL TAX` de A.N.A. no se captura.** El IVA (`TAX`) sí — se guarda desde
2026-08-18 — pero `LOCAL TAX` es un gravamen distinto sin columna destino y
**no** se suma al IVA: sumarlo daría una cifra que ya no divide de vuelta a
una tasa. Imprime 0.00 en todas las pólizas vistas hasta hoy; una distinta
de cero levanta la nota *"impuesto local N no capturado"* y significa que
`total` no cuadra contra `netPremium + policyFee + tax`.
- **`Policy.taxRate` queda en null por la ruta OCR.** A.N.A. imprime el monto
del IVA, no la tasa, y despejarla a la inversa inventaría una tasa que el
documento nunca declaró. El formulario resuelve una desde el ramo.
- **El recargo no se valida contra la forma de pago en datos migrados.** El
formulario lo deshabilita en ANUAL/CONTADO, pero
`backfill_policy_premium_breakdown.py` solo advierte cuando encuentra una
póliza anual con recargo; no la corrige.
- **Las parcialidades 3 y 4 no llevan desglose.** Access solo dibujó la fila
de dinero dos veces, así que una póliza trimestral capturada hoy sí puede
llenar las cuatro a mano, pero no hay nada legacy que migrar a las dos
últimas.
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md) **Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus - **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
a parser map, so a third carrier is one function and two entries — but no a parser map, so a third carrier is one function and two entries — but no
+6
View File
@@ -440,6 +440,12 @@ take a `policyType` select param. The workflow is *not* MULT-only: the legacy
Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`. Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`.
Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago. Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago.
️ `forma de pago` became a real column on 2026-08-18 (`Policy.paymentFrequency`).
It is **null on every policy migrated before that date** — the original ETL
marked Access's `FORMA PAGO` consumed and then never wrote it anywhere — so the
report must render null as "—" rather than assuming annual. Running
`backfill_policy_premium_breakdown.py` recovers it from the staged Parquet.
Totals: count + prima neta sum per currency (**never collapse MXN and USD** — Totals: count + prima neta sum per currency (**never collapse MXN and USD** —
same constraint as the billing module). same constraint as the billing module).
+81
View File
@@ -134,6 +134,55 @@ customers do occur (one group policy bound by two related parties), and
picking arbitrarily would silently book the wrong coverage against the wrong picking arbitrarily would silently book the wrong coverage against the wrong
person. person.
### Name suggestions on the zero-hit path
When the policy number finds nothing — the new-policy case, where a human has
to pick a customer anyway — `name-matcher.ts` ranks the customer book against
the printed insured name and the review screen offers the top three as
one-click buttons above the picker. They are written to
`policy_ocr_documents.customerSuggestions`, deliberately **not** to
`matchCandidates`, so a name hint can never be read as a policy-number hit.
Nothing sets `matchedCustomerId`; the rule above is unchanged.
The problem is only ordering: the office books customers surname-first
(`WAGONER, PAMELA`) and carriers print them given-name-first
(`PAMELA DENISE WAGONER`), so a string compare never hits while a **token-set**
compare does. Names are normalized (accents folded, so OCR's `MUNOZ` reaches
the book's `MUÑOZ`; initials, `DE`/`LA`/`Y`, `JR`, `S.A. DE C.V.` and any token
containing a digit dropped — ANA prints the phone hard against the name as
`Ph.3102001538`). Two tiers:
| Tier | Rule |
|---|---|
| `EXACT` | identical token sets, any order |
| `PARTIAL` | one set contains the other, ≥2 shared tokens, **and** the surname is present |
Both thresholds come from measuring the real book (1536 customers):
- 1487 distinct token sets, so `EXACT` cross-person collisions are ~0
- loosen to surname + first given name and 131 customers (8.5%) collide —
the book holds `MCWILLIAMS, BRIAN MICHAEL` *and* `MCWILLIAMS, BRIAN`
- 185 surnames are shared by 524 customers, so one token is never evidence;
hence the ≥2 floor and the explicit surname requirement, which is what stops
`JERRY MARILYN` reaching `ESTRADA, JERRY & MARILYN` on given names alone
Replaying every book row as a carrier would print it (given-name-first, joint
spouse dropped): 97.9% top-ranked correct, 0.9% no suggestion, 1.2% a
different row — and all but two of those are the same human on a duplicate or
variant row (`MOLNAR, JANOS` vs `MOLNAR, JANOS`, `IBARRA, ISMAEL &`). The
two genuine wrong-person cases are `CUADROS, JORGE JR` against three
`CUADROS, JORGE H.`, and they appear as a tie in the list rather than as a
single answer.
A blob is refused outright (>8 tokens or >80 characters): GMX's especificación
has no field labels and the parser has been seen handing its whole first page
over as `insuredName`, which would find a surname somewhere in the prose.
`(SIN NOMBRE)` — 14 rows the migration left — is skipped on both sides.
**Not used for utility statements.** There the registrant genuinely is not the
customer (the `CATT, RANDY` finding above), so the same trick would be wrong,
not merely noisy.
## GMX ships two unrelated documents for the same policy ## GMX ships two unrelated documents for the same policy
The office downloads both from the same portal, and either can land in a The office downloads both from the same portal, and either can land in a
@@ -316,6 +365,27 @@ A.N.A.'s faces do print one — the `DISCOUNT / PREMIUM / POLICY FEE / TAX /
LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the
review queue with `netPremium` populated and `postPremium` already ticked. review queue with `netPremium` populated and `postPremium` already ticked.
Four of those six cells are stored: `PREMIUM``netPremium`, `POLICY FEE`
`policyFee`, `TAX``tax` (`extractedTax` on the document, `Policy.tax` on
confirm), `TOTAL``total`. `DISCOUNT` and `LOCAL TAX` are reported as notes
instead:
- **`DISCOUNT`** has no column, and it prints as a bare `-` when unused, which
is what makes the row positional rather than "find six amounts".
- **`LOCAL TAX`** is a separate levy and is deliberately **not** summed into
`tax`. Folding it in would produce an IVA figure that no longer divides back
to a rate, which is the reason to store it at all. It reads 0.00 on every
A.N.A. policy seen so far; a non-zero one raises
*"impuesto local N no capturado"* and means `total` will not reconcile
against `netPremium + policyFee + tax`.
`Policy.taxRate` is left null by confirm. A.N.A. prints the IVA **amount**, not
the rate, and back-dividing one would mint a rate the document never stated —
the capture form resolves it from the line of business instead
(`PolicyType.taxRate`, see `apps/api/src/policies/premium.ts`). The figures do
agree: 298.61 + 30.00 taxed at 8% is 26.29, totalling 354.90, asserted in
`policy-parser.spec.ts`.
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`, Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts `"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
and free text, and normalising them would lose the distinction. and free text, and normalising them would lose the distinction.
@@ -457,6 +527,17 @@ term, the excluded sections, the column-position split on the driver's policy,
its different section order, and — for both faces — that a doubled or tripled its different section order, and — for both faces — that a doubled or tripled
input yields one set of coverages and one driver rather than one per copy. input yields one set of coverages and one driver rather than one per copy.
`apps/api/src/policy-ocr/name-matcher.spec.ts` — 21 cases on the customer name
suggestions, every fixture name lifted from the real book: the reversed name,
the printed middle name, the exact row outranking the row that merely contains
it, the joint account reached from one spouse (and refused when only given
names are printed), the Spanish double surname with the comma in either place,
the 54 rows with no comma at all, the `(SIN NOMBRE)` placeholder, a bare shared
surname, and the page-sized blob. Four more in
`policy-matcher.service.spec.ts` pin the wiring: suggestions on the zero-hit
and unreadable-number paths, no book read at all when the policy number hits,
and one book read across a batch.
Four of the GMX cases are regression tests for ways the parser can silently attach Four of the GMX cases are regression tests for ways the parser can silently attach
the *wrong* value rather than none — a neighbouring coverage's prose read as the *wrong* value rather than none — a neighbouring coverage's prose read as
a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage a deductible, the page-level `DEDUCIBLES:` paragraph read as one, a coverage
@@ -0,0 +1,249 @@
"""
Recovers the premium breakdown the original policy transform dropped.
`transform_policies.py` modeled prima neta, derecho de póliza and comisión and
nothing else, which lost two things from every migrated policy:
1. RECARGO — the financing surcharge on a policy paid in more than one
exhibición, plus the whole second money row (`p_neta_2`, `recargo_2`,
`d_pol_2`, `com_2`) that a semestral policy carries because each payment
is priced separately. These were not deleted, they were swept into
`policies.coveragesJson` as loose strings alongside the real coverages —
unqueryable, and mislabeled as coverage amounts.
2. FORMA PAGO — dropped outright. The column was marked "consumed" by the
transform's coverage sweep but never written to any column, so it exists
nowhere in the platform database. It is the field that decides whether a
recargo is legitimate on a row at all, so it cannot be inferred back from
the money.
The transform has been fixed in the same commit, so a full `run_all.py` now
produces all of this directly. This script exists for a database that must not
be re-imported: it reads the same staged Parquet and patches in place.
What it does NOT do: invent IVA or the printed TOTAL. Those were never columns
in the home tables — they were unbound calculated controls on the Access form —
so there is genuinely nothing to recover, and both stay null until a human
edits the policy. The app computes them (apps/api/src/policies/premium.ts).
Idempotent, and never overwrites a non-null value: a figure a human has since
corrected in the app wins over the legacy one.
./.venv/bin/python backfill_policy_premium_breakdown.py --env dev
"""
from __future__ import annotations
import json
import sys
from decimal import Decimal, InvalidOperation
from pathlib import Path
import pandas as pd
from dbenv import connect
from sync import parse_mode
STG = Path(__file__).parent / "output" / "stg_seguros"
LEGACY_DB = "SEGUROS 16_be"
NULL = ""
# Legacy column -> what it is, per source table. Only the tables that actually
# carry a breakdown appear; the auto tables have no recargo and no second row.
HOME_TABLES = ("mult", "incendio", "m_empr")
# Keys the transform used to dump into coveragesJson that are now real columns.
# Stripped once migrated so the blob stops pretending they are coverages.
MIGRATED_COVERAGE_KEYS = (
"recargo", "recargo_2", "p_neta_2", "d_pol_2", "com_2",
)
_FREQ = {
"ANNUAL": "ANNUAL",
"ANUAL": "ANNUAL",
"SEMESTRAL": "SEMIANNUAL",
"TRIMESTRAL": "QUARTERLY",
"MENSUAL": "MONTHLY",
"CONTADO": "SINGLE",
}
def s(v):
if v is None or pd.isna(v):
return None
v = str(v).strip()
return None if v in ("", NULL) else v
def dec(v):
v = s(v)
if v is None:
return None
try:
return Decimal(v.replace(",", ""))
except (InvalidOperation, ValueError):
return None
def freq(v):
return _FREQ.get((s(v) or "").upper())
def load(name):
df = pd.read_parquet(STG / f"{name}.parquet").sort_values("_row_num").reset_index(drop=True)
for c in df.columns:
if c != "_row_num":
df[c] = df[c].astype("string").str.strip()
return df
def main():
env, _sync_mode = parse_mode()
# Fails closed rather than reporting a clean run over nothing: an empty
# staging directory and a policy set with no recargo look identical from
# the database side, and "0 rows updated" would read as success.
if not STG.exists():
print(f"[policy-premium] staged Parquet missing at {STG} — run extract/load first.")
return 3
conn = connect(env)
c = conn.cursor()
print(f"[policy-premium] target env: {env}")
# Every policy that came from the insurance ETL, keyed by provenance. The
# id is needed to reach the installments, coveragesJson to strip the keys.
c.execute(
"SELECT legacySourceTable, legacyId, id, coveragesJson "
"FROM policies WHERE legacySourceDb = %s AND legacyId IS NOT NULL",
(LEGACY_DB,),
)
by_key = {(t, lid): (pid, cov) for t, lid, pid, cov in c.fetchall()}
print(f" {len(by_key)} migrated polic(ies) in the target database")
pol_updates = [] # (surcharge, paymentFrequency, coveragesJson, policyId)
inst_updates = [] # (netPremium, surcharge, policyFee, commission, policyId, seq)
seen_tables = 0
for table in HOME_TABLES + (
"tabla_autos", "tabla_autos_ampl", "tabla_autos_limit",
"tabla_autos_ampl_r", "tabla_autos_rc_r", "mca2", "licencias",
):
path = STG / f"{table}.parquet"
if not path.exists():
continue
seen_tables += 1
df = load(table)
home = table in HOME_TABLES
for _, row in df.iterrows():
key = (table, str(int(row["_row_num"])))
hit = by_key.get(key)
if not hit:
continue
pid, cov_raw = hit
surcharge = dec(row.get("recargo")) if home else None
frequency = freq(row.get("forma_pago"))
# Strip the now-modeled keys out of the coverage blob. Rewritten
# only when something actually changes, so a policy whose blob a
# human has edited is left byte-identical.
cov_new = None
if cov_raw:
try:
cov = json.loads(cov_raw) if isinstance(cov_raw, str) else cov_raw
except (TypeError, ValueError):
cov = None
if isinstance(cov, dict):
kept = {k: v for k, v in cov.items() if k not in MIGRATED_COVERAGE_KEYS}
if len(kept) != len(cov):
cov_new = json.dumps(kept, ensure_ascii=False) if kept else None
if surcharge is not None or frequency is not None or cov_new is not None:
pol_updates.append((surcharge, frequency, cov_new, cov_new is not None, pid))
# Per-payment breakdown. Slot 1 is the unsuffixed money row, slot 2
# the _2 twin; the auto tables have a single slot and no recargo.
if home:
slots = [
(1, "p_neta", "recargo", "d_pol", "com"),
(2, "p_neta_2", "recargo_2", "d_pol_2", "com_2"),
]
else:
pn = "prima1" if table == "mca2" else "prima_neta"
dp = "d_poliza1" if table == "mca2" else "d_poliza"
slots = [(1, pn, None, dp, None)]
for seq, pn, rc, dp, cm in slots:
vals = (
dec(row.get(pn)) if pn else None,
dec(row.get(rc)) if rc else None,
dec(row.get(dp)) if dp else None,
dec(row.get(cm)) if cm else None,
)
if all(v is None for v in vals):
continue
inst_updates.append((*vals, pid, seq))
if not seen_tables:
print(f"[policy-premium] no policy tables staged under {STG} — nothing to do.")
return 3
# COALESCE on every target: a column a human has already filled in the app
# keeps its value, the legacy figure only lands where there is a hole.
for surcharge, frequency, cov_new, rewrite_cov, pid in pol_updates:
c.execute(
"UPDATE policies SET "
" surcharge = COALESCE(surcharge, %s), "
" paymentFrequency = COALESCE(paymentFrequency, %s), "
" coveragesJson = IF(%s, %s, coveragesJson) "
"WHERE id = %s",
(surcharge, frequency, 1 if rewrite_cov else 0, cov_new, pid),
)
for netp, surch, fee, comm, pid, seq in inst_updates:
c.execute(
"UPDATE policy_payment_installments SET "
" netPremium = COALESCE(netPremium, %s), "
" surcharge = COALESCE(surcharge, %s), "
" policyFee = COALESCE(policyFee, %s), "
" commission = COALESCE(commission, %s) "
"WHERE policyId = %s AND sequence = %s",
(netp, surch, fee, comm, pid, seq),
)
conn.commit()
print(f" policies : {len(pol_updates)} row(s) touched")
print(f" installments: {len(inst_updates)} row(s) touched")
# --- validation ---------------------------------------------------------
c.execute("SELECT COUNT(*) FROM policies WHERE surcharge IS NOT NULL AND surcharge <> 0")
n_surch = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM policies WHERE paymentFrequency IS NOT NULL")
n_freq = c.fetchone()[0]
c.execute(
"SELECT COUNT(*) FROM policies "
"WHERE paymentFrequency IN ('ANNUAL','SINGLE') AND surcharge IS NOT NULL AND surcharge <> 0"
)
n_bad = c.fetchone()[0]
c.execute(
"SELECT COUNT(*) FROM policies WHERE coveragesJson IS NOT NULL "
"AND JSON_EXTRACT(coveragesJson, '$.recargo') IS NOT NULL"
)
n_left = c.fetchone()[0]
print(f" -> policies with a recargo : {n_surch}")
print(f" -> policies with a forma pago : {n_freq}")
print(f" -> recargo still in coverages : {n_left}")
# A surcharge on an annual policy contradicts the rule the capture form
# enforces, so it is worth surfacing rather than leaving for someone to
# find in a total. It is a warning, not a failure: the books are the books.
if n_bad:
print(f" !! {n_bad} annual/contado polic(ies) carry a non-zero recargo — review by hand")
return 0
if __name__ == "__main__":
sys.exit(main() or 0)
+12
View File
@@ -59,6 +59,12 @@ STEPS = [
# touches. # touches.
"backfill_statement_match_fields.py", "backfill_statement_match_fields.py",
"transform_policies.py", "transform_policies.py",
# Premium breakdown (recargo, per-payment figures, forma de pago).
# transform_policies.py now writes these directly, so on a full rebuild
# this is a no-op that re-asserts them; on a database migrated before the
# breakdown existed it is what recovers them out of coveragesJson.
# Must follow transform_policies.py, which truncates the installments.
"backfill_policy_premium_breakdown.py",
"transform_transactions.py", "transform_transactions.py",
"prune_empty_customers.py", "prune_empty_customers.py",
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into; # Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
@@ -78,6 +84,12 @@ SYNC_STEPS = [
# touches. # touches.
"backfill_statement_match_fields.py", "backfill_statement_match_fields.py",
"transform_policies.py", "transform_policies.py",
# Premium breakdown (recargo, per-payment figures, forma de pago).
# transform_policies.py now writes these directly, so on a full rebuild
# this is a no-op that re-asserts them; on a database migrated before the
# breakdown existed it is what recovers them out of coveragesJson.
# Must follow transform_policies.py, which truncates the installments.
"backfill_policy_premium_breakdown.py",
"transform_transactions.py", "transform_transactions.py",
# Manual-safe prune: drops legacy-owned empties that the customer upsert # Manual-safe prune: drops legacy-owned empties that the customer upsert
# re-creates from Parquet, but leaves manually-added customers alone. # re-creates from Parquet, but leaves manually-added customers alone.
+72 -12
View File
@@ -18,6 +18,17 @@ Design (validated against staged data):
policies are skipped and counted (required FK). policies are skipped and counted (required FK).
- Payment slots: c_1er_pago is the first amount, pago_subsec the recurring - Payment slots: c_1er_pago is the first amount, pago_subsec the recurring
amount for slots 2-4; efectivo is a cash flag, no_cheque the check ref. amount for slots 2-4; efectivo is a cash flag, no_cheque the check ref.
- Premium breakdown: a policy paid in more than one exhibicion prices EACH
payment separately, which is why the home tables carry the whole money row
twice (p_neta/recargo/d_pol/com and their _2 twins). The unsuffixed set is
the policy header, the suffixed one belongs to payment 2, and both are
written per installment as well. This used to be lost: `recargo` and the
_2 columns fell into coveragesJson as loose strings and forma_pago was
marked consumed but never written anywhere at all.
- IVA and the printed TOTAL are NOT in Access for the home tables. They were
unbound calculated controls on the form, so there is nothing to migrate;
the app computes them (apps/api/src/policies/premium.ts) from
(p_neta + recargo + d_pol) * rate.
- Any source column not explicitly modeled (coverage amounts: edificio, - Any source column not explicitly modeled (coverage amounts: edificio,
contenidos, robo, cristales, ...) is preserved verbatim in coveragesJson, contenidos, robo, cristales, ...) is preserved verbatim in coveragesJson,
so nothing is lost in consolidation. so nothing is lost in consolidation.
@@ -93,20 +104,55 @@ def truthy(v):
return (s(v) or "0").lower() in {"1", "-1", "true", "si", "", "yes", "x"} return (s(v) or "0").lower() in {"1", "-1", "true", "si", "", "yes", "x"}
# Access FORMA PAGO -> PaymentFrequency. The whole staged corpus holds exactly
# four spellings (ANNUAL 1851, SEMESTRAL 29, semestral 2, CONTADO 1); anything
# else is left null rather than guessed, because the value decides whether a
# recargo is legitimate on the row.
_FREQ = {
"ANNUAL": "ANNUAL",
"ANUAL": "ANNUAL",
"SEMESTRAL": "SEMIANNUAL",
"TRIMESTRAL": "QUARTERLY",
"MENSUAL": "MONTHLY",
"CONTADO": "SINGLE",
}
def freq(v):
return _FREQ.get((s(v) or "").upper())
def slot_dec(row, slot, key):
"""One figure of a payment's premium breakdown, or None when the source
table has no such column. Deliberately not zero: a zero d_pol on the second
payment is a real figure in the books and must stay distinguishable from
'this table never had that column'."""
col = slot.get(key)
return dec(row.get(col)) if col else None
# --- per-table config ------------------------------------------------------- # # --- per-table config ------------------------------------------------------- #
# fields: policy column -> source column. installments: list of slot dicts. # fields: policy column -> source column. installments: list of slot dicts.
# vehicles: 'trip_underscore' | 'single' | 'mca2' | None. drivers: 'mca2' | # vehicles: 'trip_underscore' | 'single' | 'mca2' | None. drivers: 'mca2' |
# 'licencias' | None. # 'licencias' | None.
# `pneta`/`recarg`/`dpol`/`com` on a slot are that payment's own share of the
# premium. Only the first two payments have one in Access — the form only ever
# drew the money row twice — so slots 3 and 4 carry none and keep just the
# amount actually collected.
HOME_INST = [ HOME_INST = [
dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo"), dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo",
dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2"), pneta="p_neta", recarg="recargo", dpol="d_pol", com="com"),
dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2",
pneta="p_neta_2", recarg="recargo_2", dpol="d_pol_2", com="com_2"),
dict(seq=3, amt="pago_subsec", cu="moned_3", d="fecha_pago_3", ck="no_cheque_3", cash="efectivo_3"), dict(seq=3, amt="pago_subsec", cu="moned_3", d="fecha_pago_3", ck="no_cheque_3", cash="efectivo_3"),
dict(seq=4, amt="pago_subsec", cu="moned_4", d="fecha_pago_4", ck="no_cheque_4", cash="efectivo_4"), dict(seq=4, amt="pago_subsec", cu="moned_4", d="fecha_pago_4", ck="no_cheque_4", cash="efectivo_4"),
] ]
HOME_FIELDS = dict(polno="no_poliza", agent="agent", comp="comp", desde="desde", hasta="hasta", HOME_FIELDS = dict(polno="no_poliza", agent="agent", comp="comp", desde="desde", hasta="hasta",
forma="forma_pago", curcol="moned", pneta="p_neta", dpol="d_pol", com="com", forma="forma_pago", curcol="moned", pneta="p_neta", recarg="recargo",
dpol="d_pol", com="com",
liquidada="liquidada", numliq="num_liquidacion", fliq="f_liquida1", renov="renovacion") liquidada="liquidada", numliq="num_liquidacion", fliq="f_liquida1", renov="renovacion")
AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")] AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo",
pneta="prima_neta", dpol="d_poliza")]
CONFIGS = { CONFIGS = {
"incendio": dict(ptype="INCENDIO", idcol="num_id", fields={**HOME_FIELDS, "curcol": "moneda"}, "incendio": dict(ptype="INCENDIO", idcol="num_id", fields={**HOME_FIELDS, "curcol": "moneda"},
@@ -157,7 +203,8 @@ CONFIGS = {
forma="forma_pago", curcol="moneda", pneta="prima_neta", dpol="d_poliza", forma="forma_pago", curcol="moneda", pneta="prima_neta", dpol="d_poliza",
total="total", liquidada="liquidada", numliq="num_liquidacion", total="total", liquidada="liquidada", numliq="num_liquidacion",
fliq="f_liquida1", renov="renovacion"), fliq="f_liquida1", renov="renovacion"),
inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")], inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque",
cash="efectivo", pneta="prima_neta", dpol="d_poliza")],
veh=None, drv="licencias"), veh=None, drv="licencias"),
} }
@@ -200,6 +247,10 @@ def main():
consumed = {cfg["idcol"], *F.values()} consumed = {cfg["idcol"], *F.values()}
for slot in cfg["inst"]: for slot in cfg["inst"]:
consumed |= {slot["amt"], slot["cu"], slot["d"], slot["ck"], slot["cash"]} consumed |= {slot["amt"], slot["cu"], slot["d"], slot["ck"], slot["cash"]}
# The per-payment premium columns are now modeled, so they must
# leave the coveragesJson sweep — otherwise every recargo would be
# written twice, once as a column and once as a fake coverage.
consumed |= {slot[k] for k in ("pneta", "recarg", "dpol", "com") if slot.get(k)}
for _, row in df.iterrows(): for _, row in df.iterrows():
cid = cust.get(norm_id(row[cfg["idcol"]])) cid = cust.get(norm_id(row[cfg["idcol"]]))
@@ -237,9 +288,11 @@ def main():
dt(row.get(F.get("desde", ""))) if F.get("desde") else None, dt(row.get(F.get("desde", ""))) if F.get("desde") else None,
dt(row.get(F.get("hasta", ""))) if F.get("hasta") else None, dt(row.get(F.get("hasta", ""))) if F.get("hasta") else None,
dec(row.get(F.get("pneta", ""))) if F.get("pneta") else None, dec(row.get(F.get("pneta", ""))) if F.get("pneta") else None,
dec(row.get(F.get("recarg", ""))) if F.get("recarg") else None,
dec(row.get(F.get("dpol", ""))) if F.get("dpol") else None, dec(row.get(F.get("dpol", ""))) if F.get("dpol") else None,
dec(row.get(F.get("com", ""))) if F.get("com") else None, dec(row.get(F.get("com", ""))) if F.get("com") else None,
dec(row.get(F.get("total", ""))) if F.get("total") else None, dec(row.get(F.get("total", ""))) if F.get("total") else None,
freq(row.get(F.get("forma", ""))) if F.get("forma") else None,
cur(row.get(F.get("curcol", ""))) if F.get("curcol") else "MXN", cur(row.get(F.get("curcol", ""))) if F.get("curcol") else "MXN",
s(row.get("observaciones")), s(row.get("observaciones")),
json.dumps(cov, ensure_ascii=False) if cov else None, json.dumps(cov, ensure_ascii=False) if cov else None,
@@ -255,9 +308,12 @@ def main():
pdate = dt(row.get(slot["d"])) pdate = dt(row.get(slot["d"]))
if amt is None and pdate is None: if amt is None and pdate is None:
continue continue
# Slot breakdown, where the source table has one.
insts.append((str(uuid.uuid4()), pid, slot["seq"], amt, insts.append((str(uuid.uuid4()), pid, slot["seq"], amt,
cur(row.get(slot["cu"])), pdate, s(row.get(slot["ck"])), cur(row.get(slot["cu"])), pdate, s(row.get(slot["ck"])),
1 if truthy(row.get(slot["cash"])) else 0)) 1 if truthy(row.get(slot["cash"])) else 0,
slot_dec(row, slot, "pneta"), slot_dec(row, slot, "recarg"),
slot_dec(row, slot, "dpol"), slot_dec(row, slot, "com")))
# vehicles # vehicles
def add_vehicle(make, model, body, engine, plate, year=None, state=None): def add_vehicle(make, model, body, engine, plate, year=None, state=None):
@@ -328,16 +384,19 @@ def main():
1 if truthy(r["concluido"]) else 0, s(r["resolucion"]))) 1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate," pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
"policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations," "policyFrom,policyTo,netPremium,surcharge,policyFee,commission,total,paymentFrequency,"
"currency,observations,"
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb," "coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
"legacySourceTable,legacyId,updatedAt") "legacySourceTable,legacyId,updatedAt")
ph = ",".join(["%s"] * 23) ph = ",".join(["%s"] * 25)
pol_upsert = ( pol_upsert = (
f"INSERT INTO policies ({pol_cols}) VALUES ({ph}) ON DUPLICATE KEY UPDATE " f"INSERT INTO policies ({pol_cols}) VALUES ({ph}) ON DUPLICATE KEY UPDATE "
"customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId)," "customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),"
"insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate)," "insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),"
"policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee)," "policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),"
"commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations)," "surcharge=VALUES(surcharge),policyFee=VALUES(policyFee),"
"commission=VALUES(commission),total=VALUES(total),paymentFrequency=VALUES(paymentFrequency),"
"currency=VALUES(currency),observations=VALUES(observations),"
"coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber)," "coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),"
"liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL") "liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL")
@@ -399,8 +458,9 @@ def main():
c.executemany("INSERT INTO policy_payment_installments " c.executemany("INSERT INTO policy_payment_installments "
"(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) " "(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash,"
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", insts) "netPremium,surcharge,policyFee,commission) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", insts)
c.executemany("INSERT INTO vehicles (id,customerId,policyId,make,model,modelYear,bodyType," c.executemany("INSERT INTO vehicles (id,customerId,policyId,make,model,modelYear,bodyType,"
"engineNumber,licensePlate,stateCode,legacySourceTable,legacyId) " "engineNumber,licensePlate,stateCode,legacySourceTable,legacyId) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", vehicles) "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", vehicles)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jorgecuadros-platform", "name": "jorgecuadros-platform",
"version": "1.0.20", "version": "1.0.23",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@jorgecuadros/database", "name": "@jorgecuadros/database",
"version": "1.0.20", "version": "1.0.23",
"private": true, "private": true,
"main": "generated/client/index.js", "main": "generated/client/index.js",
"types": "generated/client/index.d.ts", "types": "generated/client/index.d.ts",
@@ -0,0 +1,7 @@
-- Ranked customers whose name matches the printed insured name, for the
-- documents whose policy number found nothing and therefore need a customer
-- picked by hand. Kept in its own column rather than folded into
-- `matchCandidates`, which the review screen reads as policy-number hits —
-- a name is a suggestion and must never be able to masquerade as a match.
ALTER TABLE `policy_ocr_documents`
ADD COLUMN `customerSuggestions` JSON NULL;
@@ -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;
@@ -0,0 +1,9 @@
-- A.N.A. prints IVA on the policy face and the parser already read it, but
-- `ParsedPolicy` had no field for it, so the figure only ever reached a review
-- note and the confirmed Policy was written with `tax` null. This gives it a
-- column, matching the premium fields beside it.
--
-- GMX stays null: its certificate carries no premium at all, so there is no
-- tax on it to read either.
ALTER TABLE `policy_ocr_documents`
ADD COLUMN `extractedTax` DECIMAL(12, 2) NULL;
+104 -32
View File
@@ -158,10 +158,31 @@ model InsuranceProvider {
@@map("insurance_providers") @@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 { model PolicyType {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
shortDescription String? 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[] policies Policy[]
@@map("policy_types") @@map("policy_types")
@@ -185,10 +206,32 @@ model Policy {
policyTo DateTime? policyTo DateTime?
coveragePeriodDays Int? @default(365) coveragePeriodDays Int? @default(365)
netPremium Decimal? @db.Decimal(12, 2) 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) policyFee Decimal? @db.Decimal(12, 2)
brokerFee Decimal? @db.Decimal(12, 2) brokerFee Decimal? @db.Decimal(12, 2)
commission 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) 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) currency Currency @default(MXN)
observations String? @db.Text observations String? @db.Text
notes String? @db.Text notes String? @db.Text
@@ -267,6 +310,23 @@ model PolicyPaymentInstallment {
checkNumber String? checkNumber String?
isCash Boolean @default(false) 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") @@map("policy_payment_installments")
} }
@@ -430,25 +490,30 @@ model PolicyOcrDocument {
provider String? provider String?
// Extracted header fields, all staff-editable in review. // Extracted header fields, all staff-editable in review.
extractedPolicyNumber String? extractedPolicyNumber String?
extractedInsuredName String? extractedInsuredName String?
extractedAdditionalInsured String? extractedAdditionalInsured String?
extractedAgentName String? extractedAgentName String?
extractedLegalAddress String? @db.Text extractedLegalAddress String? @db.Text
extractedZip String? extractedZip String?
extractedPolicyFrom DateTime? extractedPolicyFrom DateTime?
extractedPolicyTo DateTime? extractedPolicyTo DateTime?
extractedPolicyDate DateTime? extractedPolicyDate DateTime?
extractedCurrency String? extractedCurrency String?
extractedNetPremium Decimal? @db.Decimal(12, 2) extractedNetPremium Decimal? @db.Decimal(12, 2)
extractedPolicyFee Decimal? @db.Decimal(12, 2) extractedPolicyFee Decimal? @db.Decimal(12, 2)
extractedBrokerFee Decimal? @db.Decimal(12, 2) extractedBrokerFee Decimal? @db.Decimal(12, 2)
extractedTotal Decimal? @db.Decimal(12, 2) /// IVA off A.N.A.'s `TAX` cell. Null on GMX, whose certificate carries no
/// premium at all. The adjacent `LOCAL TAX` is a separate levy with no
/// column of its own and is NOT summed in — it would make the figure stop
/// dividing back to a rate; the parser reports a non-zero one as a note.
extractedTax Decimal? @db.Decimal(12, 2)
extractedTotal Decimal? @db.Decimal(12, 2)
/// Per-coverage rows from the GMX "Material damages" / "Additional risk" /// Per-coverage rows from the GMX "Material damages" / "Additional risk"
/// tables and ANA's numbered risk sections — preserved verbatim so a /// tables and ANA's numbered risk sections — preserved verbatim so a
/// missing premium receipt still leaves the coverages auditable. /// missing premium receipt still leaves the coverages auditable.
extractedCoveragesJson Json? extractedCoveragesJson Json?
extractedPremiumPayment String? extractedPremiumPayment String?
/// Printed term length. ANA sells 3- and 4-day tourist policies, so /// Printed term length. ANA sells 3- and 4-day tourist policies, so
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate /// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
/// a weekend policy by a year. /// a weekend policy by a year.
@@ -456,31 +521,38 @@ model PolicyOcrDocument {
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table. /// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
/// Written to `Vehicle` rows on confirm; kept here so the review screen /// Written to `Vehicle` rows on confirm; kept here so the review screen
/// shows what was read before anything is applied. /// shows what was read before anything is applied.
extractedVehiclesJson Json? extractedVehiclesJson Json?
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered /// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver` /// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
/// rows on confirm. /// rows on confirm.
extractedDriversJson Json? extractedDriversJson Json?
/// The `PolicyType.name` the parser read the product as ("AUTO", /// The `PolicyType.name` the parser read the product as ("AUTO",
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the /// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
/// database, so confirm resolves it against `policy_types` and leaves /// database, so confirm resolves it against `policy_types` and leaves
/// `Policy.policyTypeId` null if there is no such row. /// `Policy.policyTypeId` null if there is no such row.
extractedPolicyTypeName String? extractedPolicyTypeName String?
// Match by `Policy.policyNumber` → existing Policy / Customer. // Match by `Policy.policyNumber` → existing Policy / Customer.
matchedPolicyId String? matchedPolicyId String?
matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id]) matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
matchedCustomerId String? matchedCustomerId String?
matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id]) matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
/// All policies carrying the same number, with their customer. One is /// All policies carrying the same number, with their customer. One is
/// normal; >1 means the policy number is shared across customers and a /// normal; >1 means the policy number is shared across customers and a
/// human must pick. /// 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
/// name hint for a policy-number hit, and it never sets
/// `matchedCustomerId`. Only populated when the policy number found
/// nothing, which is exactly when staff have to pick a customer by hand.
customerSuggestions Json?
/// Text, not VARCHAR(191): this carries the parser's whole note trail, and /// Text, not VARCHAR(191): this carries the parser's whole note trail, and
/// a multi-section ANA policy runs past 191 characters routinely. Silently /// a multi-section ANA policy runs past 191 characters routinely. Silently
/// truncating it drops the tail notes, which are the ones that say what /// truncating it drops the tail notes, which are the ones that say what
/// could NOT be read. /// could NOT be read.
matchNote String? @db.Text matchNote String? @db.Text
reviewedById String? reviewedById String?
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id]) reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
@@ -1014,8 +1086,8 @@ enum EmailNotificationStatus {
/// we store it always, so a customer reply quoting an old email can be traced /// we store it always, so a customer reply quoting an old email can be traced
/// to the exact letter that was sent. /// to the exact letter that was sent.
model EmailNotificationLog { model EmailNotificationLog {
id String @id @default(uuid()) id String @id @default(uuid())
sendDate DateTime @default(now()) sendDate DateTime @default(now())
notificationType EmailNotificationType notificationType EmailNotificationType
/// Per-type discriminator, null where the type has none: /// Per-type discriminator, null where the type has none:
/// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO") /// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO")
@@ -1033,7 +1105,7 @@ model EmailNotificationLog {
/// resolve the owner through `Property.customerId`, so this stays set on /// resolve the owner through `Property.customerId`, so this stays set on
/// job 4 too. Null only on skipped rows where the lookup itself failed. /// job 4 too. Null only on skipped rows where the lookup itself failed.
customerId String? customerId String?
customer Customer? @relation(fields: [customerId], references: [id]) customer Customer? @relation(fields: [customerId], references: [id])
customerName String customerName String
customerEmail String customerEmail String
/// Subject line of the email we attempted to send. /// Subject line of the email we attempted to send.
@@ -1041,16 +1113,16 @@ model EmailNotificationLog {
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and /// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
/// fetched (kept verbatim so the legacy format is reproducible). Null on /// fetched (kept verbatim so the legacy format is reproducible). Null on
/// the other three jobs — the body is built inline. /// 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 /// 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 /// rows). Stored verbatim so audit/customer-service can read the exact
/// letter that went out without re-running the render. /// 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 /// True when `debug` was passed — the recipient was overridden to the
/// admin address and no real customer received the mail. Kept here so a /// 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 /// "where did all these emails go" investigation finds the answer in one
/// place instead of "who ran what with what flags" archaeology. /// 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 /// SES SendEmail MessageId, when we actually got one back. Null on
/// failures, skipped rows, and dev/mock transport. /// failures, skipped rows, and dev/mock transport.
providerMessageId String? providerMessageId String?
@@ -1058,7 +1130,7 @@ model EmailNotificationLog {
/// insert so a verbose SES bounce payload can't blow the column. /// insert so a verbose SES bounce payload can't blow the column.
providerResponse String? providerResponse String?
status EmailNotificationStatus status EmailNotificationStatus
error String? @db.Text error String? @db.Text
@@index([sendDate]) @@index([sendDate])
@@index([notificationType, sendDate]) @@index([notificationType, sendDate])