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
65 lines
2.0 KiB
TypeScript
65 lines
2.0 KiB
TypeScript
import type { RenewalLetterRow } from "../reports/renewal-letter";
|
|
import { renderRenewalEmail } from "./renewal-email";
|
|
|
|
function letter(overrides: Partial<RenewalLetterRow> = {}): RenewalLetterRow {
|
|
return {
|
|
__kind: "letter",
|
|
policyId: "policy-1",
|
|
policyNumber: "POL-123",
|
|
policyType: "AUTO",
|
|
customerName: "Ana Pérez",
|
|
customerEmail: "ana@example.com",
|
|
customerPhone: "664-111-2222",
|
|
customerMobile: null,
|
|
customerAddress: ["Calle Uno 123", "Tijuana, BC, 22000"],
|
|
provider: "Aseguradora Uno",
|
|
policyTo: "2026-09-01",
|
|
netPremium: "1200.00",
|
|
policyFee: null,
|
|
total: "1392.00",
|
|
currency: "MXN",
|
|
coverageDays: null,
|
|
cslLimit: null,
|
|
medicalCoverage: null,
|
|
propertyDamage: null,
|
|
perPersonLiability: null,
|
|
additionalService: null,
|
|
vehicle: null,
|
|
generation: 1,
|
|
sentAt: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("renderRenewalEmail", () => {
|
|
it("includes policy, premium, expiration, type, and customer information", () => {
|
|
const result = renderRenewalEmail(letter());
|
|
|
|
expect(result.subject).toContain("POL-123");
|
|
expect(result.html).toContain("primer aviso");
|
|
expect(result.html).toContain("AUTO");
|
|
expect(result.html).toContain("01/09/2026");
|
|
expect(result.html).toContain("1,392.00");
|
|
expect(result.html).toContain("Ana Pérez");
|
|
expect(result.html).toContain("ana@example.com");
|
|
expect(result.html).toContain("664-111-2222");
|
|
expect(result.html).toContain("Calle Uno 123");
|
|
});
|
|
|
|
it("uses overdue wording for generation three", () => {
|
|
const result = renderRenewalEmail(letter({ generation: 3 }));
|
|
|
|
expect(result.subject).toContain("Póliza vencida");
|
|
expect(result.html).toContain("está vencida");
|
|
});
|
|
|
|
it("escapes customer-provided HTML", () => {
|
|
const result = renderRenewalEmail(
|
|
letter({ customerName: '<img src=x onerror="alert(1)">' }),
|
|
);
|
|
|
|
expect(result.html).not.toContain("<img");
|
|
expect(result.html).toContain("<img");
|
|
});
|
|
});
|