feat(renovaciones): renewal notification emails over SES
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:
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -108,7 +108,7 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user