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
+248
View File
@@ -0,0 +1,248 @@
import {
ConflictException,
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { AuditService } from "../common/audit.service";
import { PrismaService } from "../prisma/prisma.service";
import {
renewalLetterSelect,
toRenewalLetterRow,
} from "../reports/renewal-letter";
import { MailService } from "./mail.service";
import { renderRenewalEmail } from "./renewal-email";
export const RENEWAL_CADENCE = [
{ generation: 1, offsetDays: 30 },
{ generation: 2, offsetDays: 15 },
{ generation: 3, offsetDays: -7 },
] as const;
const JOB_NAME = "renewal-email-sweep";
const TIME_ZONE = "America/Tijuana";
const DAY_MS = 86400000;
export function dateInTimeZone(now: Date, timeZone = TIME_ZONE): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const value = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value);
return new Date(Date.UTC(value("year"), value("month") - 1, value("day")));
}
export function addUtcDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * DAY_MS);
}
export function renewalWindow(
today: Date,
offsetDays: number,
lastSuccessfulAt?: Date | null,
): { from: Date; to: Date } {
const to = addUtcDays(today, offsetDays);
if (!lastSuccessfulAt) return { from: to, to };
const previousDay = dateInTimeZone(lastSuccessfulAt);
if (previousDay >= today) return { from: to, to };
return { from: addUtcDays(previousDay, offsetDays + 1), to };
}
@Injectable()
export class RenewalsService {
private readonly logger = new Logger(RenewalsService.name);
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
private readonly audit: AuditService,
) {}
@Cron("0 6 * * *", { timeZone: TIME_ZONE })
async scheduledSweep(): Promise<void> {
try {
await this.sweep();
} catch (error) {
this.logger.error(
`Falló el barrido de renovaciones: ${(error as Error).message}`,
);
}
}
async pending(days = 30) {
const today = dateInTimeZone(new Date());
const state = await this.prisma.scheduledJobState.findUnique({
where: { name: JOB_NAME },
select: { lastSuccessfulAt: true },
});
const cadence = RENEWAL_CADENCE.filter(
(item) => item.offsetDays < 0 || item.offsetDays <= days,
);
const groups = await Promise.all(
cadence.map(async (item) => ({
generation: item.generation,
rows: await this.findCandidates(
item,
today,
state?.lastSuccessfulAt ?? null,
),
})),
);
return groups.flatMap(({ generation, rows }) =>
rows
.filter((policy) => Boolean(policy.customer.email?.trim()))
.map((policy) => toRenewalLetterRow(policy, generation)),
);
}
async sweep(userId?: string) {
const now = new Date();
const state = await this.acquireLock(now);
try {
if (!this.mail.available) {
throw new ServiceUnavailableException(
"El servicio de correo no está configurado.",
);
}
const today = dateInTimeZone(now);
let eligible = 0;
let sent = 0;
let skipped = 0;
const failures: Array<{ policyId: string; generation: number; error: string }> = [];
for (const cadence of RENEWAL_CADENCE) {
const policies = await this.findCandidates(
cadence,
today,
state.lastSuccessfulAt,
);
eligible += policies.length;
for (const policy of policies) {
const to = policy.customer.email?.trim();
if (!to) {
skipped++;
continue;
}
try {
const letter = toRenewalLetterRow(policy, cadence.generation);
const message = renderRenewalEmail(letter);
const result = await this.mail.send({ to, ...message });
const sentAt = new Date();
await this.prisma.renewalNotice.upsert({
where: {
policyId_generation: {
policyId: policy.id,
generation: cadence.generation,
},
},
create: {
policyId: policy.id,
generation: cadence.generation,
channel: "EMAIL",
sentAt,
sentById: userId,
providerMessageId: result.providerId,
},
update: {
channel: "EMAIL",
sentAt,
sentById: userId,
providerMessageId: result.providerId,
},
});
sent++;
void this.audit.log(userId, "renewalNotice.send", {
policyId: policy.id,
generation: cadence.generation,
providerMessageId: result.providerId,
});
} catch (error) {
failures.push({
policyId: policy.id,
generation: cadence.generation,
error: (error as Error).message,
});
}
}
}
const result = { eligible, sent, skipped, failed: failures.length, failures };
await this.releaseLock(failures.length === 0 ? now : null);
void this.audit.log(userId, "renewalNotice.sweep", result);
return result;
} catch (error) {
await this.releaseLock(null);
throw error;
}
}
private findCandidates(
cadence: (typeof RENEWAL_CADENCE)[number],
today: Date,
lastSuccessfulAt: Date | null,
) {
const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt);
return this.prisma.policy.findMany({
where: {
archivedAt: null,
policyTo: { gte: window.from, lte: window.to },
customer: {
archivedAt: null,
emailOptOut: false,
email: { not: "" },
},
renewalNotices: {
none: { generation: cadence.generation, sentAt: { not: null } },
},
},
orderBy: [{ policyTo: "asc" }, { policyNumber: "asc" }],
select: renewalLetterSelect(cadence.generation),
});
}
private async acquireLock(now: Date) {
await this.prisma.scheduledJobState.upsert({
where: { name: JOB_NAME },
create: { name: JOB_NAME },
update: { updatedAt: now },
});
const acquired = await this.prisma.scheduledJobState.updateMany({
where: {
name: JOB_NAME,
OR: [{ lockedUntil: null }, { lockedUntil: { lte: now } }],
},
data: { lockedUntil: new Date(now.getTime() + 2 * 60 * 60 * 1000) },
});
if (acquired.count !== 1) {
throw new ConflictException(
"Ya hay un barrido de renovaciones en curso.",
);
}
return this.prisma.scheduledJobState.findUniqueOrThrow({
where: { name: JOB_NAME },
});
}
private async releaseLock(lastSuccessfulAt: Date | null): Promise<void> {
await this.prisma.scheduledJobState.update({
where: { name: JOB_NAME },
data: {
lockedUntil: null,
...(lastSuccessfulAt && { lastSuccessfulAt }),
},
});
}
}