feat(renovaciones): renewal notification emails over SES
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m48s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s

INSURANCE_FEATURES_SPEC §1. The office printed and mailed renewal letters
from the legacy CONTROL <ramo> RENEW[2/3] paper log; 91% of policyholders
have an email on file, so send the notice instead and keep the paper log
as the fallback.

A daily cron (06:00 America/Tijuana) sweeps three generations off
policyTo — 30 and 15 days before expiry, 7 days after — sends each
through SES, and upserts RenewalNotice by [policyId, generation] so a
policy is never notified twice for the same milestone. RenewalNotice now
records providerMessageId, so a later bounce or complaint webhook can be
traced back to the row that sent it.

- customers.emailOptOut excludes a customer from every sweep; editable
  from the customer form
- scheduled_job_states holds the sweep's lock and last successful run;
  the window is widened to cover days the job did not run, so a weekend
  outage does not silently drop a generation
- SES unconfigured is not an error outside production — messages are
  logged and skipped, so dev and CI never send
- /renovaciones (renewal:send, MANAGER+) lists what is pending per
  generation, runs the sweep by hand, and marks a notice sent by mail
  for the customers with no email
- POST /policies/:id/renewal-notices records that manual mark
- the aviso-renovacion report and the emails now share one projection
  (reports/renewal-letter.ts) instead of two copies of the mapping
This commit is contained in:
2026-08-02 02:00:02 -07:00
parent 3125b52057
commit 87d8743251
29 changed files with 1450 additions and 82 deletions
+266
View File
@@ -0,0 +1,266 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { useCan } from "@/lib/abilities";
import { formatDate, formatMoney } from "@/lib/labels";
import { apiFetch } from "@/lib/api";
export interface RenewalLetter {
policyId: string;
policyNumber: string;
policyType: string;
customerName: string;
customerEmail: string | null;
provider: string;
policyTo: string;
netPremium: string | null;
total: string | null;
currency: string;
generation: number;
sentAt: string | null;
}
export interface RenewalSweepResult {
eligible: number;
sent: number;
skipped: number;
failed: number;
failures: { policyId: string; generation: number; error: string }[];
}
export interface RenewalMarkInput {
generation: number;
channel: "MAIL" | "EMAIL";
sentAt?: string;
notes?: string;
}
export default function RenovacionesPage() {
return (
<AppShell>
<Renovaciones />
</AppShell>
);
}
const GENERATION_LABEL: Record<number, string> = {
1: "Primer aviso (30 días antes)",
2: "Segundo aviso (15 días antes)",
3: "Tercer aviso (7 días después)",
};
const CHANNEL_LABEL: Record<"MAIL" | "EMAIL", string> = {
MAIL: "Impreso",
EMAIL: "Correo electrónico",
};
function Renovaciones() {
const allowed = useCan("renewal:send");
const [days, setDays] = useState(30);
const [pending, setPending] = useState<RenewalLetter[] | null>(null);
const [pendingError, setPendingError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [sweeping, setSweeping] = useState(false);
const refresh = useCallback(async () => {
setPendingError(null);
try {
const data = await apiFetch<RenewalLetter[]>(
`/renewals/pending?days=${days}`,
);
setPending(data);
} catch (e) {
setPendingError(
(e as Error)?.message ?? "No se pudo cargar la lista de avisos.",
);
setPending([]);
}
}, [days]);
useEffect(() => {
if (allowed) refresh();
}, [allowed, refresh]);
async function handleSweep() {
setActionError(null);
setNotice(null);
setSweeping(true);
try {
const result = await apiFetch<RenewalSweepResult>("/renewals/sweep", {
method: "POST",
});
setNotice(
`Enviados ${result.sent} avisos (${result.failed} con error).`,
);
await refresh();
} catch (e) {
setActionError((e as Error)?.message ?? "No se pudo ejecutar el barrido.");
} finally {
setSweeping(false);
}
}
async function handleMark(letter: RenewalLetter, channel: "MAIL" | "EMAIL") {
setActionError(null);
setNotice(null);
try {
await apiFetch(`/policies/${letter.policyId}/renewal-notices`, {
method: "POST",
body: JSON.stringify({
generation: letter.generation,
channel,
} satisfies RenewalMarkInput),
});
setNotice(`Aviso marcado como enviado (${CHANNEL_LABEL[channel]}).`);
await refresh();
} catch (e) {
setActionError(
(e as Error)?.message ?? "No se pudo registrar el aviso.",
);
}
}
if (!allowed) {
return (
<div className="page-head">
<h1 className="page-title">Renovaciones</h1>
<div className="state-box state-error">
No tiene permisos para enviar avisos de renovación.
</div>
</div>
);
}
const counts = (pending ?? []).reduce<Record<number, number>>(
(acc, item) => ({
...acc,
[item.generation]: (acc[item.generation] ?? 0) + 1,
}),
{},
);
const grouped = [1, 2, 3].filter((gen) => (counts[gen] ?? 0) > 0);
return (
<>
<div className="page-head">
<p className="eyebrow">Renovaciones</p>
<h1 className="page-title">Avisos de renovación</h1>
<p className="muted" style={{ marginTop: 6, maxWidth: 720 }}>
El sistema ejecuta un barrido diario a las 06:00 hora local que
notifica a los clientes a 30, 15 y 7 días antes o después del
vencimiento de su póliza. Esta pantalla muestra qué avisos están
pendientes y permite ejecutarlo manualmente.
</p>
</div>
{actionError && <div className="state-box state-error">{actionError}</div>}
{notice && <div className="state-box">{notice}</div>}
<div className="card" style={{ padding: 20, marginBottom: 20 }}>
<div className="row-actions" style={{ justifyContent: "space-between" }}>
<div>
<h2 className="section-title">Barrido manual</h2>
<p className="muted small" style={{ marginTop: 4 }}>
Usa la fecha actual del servidor como referencia para seleccionar
avisos vencidos a 30 y 15 días, y vencidos hace 7 días.
</p>
</div>
<button
type="button"
className="btn btn-primary"
disabled={sweeping}
onClick={handleSweep}
>
{sweeping ? "Enviando…" : "Ejecutar barrido"}
</button>
</div>
<div className="field" style={{ maxWidth: 180, marginTop: 12 }}>
<span className="field-label">Ventana (días)</span>
<input
className="input"
type="number"
min={1}
max={365}
value={days}
onChange={(e) =>
setDays(Math.min(365, Math.max(1, Number(e.target.value) || 30)))
}
/>
</div>
</div>
{pendingError && (
<div className="state-box state-error">{pendingError}</div>
)}
{!pendingError && grouped.length === 0 && (
<div className="empty-inline">
No hay avisos pendientes en esta ventana.
</div>
)}
{grouped.map((generation) => (
<section className="card" key={generation} style={{ padding: 20 }}>
<h2 className="section-title">{GENERATION_LABEL[generation]}</h2>
<div className="tx-scroll">
<table className="tx-table">
<thead>
<tr>
<th>Cliente</th>
<th>Póliza</th>
<th>Tipo</th>
<th>Aseguradora</th>
<th>Vence</th>
<th className="num">Prima</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{(pending ?? [])
.filter((item) => item.generation === generation)
.map((item) => (
<tr key={`${item.policyId}-${item.generation}`}>
<td>
<div>{item.customerName}</div>
<div className="muted small">
{item.customerEmail ?? "Sin correo"}
</div>
</td>
<td className="mono">{item.policyNumber}</td>
<td>{item.policyType}</td>
<td>{item.provider}</td>
<td>{formatDate(item.policyTo)}</td>
<td className="num">
{formatMoney(item.total ?? item.netPremium, item.currency)}
</td>
<td>
<div className="row-actions">
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleMark(item, "EMAIL")}
disabled={!item.customerEmail}
>
Marcar EMAIL
</button>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => handleMark(item, "MAIL")}
>
Marcar impreso
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
))}
</>
);
}
+1
View File
@@ -79,6 +79,7 @@ const NAV: NavEntry[] = [
ability: "bank:manage-accounts",
},
{ href: "/usuarios", label: "Usuarios", ability: "user:manage" },
{ href: "/renovaciones", label: "Renovaciones", ability: "renewal:send" },
{ href: "/operaciones", label: "Operaciones", ability: "db:manage" },
],
},
+10
View File
@@ -30,6 +30,7 @@ type Values = {
mobile: string;
fax: string;
email: string;
emailOptOut: boolean;
identificationType: string;
identificationNumber: string;
identificationExpiration: string;
@@ -54,6 +55,7 @@ function initial(c?: CustomerDetail): Values {
mobile: c?.mobile ?? "",
fax: c?.fax ?? "",
email: c?.email ?? "",
emailOptOut: c?.emailOptOut ?? false,
identificationType: c?.identificationType ?? "",
identificationNumber: c?.identificationNumber ?? "",
identificationExpiration: toDateInput(c?.identificationExpiration),
@@ -103,6 +105,7 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
mobile: s(v.mobile),
fax: s(v.fax),
email: s(v.email),
emailOptOut: v.emailOptOut,
identificationType: s(v.identificationType),
identificationNumber: s(v.identificationNumber),
identificationExpiration: s(v.identificationExpiration),
@@ -139,6 +142,13 @@ export function CustomerForm({ customer }: { customer?: CustomerDetail }) {
<input className="input" type="email" value={v.email}
onChange={(e) => set("email", e.target.value)} />
</Field>
<Field label="Notificaciones de renovación">
<label>
<input type="checkbox" checked={v.emailOptOut}
onChange={(e) => set("emailOptOut", e.target.checked)} />
{" "}No enviar correos
</label>
</Field>
<Field label="Teléfono">
<input className="input" value={v.phone}
onChange={(e) => set("phone", e.target.value)} />
+1 -1
View File
@@ -108,7 +108,7 @@ export class ApiError extends Error {
}
}
async function apiFetch<T>(
export async function apiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
+3
View File
@@ -14,6 +14,7 @@ export type Ability =
| "policy:delete"
| "policy:ingest"
| "policy:ocr-review"
| "renewal:send"
| "property:create"
| "property:update"
| "property:delete"
@@ -963,6 +964,7 @@ export interface CustomerDetail {
mobile: string | null;
fax: string | null;
email: string | null;
emailOptOut: boolean;
notes: string | null;
identificationType: string | null;
identificationNumber: string | null;
@@ -993,6 +995,7 @@ export interface CustomerInput {
mobile?: string;
fax?: string;
email?: string;
emailOptOut?: boolean;
notes?: string;
identificationType?: string;
identificationNumber?: string;