Files
jorgecuadros-platform/docs/INSURANCE_FEATURES_SPEC.md
T
rmancinasandClaude Opus 5 48e01ddd21
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m19s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s
feat(policies): capture the full premium breakdown
The capture form only ever had prima neta, derecho de póliza and comisión.
The Access form it replaces has seven figures, and the four that were missing
are the ones that make a policy paid in installments add up.

Adds recargo, IVA, prima total and forma de pago to the policy header, the
same breakdown per installment, and a per-line-of-business IVA rate.

IVA and prima total are the only derived figures:

    base  = prima neta + recargo + derecho de póliza
    IVA   = round(base * tasa)
    total = base + IVA

The recargo is inside the taxable base. That is not a guess — policy 7006785
prints IVA 52.03 on 610.86 + 8.55 + 31.00, and leaving the recargo out gives
51.35, which matches nothing on the page. Both of its money rows are asserted
in premium.spec.ts. The recargo itself is never derived: the carrier quotes it,
so staff key it in, and the field is disabled on ANNUAL/SINGLE. Both derived
figures are stored rather than recomputed on read, and stay editable, because
the printed policy is the record of truth and a later rate change must not
silently restate what was issued.

The rate lives on PolicyType (seeded to 0.08, editable in Catálogos), which is
the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made configurable. The
rate applied is stamped on the policy so an old one reads back at its original
rate.

Per-installment, not two fixed slots on the header: a policy split into several
exhibiciones prices each payment separately — that is why the Access form drew
the money row twice — and a trimestral policy needs four, which the Access
layout could not hold.

Also fixes two losses in the ETL, which is how these went missing:

  - `forma_pago` was marked consumed by the coverage sweep and then never
    written to any column, so FORMA PAGO existed nowhere in the platform.
  - `recargo` and the whole second money row fell into `coveragesJson` as
    loose strings, mislabeled as coverage amounts.

transform_policies.py now writes all of it directly;
backfill_policy_premium_breakdown.py recovers it on a database that must not be
re-imported, and strips the migrated keys back out of coveragesJson. Both are
COALESCE-only, so a figure a human has corrected in the app wins.

IVA and TOTAL are NOT backfilled: they were unbound calculated controls on the
Access form, never columns, so there is nothing to recover and every migrated
policy reads null until it is edited.

The backfill warns on 5 annual policies that carry a non-zero recargo — a
contradiction that predates this change and is left for a human, not silently
corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 00:24:22 -07:00

42 KiB
Raw Blame History

Insurance Features — Implementation Spec

Source: Jorge Cuadros meeting notes, 2026-07-25/26 (Seguros section), plus a read-through of the current policies/, reports/, storage/ and auth/ code and a live query of the dev database. This is a forward spec for work not yet built — contrast with RENEWAL_NOTICES.md, which documents the legacy renewal-report chain that has already been migrated into the aviso-renovacion report.

Companion doc: RECEIPT_CAPTURE_SPEC.md covers the Utility Management half of the same meeting (PLAN.md step 11). This doc is the insurance half (PLAN.md step 12).

A fifth insurance feature exists that this spec never proposed. POLICY_OCR.md — OCR capture of carrier policy PDFs into Policy rows, built 2026-08-01. It came out of the utility statement OCR work in RECEIPT_CAPTURE_SPEC.md §2, not from this meeting, which is why it is documented on its own rather than folded in here. It is relevant to §4: an OCR path that turns a carrier PDF into a Policy row already covers part of what a carrier API was wanted for, and unlike the API it is not blocked on Grupo Valore returning a phone call.

Why these four features are one spec

The meeting produced four insurance asks. They are specified together because they share a spine — the Policy record and its expiry/settlement lifecycle:

  1. Renewal notification emails — automates the outbound half of a policy's expiry (30 days before, 15 days before, 7 days after). The report that produces the letter text already exists; nothing sends it.
  2. Liquidación batch workflow — the settlement half of the same lifecycle. The per-policy fields are wired end to end; only the batch print-and-mark step is missing.
  3. Certificate / "Solicitud Atlas" — a customer-facing artifact rendered from the same policy record, delivered through the existing PHP portal.
  4. Carrier API integration — an inbound path that would populate the same Policy rows automatically instead of by hand.

1 and 2 are small additions on top of shipped code. 3 is half-buildable and half-blocked on infrastructure. 4 is fully blocked on vendor information.

Two of the four are much smaller than they sound, and the spec says so up front so nobody re-estimates them as greenfield work: §1 needs a scheduler, a mail client and one mutation — the notice table, its idempotency key, and the letter body all exist. §2 needs one report and one endpoint.


Ground truth (verified 2026-07-27, do not re-derive)

Everything below was checked against the code and the dev DB (192.168.4.212:3307), not inferred from the meeting notes.

What exists

Thing Where State
Policy.liquidated / liquidationNumber / liquidationDate schema.prisma:165-167 wired end to end (DTOs, ?liquidated= filter, stats, form checkbox, detail label)
RenewalNotice model + @@unique([policyId, generation]) schema.prisma:201-217 0 rows — never written by anything
aviso-renovacion letter report reports.registry.ts:623-799 shipped; read-only. Its enviadas/pendientes totals are permanently 0 because nothing writes RenewalNotice
Letter render + PDF/CSV/XLSX/print outputs reports.types.ts:32, outputs.ts, ReportRunner.tsx:502 (LetterLayout) shipped, reusable as-is
S3-style optional-client service pattern storage.service.ts:28-57 the pattern the mail client should copy
Single-running-job guard ops.service.ts:171-176 the pattern the cron sweep should copy
Ability matrix (17 abilities) auth/abilities.ts single source of truth; web consumes the server-resolved map

What does not exist

  • No scheduler. No @nestjs/schedule, bull/bullmq, node-cron or setInterval in apps/api. ops/ spawns detached child processes on user request only.
  • No mail code or dependency. Nothing in any package.json, .env.example or docker-compose.yml.
  • EmailTemplate / EmailCampaign / EmailLog (schema.prisma:540-571) are dead migrated legacy tables — no FKs, no code touches them. Leave them alone; RenewalNotice is the send log.
  • express-session uses the in-memory default store (main.ts:28-39), so sessions die on API restart. Relevant to any customer-identity idea in §3.
  • Reports are gated by AuthenticatedGuard alone (reports.controller.ts:28) — any logged-in user, including VIEWER, can run any report. Adding a mutation to the reports area (§2) means it cannot live on that controller.

Live data shape

Measure Value
Customers 1,536 — 1,304 (85%) have a non-blank email
Customers holding ≥1 policy 893 — 815 (91%) have an email
Policies 2,396 (0 archived); 1,865 have policyTo
Policies expiring in the next 12 months 1,045 (≈87/month)
Liquidated 2,170; pending 226
liquidationNumber / liquidationDate populated 2,245 / 2,239
Installments 4,724 — 1,849 with paidDate, 1,651 with checkNumber
renewal_notices rows 0

Email volume for §1 sizing: ≈87 policies/month × 3 notices ≈ 260 emails/month, and 91% of policyholders are reachable. This is an email channel, not a print-fallback channel — but see §1's open question on the remaining 9%.

Two meeting terms have no referent in the data

Do not guess at these. Negative greps re-run 2026-07-27 across docs/, migration/ and apps/. (A third — "GDMX" — turned out to be a typo for GMX, confirmed with the user; see §4.)

  • "Solicitud" — 0 hits. Not a legacy report, form or table. ("Atlas" is a carrier — COMP = "ATLAS, S.A." — not a report; see RENEWAL_NOTICES.md.) The closest legacy artifact to a certificate is the * MENS/*MENSAJE blob letter templates, one per line of business, deliberately excluded from migration (LEGACY_DATABASES.md → excluded tables).
  • "Garantías" — 0 hits for garant/warranty. No table, no column.

For reference, both carriers named in the meeting do appear in the data: GMX in mult.comp, m_empr.comp and gen1.comp, and ANA SEGUROS verbatim (with an inconsistent ANA variant in licencias.comp) — which matches the ANA-autos / GMX-daños split described in §4.

The only hit for transferencia anywhere is a bank-register UI label (apps/web/src/app/banco/page.tsx:948, a SCOTHIA movement type) — unrelated to policy settlement. "Número de transferencia" is therefore a new requirement mapping onto the existing liquidationNumber field, not a missed migration.

Two defects found while verifying this spec

Both are pre-existing, both affect the features below, and both should be fixed as part of §2 rather than filed separately.

(a) INCENDIO and M_EMPR have no policy_types row, and 5 policies lost their ramo. policy_types currently holds only AUTO, LICENCIAS, MULT. transform_policies.py:111-115 configures INCENDIO and M_EMPR too, so the migration creates all five — but policies_policyTypeId_fkey is ON DELETE SET NULL, so deleting an (apparently unused) lookup row silently blanked the ramo on every policy pointing at it. The 5 m_empr policies now have policyTypeId = NULL:

3249481 / 3249872  vence 2014-03-26  pendiente
3673 / 1200003673  vence 2013-03-30  pendiente
7000017            sin vigencia      liquidada

Consequence: every ramo-parameterized query filters on policyType: { name: … } (reports.registry.ts:703-707), so these 5 are invisible to aviso-renovacion and would be invisible to §2's pending-liquidación report — including 4 that are genuinely pending. INCENDIO is a different story: the legacy INCENDIO table has exactly 1 row, and it did not migrate (customer unresolved), so the ramo is legitimately empty — but the aviso-renovacion "Incendio" dropdown option still promises a report that can only ever return zero rows.

Fix as part of §2: re-seed the two missing policy_types rows, re-point the 5 orphans, and change the FK to ON DELETE RESTRICT so a lookup delete fails loudly instead of silently blanking data.

(b) The legacy settlement slots do not match the plan's assumption. MULT and INCENDIO carry two slots (LIQUIDADA/LIQUIDADA 2, NUM LIQUIDACION/NUM LIQUIDACION2, F LIQUIDA1/F LIQUIDA2) — but M EMPR carries four (liquidadaliquidada_4, num_liquidacionnum_liquidacion4, f_liquida1f_liquida4). Actual usage in the staged data:

Table rows slot 2 number slot 2 date slots 3-4
mult 773 41 (5.3%) 39 n/a
m_empr 5 0 0 0
incendio 1 0 0 n/a

So the second slot was used on ~5% of MULT policies and never anywhere else, and slots 34 were never used at all. Policy collapses this to one set, which means ≤41 rows lost a second settlement record in migration. Design decision in §2.

Utilities ↔ Seguros reconciliation — resolved, not open

The plan carried this as "two competing sources." It is not competitive; one of them is unusable.

  • SEGUROS 16_be.mdb: DATGRAL.[NUM UTIL] — 563 complete (num_id, num_util) pairs. Validated by comparing the insurance customer's own NOMBRE against the utilities customer it points at: 298/563 (53%) match exactly, the remainder being ordinary name variants (spouses, married names, entity vs. person). This is a real link, and it is the key transform_customers.py already uses.

  • UTILSEG (1,582 rows) — 379 rows carry both a seguros and a util number. Under the obvious reading (seguros → seguros DATGRAL.num_id, util → utilities DATGRAL.num_id) the row's own NOMBRE matches the target master's name 58/1,024 and 70/932 of the time respectively — i.e. essentially never. Spot-checking makes it plain:

    UTILSEG 'STEWART, KENNETH'   seguros=220 → 'ZEPEDA, JAIME RAUL'   util=441 → 'MENDOZA, SERGIO'
    UTILSEG 'HANCOCK, STEVENS'   seguros=225 → 'RODRIGUEZ, MIKE'      util=403 → 'JOW, LILY/EVANS, LARRY'
    UTILSEG 'HUDSON, RICHARD L.' seguros=227 → 'WELLES, ROBERT'       util=218 → 'ARTER, KAREN'
    

    And where the two sources overlap they contradict each other: of 218 seguros ids present in both, 170 (78%) point at a different utilities customer; only 48 pairs agree outright.

Rule: DATGRAL.[NUM UTIL] is authoritative. UTILSEG is a stale artifact of an older numbering and must not be used to reconcile customers. This matters directly to RECEIPT_CAPTURE_SPEC.md §4 (customer-number recycling), which touches the same identity space — a recycling backfill that consulted UTILSEG would merge unrelated people.


1. Renewal notification emails

BUILT — 2026-08-01, extended 2026-08-02. apps/api/src/renewals/ (sweep, sendOne, the scheduled_job_states lock) plus apps/api/src/mail/ (SES). Web: the Pólizas tab of /notificaciones; /renovaciones is an alias that lands on it. Ability renewal:send (MANAGER), as specced.

Three things in the sections below were superseded, each noted inline:

  • §1.1 — the @Cron("0 6 * * *") literal is gone. Both this sweep and the servicios jobs take their cadence from NotificationScheduleService, which stores it in app_settings and reinstalls the job on save. The default is still 06:00 daily, so behaviour is unchanged until an operator edits it. See MASS_EMAIL_NOTIFICATIONS.md, "Scheduled runs".
  • §1.4 manual mark-as-sent — dropped, deliberately. Sending from the list is what marks a notice sent; there is no way to claim a letter went out when no mail was sent. RenewalNoticeChannel.MAIL still exists for a future paper path, but nothing writes it.
  • The send log is not renewal-specific. Every attempt — including the failures and no-email skips a RenewalNotice row cannot represent — also writes email_notification_log as RENEWAL_NOTICE / POLICIES, shared with the four bulk jobs. RenewalNotice stays the gating state; the log is history.

Also added 2026-08-02: the platform-wide debug flag reaches this path. A debug send diverts the mail, skips the RenewalNotice upsert and does not advance lastSuccessfulAt — see that doc's "Send flags" for why all three are required together.

What Jorge asked for

Automatic notice to the customer at 30 days before expiry, 15 days before, and 7 days after — replacing the manual monthly run of the legacy RENEW/RENEW2/RENEW3 report batch.

What's already built (do not re-build)

  • The letter itself: aviso-renovacion (reports.registry.ts:623-799) already resolves customer, carrier, policyTo, premium, vehicle and the ramo-specific coveragesJson keys (cov.cobertura, cov.csl_limite, cov.gastos_medico, cov.propiedades, cov.personas, cov.servicio_adicional) into a __kind: "letter" row. Do not fork this copy — one letter definition, two render targets.
  • The send log: RenewalNotice, with @@unique([policyId, generation]) (schema.prisma:216) — this is the idempotency mechanism and it is already in place. A sweep that upserts on that key cannot double-send, even on re-run, redeploy or double-fire. No new dedup design is needed.
  • The cadence maps onto the existing generation Int with no schema change: 30d-before = 1, 15d-before = 2, 7d-after = 3 — exactly the legacy 1st/2nd/3rd notice model.

1.1 The scheduler

Superseded — the cadence is operator-editable, not a literal. RenewalsService registers its handler with NotificationScheduleService in onModuleInit; that service compiles the stored {hour, minute, weekdays} to a cron expression and installs it in SchedulerRegistry. Default 0 6 * * * / America/Tijuana, i.e. exactly what the literal below did. The rest of this section still holds.

Add @nestjs/schedule. One @Cron job, daily, early morning local time.

@Cron("0 6 * * *", { timeZone: "America/Tijuana" })
async sweepRenewals()

Guard multi-replica double-fire the same way ops.service.ts:171-176 guards concurrent jobs — a DB row, not an in-process flag. Reuse OpsJob with a new kind, or add a minimal ScheduledRun row; either way the guard must be a database write, because the API is deployed as a Swarm service and may run more than one replica.

The sweep must also be manually runnable (an admin endpoint that invokes the same service method), so a missed day can be caught up without waiting 24h and so the job is testable without clock manipulation.

1.2 The sweep query

For each of the three offsets, select non-archived policies whose policyTo falls on the target date:

Generation Target date Meaning
1 today + 30d primer aviso
2 today + 15d segundo aviso
3 today - 7d tercer aviso (vencida)

archivedAt: null, policyTo non-null. Date comparison must be on the UTC date, not the timestamppolicyTo is stored midnight-UTC (see the existing report's Date.UTC(year, month - 1, 1) bounds at reports.registry.ts:700), and a naive local-time comparison shifts the whole sweep by a day for America/Tijuana.

For each hit: render the letter, send, then upsert RenewalNotice on [policyId, generation] with sentAt, channel: EMAIL, and the provider message id. Upsert after a successful send, not before — a failed send must leave the row absent so the next day's sweep retries it. A row that already has sentAt is skipped.

Catch-up behaviour: because the query is date-equality, a day the job doesn't run is a day of notices silently skipped. Either make the sweep look at a window (policyTo between the target date and the last successful run's target date) or record the last successful sweep date and re-run the gap. Recommend the window — it needs no extra state beyond a lastSweptAt and it degrades correctly if the API is down for a week.

1.3 The mail client

MailProvider interface:

send(msg: { to: string; subject: string; html: string; attachments?:  })
  => Promise<{ providerId: string }>

Amazon SES is the first and intended implementation — the user already runs SES for mass notification, so this reuses an established sending reputation rather than warming a new channel. Provider choice and budget are settled, not open questions; ≈260 emails/month is negligible against existing usage.

Implement it with @aws-sdk/client-sesv2, mirroring StorageService (storage.service.ts:28-57) exactly:

  • env-driven config (SES_REGION, SES_FROM, SES_ACCESS_KEY, SES_SECRET_KEY, optional SES_CONFIGURATION_SET), added to .env.example;
  • null client when unconfigured, ServiceUnavailableException on use — an unconfigured mail setup must never crash API boot, same degradation as document storage today;
  • a no-op/log implementation for dev, selected when SES env vars are absent.

The interface stays swappable for testability, not for vendor escape.

Persist the SES message id — add providerMessageId String? to RenewalNotice rather than overloading notes, so a bounce or complaint notification can be traced back to the notice that caused it. (notes stays free-text for staff.)

1.4 Manual mark-as-sent

Not built, and deliberately so. A button that marks a notice sent without sending anything is a button that lets the list claim a customer was told when they were not — the exact failure the log exists to make visible. POST /renewals/send replaced it: sending is the marking. Revisit only when a real paper-mail workflow exists to record.

The aviso-renovacion doc comment (reports.registry.ts:617-621) already anticipates this: staff who mail a paper notice need to record it. RenewalNoticeChannel (MAIL | EMAIL) exists for exactly this distinction.

POST /policies/:id/renewal-notices — body { generation, channel, sentAt?, notes? }, upserting on the same unique key. This closes the loop that makes the report's enviadas/pendientes totals meaningful for the first time.

1.5 Bounces and unsubscribes

Not in the meeting notes, but sending 260 mails/month to a 1,304-address list built from decades-old Access data will produce bounces. Minimum viable: record providerMessageId, and add a Customer.emailOptOut Boolean @default(false) checked by the sweep. Full SNS bounce-webhook handling is out of scope for the first build — but the opt-out flag is not, because there is no other way for a customer to stop the mail.

API surface

As built (the /policies/:id/renewal-notices mark-as-sent mutation was dropped — see §1.4):

Method Route Ability
POST /renewals/sweep (manual trigger of the scheduled body; body { debug? }) renewal:send
POST /renewals/send (one notice; body { policyId, generation, debug? }) renewal:send
GET /renewals/pending?days= (what the next sweep would send) read (AuthenticatedGuard)

The cadence itself is edited through the notifications module (GET/PUT /notifications/settings/schedule[/:kind], setting:manage), because one editor covers both sweeps.

Abilities (new)

Ability Min role Notes
renewal:send MANAGER sends mail to customers on the office's behalf — a higher trust tier than ordinary data entry

Add to both the Ability union and ABILITY_MIN in auth/abilities.ts — that file is the single source of truth; apps/web/src/lib/abilities.ts only consumes the server-resolved map.

Open questions

  • Which SES region + verified identity/configuration set this sends under, and whether it reuses existing IAM credentials or gets its own scoped ses:SendEmail user.
  • The 9% of policyholders with no email (78 of 893) — silently skipped, or surfaced as a "print these" worklist? Recommend the worklist: the existing aviso-renovacion report already produces exactly those letters, so it costs one filter parameter.
  • Spanish or English body? The legacy letters were Spanish; the customer base is substantially US-resident. Customer has no language preference field.

2. Liquidación batch workflow

What Jorge asked for

Print the pending set, then mark many policies settled at once with one transfer number — "liquidación de pólizas MULT", garantías excluded.

What's already built (do not re-build)

liquidated / liquidationNumber / liquidationDate are wired end to end: schema.prisma:165-167, create+update DTOs (policy.dto.ts:35-37,59-61), the ?liquidated= list filter (policies.service.ts:148), liquidada/pendiente counts in stats() (:219,:237), headerData() pass-through (:316), the "Liquidada" checkbox in PolicyForm.tsx:250, and the detail-page label (polizas/[id]/page.tsx:323).

Only the batch layer is missing. 2,170 of 2,396 policies are already marked liquidated from migration; the live pending set is 226.

2.1 Pending-liquidación report

New entry in reports.registry.ts, format: "tabular" — gets print/PDF/CSV/XLSX free via the existing /reportes/:slug machinery.

Parameterized by ramo, mirroring how vigente and aviso-renovacion already take a policyType select param. The workflow is not MULT-only: the legacy TABLA LIQUIDA MF scratch table served MULT, INCENDIO and M EMPR (LEGACY_DATABASES_OBJECTS.md:4887-5017).

Params: ramo (with an "todos" option), aseguradora, date range on policyFrom. 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 — same constraint as the billing module).

⚠️ Fix defect (a) above before building this, or the report inherits the same blind spot: 4 of the 226 pending policies carry policyTypeId = NULL and would be missing from every ramo-filtered run and from the "todos" run if that is implemented as a union over known types rather than as "no filter."

2.2 Batch settle endpoint

POST /policies/liquidate-batch — body:

{ policyIds: string[], liquidationNumber: string, liquidationDate: string }

One prisma.$transaction. Rejects ids that are already liquidated (return them in the response rather than silently skipping, so the UI can say which). Writes an ActivityLog row per policy — this is a financial settlement marker being set across many records at once, and it is the one place in the app where a single click changes dozens of rows.

Ability: new policy:liquidate at MANAGER, not the existing policy:update (STAFF). Reason: a STAFF user editing one policy's checkbox is data entry; a STAFF user settling 200 policies against one transfer number is a financial control. Recommend the new ability; note it as a question for Jorge only if he wants STAFF to keep doing it.

2.3 Un-settle path

The legacy had one (MULT FAM X POLIZA Consulta, LEGACY_DATABASES_OBJECTS.md:5570-5573). POST /policies/liquidate-batch/undo with the same shape, or { liquidationNumber } to reverse a whole batch. Gated at MANAGER via the same policy:liquidate. Also logs.

2.4 The two-slot decision (defect (b))

Policy has one settlement slot; MULT/INCENDIO had two and M EMPR had four, with real usage on ≤41 MULT rows and nowhere else.

Recommendation: move settlement onto PolicyPaymentInstallment, do not add a second slot to Policy. Reasons:

  • PolicyPaymentInstallment already exists, already has paidDate and checkNumber, and already models "the n-th payment of this policy" — which is exactly what the second settlement slot meant. 4,724 rows, 1,849 with a paid date.
  • Adding liquidated2/liquidationNumber2/liquidationDate2 reproduces the legacy's hardcoded-repeated-columns mistake that this whole migration exists to undo — and M EMPR proves it doesn't stop at two.
  • The Policy-level fields stay as the rollup ("this policy is fully settled"), which is what the existing UI and ?liquidated= filter already mean. No breaking change.

Concretely: add liquidationNumber String? + liquidatedAt DateTime? to PolicyPaymentInstallment; batch-settle writes the installment rows and sets Policy.liquidated = true when all installments are settled. Backfill the ≤41 lost slot-2 values from mult.num_liquidacion2 / f_liquida2 in transform_policies.py at the same time.

If Jorge wants the simpler thing instead, say so explicitly and accept that those 41 second settlements stay unmigrated.

2.5 "Garantías excluded"

Blocked — the term has no referent anywhere in the data (0 hits). Do not guess at a filter. Spec'd as: the batch report takes an explicit exclusion list or a flag once Jorge identifies what a "garantía" is in his data. Most likely candidates to ask about: a forma_pago value, an aseguradora, or a coveragesJson key.

API surface

Method Route Ability
GET /reports/liquidacion-pendiente?policyType=&provider= read
POST /policies/liquidate-batch policy:liquidate
POST /policies/liquidate-batch/undo policy:liquidate

Note the mutation lives on PoliciesController, not ReportsController — that controller is deliberately read-only and guarded by AuthenticatedGuard alone (reports.controller.ts:28), so any logged-in VIEWER reaches it.

Web

Extend /polizas with a "Liquidación" tab: the pending list with checkboxes, a select-all-filtered action, and one dialog collecting número de transferencia + fecha. Print goes through the existing /reportes/liquidacion-pendiente runner rather than a bespoke print view.

Abilities (new)

Ability Min role Notes
policy:liquidate MANAGER batch settlement across many rows; distinct from policy:update (STAFF)

Open questions

  • What "garantías" refers to (blocks the exclusion filter).
  • Two-slot settlement: installment-level (recommended) or a second Policy slot.
  • Should policy:liquidate be a new MANAGER ability, or is reusing policy:update (STAFF) what the office actually wants?

3. Certificate / "Solicitud Atlas" + portal delivery

What Jorge asked for

A "Solicitud Atlas" / insurance certificate, visible to customers on the website.

The blocked half

"Solicitud" has no referent — 0 hits across 212 SEGUROS reports and 96 UTILITIES reports; "Atlas" is a carrier, not a report. A solicitud is normally an application form (pre-policy, filled in by the applicant), which is a materially different artifact from a certificate (post-policy, proof of coverage issued to the insured). These need different data, different timing and different delivery.

Do not build until Jorge confirms which one he means. The spec below covers the certificate reading, because that is what "visible to customers on the website" implies.

The buildable half — certificate rendering

Reuse the letter machinery, exactly as aviso-renovacion does:

  • format: "letter" report (reports.types.ts:32), rendered by LetterLayout (ReportRunner.tsx:502) on screen and by outputs.ts renderPdf for the file.
  • Data needed, all already on Policy and its relations: customer name + address, policy number, carrier, policyFrom/policyTo, and the ramo-specific coverage keys already mapped in RENEWAL_NOTICES.md — plus vehicles[0] for auto and the property address for MULT/INCENDIO/M_EMPR.
  • Parameter is a single policy, not a month — /reports/certificado?policyId=. Staff-facing route: a "Certificado" button on /polizas/[id].

The infrastructure half — portal delivery

PLAN.md:16,20-24 locks the customer portal (my-jorgecuadros-web, PHP/mysqli, its own utility_dbo DB) as out of scope and unchanged. This repo has no public route and no CUSTOMER role (UserRole = ADMIN/MANAGER/STAFF/VIEWER, schema.prisma:43-48), and its sessions are in-memory. Insurance therefore reaches customers as an extension of the already-planned replication (PLAN.md steps 8/9), not as a new public surface here.

What this spec adds to that design, to be finalized when step 8 runs:

  • Which policy fields join the replicated set — recommend the certificate's own field list and nothing more (policy number, carrier, ramo, vigencia, customer link), explicitly excluding premiums, commissions, liquidation status, observations and notes. The replicated side is the internet-exposed one; it should never carry the office's margin data.
  • Certificate as generated PDF, not portal-side rendering. Render here, upload to the existing S3/MinIO bucket via StorageService, replicate the pointer. The portal is PHP and is not being modified; giving it a URL is cheaper than giving it a template. This also means the certificate the customer sees is byte-identical to the one staff printed.
  • Where in utility_dbo the pointer lands — depends on the portal's existing policy-facing views (fm2/fm3/fmt, full_coverage, mx_liability, usa_liability), and needs a read of the portal's PHP before it can be stated.

Abilities

None new. Certificate generation is a read; delivery is a replication concern.

Open questions

  • What "Solicitud Atlas" actually is — application form or certificate. Blocks the whole section.
  • If it's an application form: who fills it in (staff on the customer's behalf, or the customer on the portal), and does it need to exist as a record before a Policy does? That would be a new model, not a report.
  • Does the certificate need a carrier logo/letterhead? The legacy * MENS templates were per-carrier blobs; outputs.ts renderPdf has no image support today.

4. Carrier API integration

What Jorge asked for

Integration with ANA Seguros and GMX. ("GDMX" in the meeting notes was a typo — confirmed with the user 2026-07-27. The data's GMX is correct, and this is no longer an open question.)

Carrier research (2026-07-27) — what actually exists

The two carriers are one company. ANA and GMX are both members of Grupo Valore, alongside Seguros Argos (vida) and Prevem Seguros (gastos médicos). ANA writes autos; GMX writes daños — which maps exactly onto the split in this database: ANA covers the AUTO/LICENCIAS book, GMX covers MULT/INCENDIO/M_EMPR. Practical consequence: this is one commercial conversation, not two. The group also shares infrastructure — GMX's own quoting micrositio is served from ANA's host (server.anaseguros.com.mx/Micrositios/GRUPOVALOREGMXCOR/), so one technical contact plausibly covers both.

ANA has a real, live web service. https://server.anaseguros.com.mx/ananetws/service.asmx — a classic ASP.NET .asmx endpoint speaking SOAP 1.1 and 1.2, with its operation list published on the standard help page:

Purpose Operations
Catálogos Marca, SubMarca, Modelo, MarcaMoto, SubMarcaMoto, Color, Categoria, CatVeh, CodigoPostal, Colonia, ColxCP, DelMun, EDOS, Bancos, FormaPago, TipoPersona, TipoIndem, RegimenFiscal, Nacionalidad, Ocupacion, Identificacion, GiroEmpresa, PropositoMotos, Vigencia
Cotización CalculaValor, CalculaMSI
Vehículo Vehiculo, VehiculoMoto, ValidaSerie
Recuperación / validación RecuperaCotizacion, ValidaAsegurado
Transacción Transaccion

GMX publishes no machine interface. Its agent area (gmx.com.mx/soy-agente/herramientas/) lists only human portals — reporte de agentes, cobranzas, envío/descarga de facturas, documentos emitidos, reporte de siniestros, artículo 492. No API, no WSDL, no developer contact. The only number published is (55) 5480-4000.

Neither carrier has a public developer portal or published documentation. Across this market, web service credentials are granted by the carrier, at its discretion, to appointed agents on written request — expect a lead time measured in weeks, not a signup form.

⚠️ The critical mismatch — read before estimating this

The ANA service is a new-business quoting/issuance API. What this platform needs is an inbound feed of the office's existing book. Every operation above serves "price and issue a policy that does not exist yet." Not one of them is "list the policies where I am the agent of record," which is what would populate Policy rows and keep them current.

So the honest reading of the research is:

  • If Jorge's ask means "stop re-typing new policies into two systems" — the ANA service can do that for autos, and it is genuinely buildable once credentials arrive. GMX/daños would stay manual.
  • If Jorge's ask means "keep our policy data in sync with the carrier automatically" — no evidence exists that either carrier offers it, and the question to ask is specifically whether a portfolio/cartera download service exists for an agent's own book. That question has not been asked yet.

Do not commit to this section until Jorge says which of the two he means. The first is a moderate feature; the second may not be purchasable at all.

Note also that nothing in this spec authorizes calling those endpoints. The operation list above comes from a published help page; actually invoking CalculaValor or Transaccion requires the agent credentials Jorge would obtain, and should not be attempted before then.

Legacy precedent

Carrier config that exists in the legacy system: gen1/gen2 (LEGACY_DATABASES.md:1872-1892) — 9 rows keyed by carrier with RFC, CLAVE, FPAGO, MONED, plus a 14-row agent list. It is the only carrier-keyed table anywhere, and it carries no API metadata — no endpoint, no credential, no identifier that looks like one. In the new schema the equivalent is InsuranceProvider, which today holds only a name.

Shape

  • CarrierConnector interface — fetchPolicies(since: Date), fetchPolicy(number: string), returning a normalized DTO, one implementation per carrier. The ANA implementation cannot satisfy fetchPolicies from the operations known today (see the mismatch above); if the ask turns out to be outbound issuance instead, the interface is the wrong shape and should become quote(...) / issue(...) against CalculaValor / Transaccion.
  • SOAP, not REST, for ANA — .asmx with a WSDL. Node has no first-class SOAP client in this stack; budget for strong-soap/soap plus the schema work, and generate types from the WSDL rather than hand-writing envelopes.
  • Credentials and endpoint config per carrier: extend InsuranceProvider with the connector's identifier and store secrets in env, keyed by that identifier — never in the database row.
  • The catalog operations (Marca/SubMarca/Modelo/CodigoPostal/Colonia) are useful independently of any policy sync — they would let the policy form validate vehicle and address data against the carrier's own catalogs instead of free text. That is the cheapest possible first use of these credentials and a sensible pilot: read-only, no issuance risk, immediately visible in PolicyForm.
  • An import-staging + review step, never a direct write to Policy. Same principle as RECEIPT_CAPTURE_SPEC.md §2, which routes OCR results through a review queue instead of writing ledger rows: one write path, one audit trail, and a human confirms anything a machine proposed. A carrier feed that wrote Policy rows directly would also fight the Access sync (run_all.py --sync), which owns every row carrying provenance columns — an imported policy needs its own provenance (legacySourceDb = 'carrier:<name>') or the next sync will delete it as a row that vanished from source.

Abilities (new)

Ability Min role Notes
carrier:import MANAGER trigger a fetch and approve imported policies

Open questions

  • Does "GDMX" mean GMX? Resolved 2026-07-27 — yes, a typo in the meeting notes.
  • Direction — the one that decides whether this is buildable. Does Jorge want to stop re-typing new policies (outbound quote/issue, which the ANA service supports), or keep existing policies in sync (inbound portfolio download, which nothing found suggests either carrier offers)?
  • What to ask Grupo Valore, in one call to (55) 5480-4000 or the ANA agent channel:
    1. WSDL + test/production credentials for server.anaseguros.com.mx/ananetws/service.asmx, and whether an agent appointment is a prerequisite.
    2. Whether a cartera / portfolio download service exists for an agent's own book — the question that decides the direction above.
    3. Whether GMX daños has any machine interface at all, or whether its agent portals are the only access. This is the more valuable half for this office: GMX writes the MULT/INCENDIO/M_EMPR book.
    4. Whether one set of Grupo Valore credentials spans both carriers, given the shared hosting.
  • Does the office hold agent appointments with both ANA and GMX in good standing? Credential grants are discretionary and appointment-gated.

Build sequencing

  1. §1 renewal emailsDONE 2026-08-01/02. See §1's BUILT note. ≈260 mails/month against a 91%-reachable policyholder base.
  2. §2 liquidación batch — small, builds on fields already wired. Do the two defect fixes (missing policy_types rows + FK ON DELETE RESTRICT) as part of it, since both distort its own report.
  3. §3 certificate — the report half is buildable now; portal delivery waits on PLAN.md steps 8/9 infrastructure, and the whole section waits on what "Solicitud" means.
  4. §4 carrier APIs — blocked on a single phone call, not on research. ANA's SOAP service is real and its operation list is known; what is missing is credentials and an answer on direction (§4's open questions). GMX appears to have nothing machine-readable, which matters because GMX writes the larger half of this office's book. Build last, and consider the catalog-only pilot before anything else.

§1 and §2 are independent of each other and can be built in parallel; both are independent of everything in RECEIPT_CAPTURE_SPEC.md.

New abilities across this spec

Ability Min role Section
renewal:send MANAGER §1
policy:liquidate MANAGER §2
carrier:import MANAGER §4

No collision with the abilities proposed in RECEIPT_CAPTURE_SPEC.md (statement:ingest, statement:review, bank:manage-accounts, customer:recycle, customer:purge).

Open questions to take back to Jorge (collected)

§1 — renewal emails (feature built; these three are still open)

  • Which SES region + verified identity/configuration set, and whether to reuse existing IAM credentials or create a scoped ses:SendEmail user. Answered in practice 2026-08-02 — the Gitea secrets were created. Two things the deploy preflight cannot verify and that decide whether mail actually goes out: SES_FROM must be a verified identity in SES_REGION, and the account must be out of the SES sandbox (which restricts delivery to verified recipients). See BACKLOG.md §0.
  • The 78 policyholders with no email: skip silently, or produce a print worklist? Currently they are logged as SKIPPED_NO_EMAIL in email_notification_log — visible in "Registro de envíos", but not yet a printable worklist. (Recommend the worklist.)
  • Spanish or English notice body?

§2 — liquidación

  • What "garantías" refers to — blocks the exclusion filter.
  • Settlement on PolicyPaymentInstallment (recommended) vs. a second slot on Policy; and whether to backfill the ≤41 lost MULT second settlements.
  • New policy:liquidate (MANAGER) vs. reusing policy:update (STAFF).

§3 — certificate

  • What "Solicitud Atlas" is: application form or certificate. Blocks the section.
  • If application form: who fills it in, and does it precede the Policy record?
  • Does the certificate need carrier letterhead/logo?

§4 — carrier APIs (all four go in one call to Grupo Valore, (55) 5480-4000)

  • Direction: outbound quote/issue (supported by ANA today) or inbound portfolio sync (no evidence either carrier offers it)? This decides whether the feature is buildable at all.
  • WSDL + credentials for server.anaseguros.com.mx/ananetws/service.asmx.
  • Does a cartera/portfolio download exist for an agent's own book?
  • Does GMX daños have any machine interface, or portals only? GMX writes the MULT/INCENDIO/M_EMPR book — the bigger half for this office.
  • Does one Grupo Valore credential span both carriers?

Resolved — no longer open

  • Which of UTILSEG / DATGRAL.[NUM UTIL] is authoritativeNUM UTIL; UTILSEG is stale and must not be used (see Ground truth).
  • OCR/mail provider and budget → SES, settled before this spec was written.
  • Does "GDMX" mean GMX → yes, a typo in the meeting notes (2026-07-27).
  • Do the carriers' APIs exist → ANA: yes, a live SOAP service with a known operation list. GMX: no published machine interface. Both are Grupo Valore, so it is one relationship. See §4.

Sources (§4 carrier research, 2026-07-27)