docs(insurance): spec renewal emails, liquidación batch, certificate, carrier APIs
Companion to docs/RECEIPT_CAPTURE_SPEC.md — the insurance half of the 2026-07-25/26 meeting. Documentation only; no application code. Verified against the code and a live query of the dev DB rather than designed from the meeting notes alone, which changed several conclusions: - Renewal emails and the liquidación batch are much smaller than they look. RenewalNotice + its @@unique([policyId, generation]) idempotency key and the aviso-renovacion letter body already exist; the per-policy liquidation fields are wired end to end. What's missing is a scheduler, a mail client, and the batch layer. - Carrier research: ANA and GMX are one company (Grupo Valore). ANA exposes a live SOAP service with a published operation list; GMX publishes no machine interface at all. Every ANA operation serves new-business quoting/issuance, not "list my book" — so the direction question decides whether the feature is buildable. - UTILSEG is unusable for Utilities↔Seguros reconciliation and the spec closes that long-standing open question: DATGRAL.[NUM UTIL] is authoritative (name match 298/563 vs 58/1024), and where the two sources overlap they contradict on 170 of 218 shared ids. Also records two live defects found while verifying: policy_types is missing its INCENDIO and M_EMPR rows (the FK is ON DELETE SET NULL, so 5 m_empr policies silently lost their ramo), and the legacy settlement slots don't match the target model (MULT/INCENDIO carry two, M EMPR carries four, Policy collapses to one). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<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** — 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)
|
||||
Reference in New Issue
Block a user