diff --git a/docs/INSURANCE_FEATURES_SPEC.md b/docs/INSURANCE_FEATURES_SPEC.md new file mode 100644 index 0000000..84b6b52 --- /dev/null +++ b/docs/INSURANCE_FEATURES_SPEC.md @@ -0,0 +1,786 @@ +# 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`](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`](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). + +## 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`](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** (`liquidada` … `liquidada_4`, +`num_liquidacion` … `num_liquidacion4`, `f_liquida1` … `f_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 3–4 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`](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 + +### 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 + +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 timestamp** — `policyTo` 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: + +```ts +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 + +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 + +| Method | Route | Ability | +|---|---|---| +| `POST` | `/policies/:id/renewal-notices` | `renewal:send` | +| `POST` | `/renewals/sweep` (manual trigger of the cron body) | `renewal:send` | +| `GET` | `/renewals/pending?days=` (what the next sweep would send) | read (AuthenticatedGuard) | + +### 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. +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`](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`](../PLAN.md) 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`](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:'`) 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 emails** — highest value, schema already ready, no blocker + beyond the SES sending account. ≈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** +- Which SES region + verified identity/configuration set, and whether to reuse + existing IAM credentials or create a scoped `ses:SendEmail` user. +- The 78 policyholders with no email: skip silently, or produce a print + 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 authoritative~~ → `NUM 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) + +- [ANA Seguros web service (`ananetws/service.asmx`)](https://server.anaseguros.com.mx/ananetws/service.asmx) +- [ANA Seguros — quiénes somos / Grupo Valore](https://anaseguros.com.mx/anaweb/ana_seguros.html) +- [GMX Seguros — herramientas para agentes](https://www.gmx.com.mx/soy-agente/herramientas/) +- [GMX quoting micrositio hosted on ANA's server](https://server.anaseguros.com.mx/Micrositios/GRUPOVALOREGMXCOR/cotizador.html) +- [Agentemotor — how carriers grant web service credentials](https://www.agentemotor.com/blog/noticias-agentemotor/como-integrarte-a-las-aseguradoras-via-web-service-utilizando-agentemotor/) + (Colombian market, cited only for the credential-request pattern)