# 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). > **A fifth insurance feature exists that this spec never proposed.** > [`POLICY_OCR.md`](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`](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`](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 > **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`](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 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 > **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. 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**~~ — **DONE 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. **Still unanswered in production**: the `SES_*` variables are wired through the deploy workflow but unset in Gitea, so production sends fail loudly. - 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 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)