# 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.

ℹ️ `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`](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:<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 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.~~
  **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`](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 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)
