Files
jorgecuadros-platform/apps/api/src/renewals/renewal-email.ts
T
rmancinas 87d8743251
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m48s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m4s
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
2026-08-02 02:00:02 -07:00

66 lines
2.6 KiB
TypeScript

import type { RenewalLetterRow } from "../reports/renewal-letter";
const GENERATION_TEXT: Record<number, string> = {
1: "Le enviamos el primer aviso para renovar su póliza.",
2: "Le enviamos el segundo aviso para renovar su póliza.",
3: "Le informamos que su póliza está vencida.",
};
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function displayDate(value: string): string {
if (value === "—") return value;
const [year, month, day] = value.split("-");
return `${day}/${month}/${year}`;
}
function money(value: string | null, currency: string): string {
if (!value) return "No disponible";
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency,
minimumFractionDigits: 2,
}).format(Number(value));
}
function row(label: string, value: string): string {
return `<tr><th style="padding:8px 12px;text-align:left;background:#f4f4f4;border:1px solid #ddd">${escapeHtml(label)}</th><td style="padding:8px 12px;border:1px solid #ddd">${escapeHtml(value)}</td></tr>`;
}
export function renderRenewalEmail(letter: RenewalLetterRow): {
subject: string;
html: string;
} {
const expired = letter.generation === 3;
const subject = expired
? `Póliza vencida: ${letter.policyNumber}`
: `Aviso de renovación: póliza ${letter.policyNumber}`;
const phone = letter.customerMobile ?? letter.customerPhone ?? "No disponible";
const address = letter.customerAddress.join(", ") || "No disponible";
const premium = letter.total ?? letter.netPremium;
const details = [
row("Número de póliza", letter.policyNumber),
row("Tipo de póliza", letter.policyType),
row("Aseguradora", letter.provider),
row("Fecha de vencimiento", displayDate(letter.policyTo)),
row("Prima", money(premium, letter.currency)),
row("Cliente", letter.customerName),
row("Correo", letter.customerEmail ?? "No disponible"),
row("Teléfono", phone),
row("Dirección", address),
].join("");
return {
subject,
html: `<div style="font-family:Arial,sans-serif;color:#222;line-height:1.5"><p>Estimado(a) ${escapeHtml(letter.customerName)}:</p><p>${escapeHtml(GENERATION_TEXT[letter.generation] ?? "Le enviamos un aviso sobre la renovación de su póliza.")}</p><table style="border-collapse:collapse;width:100%;max-width:680px">${details}</table><p>Por favor, comuníquese con Jorge Cuadros &amp; Asociados para revisar su renovación.</p><p>Atentamente,<br>Jorge Cuadros &amp; Asociados</p></div>`,
};
}