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,139 @@
|
||||
import { Prisma } from "@jorgecuadros/database";
|
||||
|
||||
export function renewalLetterSelect(generation: number) {
|
||||
return Prisma.validator<Prisma.PolicySelect>()({
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
policyFee: true,
|
||||
total: true,
|
||||
currency: true,
|
||||
coveragesJson: true,
|
||||
customer: {
|
||||
select: {
|
||||
name: true,
|
||||
nameMissing: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
addressLine1: true,
|
||||
addressLine2: true,
|
||||
city: true,
|
||||
state: true,
|
||||
zipCode: true,
|
||||
country: true,
|
||||
},
|
||||
},
|
||||
policyType: { select: { name: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
vehicles: {
|
||||
take: 1,
|
||||
select: {
|
||||
make: true,
|
||||
model: true,
|
||||
modelYear: true,
|
||||
bodyType: true,
|
||||
engineNumber: true,
|
||||
licensePlate: true,
|
||||
},
|
||||
},
|
||||
renewalNotices: {
|
||||
where: { generation },
|
||||
select: { sentAt: true, channel: true },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type RenewalLetterPolicy = Prisma.PolicyGetPayload<{
|
||||
select: ReturnType<typeof renewalLetterSelect>;
|
||||
}>;
|
||||
|
||||
export interface RenewalLetterRow extends Record<string, unknown> {
|
||||
__kind: "letter";
|
||||
policyId: string;
|
||||
policyNumber: string;
|
||||
policyType: string;
|
||||
customerName: string;
|
||||
customerEmail: string | null;
|
||||
customerPhone: string | null;
|
||||
customerMobile: string | null;
|
||||
customerAddress: string[];
|
||||
provider: string;
|
||||
policyTo: string;
|
||||
netPremium: string | null;
|
||||
policyFee: string | null;
|
||||
total: string | null;
|
||||
currency: string;
|
||||
coverageDays: unknown;
|
||||
cslLimit: unknown;
|
||||
medicalCoverage: unknown;
|
||||
propertyDamage: unknown;
|
||||
perPersonLiability: unknown;
|
||||
additionalService: unknown;
|
||||
vehicle: {
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
modelYear: string | null;
|
||||
bodyType: string | null;
|
||||
engineNumber: string | null;
|
||||
licensePlate: string | null;
|
||||
} | null;
|
||||
generation: number;
|
||||
sentAt: string | null;
|
||||
}
|
||||
|
||||
export function toRenewalLetterRow(
|
||||
policy: RenewalLetterPolicy,
|
||||
generation: number,
|
||||
): RenewalLetterRow {
|
||||
const notice = policy.renewalNotices[0];
|
||||
const coverage = (policy.coveragesJson ?? {}) as Record<string, unknown>;
|
||||
const address = [
|
||||
policy.customer.addressLine1,
|
||||
policy.customer.addressLine2,
|
||||
[policy.customer.city, policy.customer.state, policy.customer.zipCode]
|
||||
.filter(Boolean)
|
||||
.join(", "),
|
||||
policy.customer.country,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
return {
|
||||
__kind: "letter",
|
||||
policyId: policy.id,
|
||||
policyNumber: policy.policyNumber,
|
||||
policyType: policy.policyType?.name ?? "—",
|
||||
customerName: policy.customer.nameMissing ? "(sin nombre)" : policy.customer.name,
|
||||
customerEmail: policy.customer.email,
|
||||
customerPhone: policy.customer.phone,
|
||||
customerMobile: policy.customer.mobile,
|
||||
customerAddress: address,
|
||||
provider: policy.insuranceProvider?.name ?? "—",
|
||||
policyTo: policy.policyTo ? policy.policyTo.toISOString().slice(0, 10) : "—",
|
||||
netPremium: policy.netPremium ? policy.netPremium.toFixed(2) : null,
|
||||
policyFee: policy.policyFee ? policy.policyFee.toFixed(2) : null,
|
||||
total: policy.total ? policy.total.toFixed(2) : null,
|
||||
currency: policy.currency,
|
||||
coverageDays: coverage.cobertura ?? null,
|
||||
cslLimit: coverage.csl_limite ?? null,
|
||||
medicalCoverage: coverage.gastos_medico ?? null,
|
||||
propertyDamage: coverage.propiedades ?? null,
|
||||
perPersonLiability: coverage.personas ?? null,
|
||||
additionalService:
|
||||
coverage.servicio_adicional ?? coverage.servicio_adiconal ?? null,
|
||||
vehicle: policy.vehicles[0]
|
||||
? {
|
||||
make: policy.vehicles[0].make,
|
||||
model: policy.vehicles[0].model,
|
||||
modelYear: policy.vehicles[0].modelYear,
|
||||
bodyType: policy.vehicles[0].bodyType,
|
||||
engineNumber: policy.vehicles[0].engineNumber,
|
||||
licensePlate: policy.vehicles[0].licensePlate,
|
||||
}
|
||||
: null,
|
||||
generation,
|
||||
sentAt: notice?.sentAt
|
||||
? notice.sentAt.toISOString().slice(0, 10)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
parseDate,
|
||||
type ReportDef,
|
||||
} from "./reports.types";
|
||||
import {
|
||||
renewalLetterSelect,
|
||||
toRenewalLetterRow,
|
||||
} from "./renewal-letter";
|
||||
|
||||
/* ------------------------------------------------------------------ helpers */
|
||||
|
||||
@@ -615,10 +619,7 @@ const vigente: ReportDef = {
|
||||
* covers every carrier and tier instead of a clone per combination.
|
||||
*
|
||||
* `sentStatus` is read from `RenewalNotice` (schema.prisma) — the
|
||||
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log
|
||||
* — but this report is read-only; marking a notice as sent is a separate
|
||||
* mutation (not yet built) that would upsert `RenewalNotice` by
|
||||
* `[policyId, generation]`.
|
||||
* replacement for the legacy `CONTROL <ramo> RENEW[2/3] X MES` paper log.
|
||||
*/
|
||||
const avisoRenovacion: ReportDef = {
|
||||
slug: "aviso-renovacion",
|
||||
@@ -711,78 +712,16 @@ const avisoRenovacion: ReportDef = {
|
||||
: {}),
|
||||
},
|
||||
orderBy: { policyTo: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
policyNumber: true,
|
||||
policyTo: true,
|
||||
netPremium: true,
|
||||
policyFee: true,
|
||||
total: true,
|
||||
currency: true,
|
||||
coveragesJson: true,
|
||||
customer: { select: { name: true, nameMissing: true } },
|
||||
insuranceProvider: { select: { name: true } },
|
||||
vehicles: {
|
||||
take: 1,
|
||||
select: {
|
||||
make: true,
|
||||
model: true,
|
||||
modelYear: true,
|
||||
bodyType: true,
|
||||
engineNumber: true,
|
||||
licensePlate: true,
|
||||
},
|
||||
},
|
||||
renewalNotices: {
|
||||
where: { generation },
|
||||
select: { sentAt: true, channel: true },
|
||||
},
|
||||
},
|
||||
select: renewalLetterSelect(generation),
|
||||
});
|
||||
|
||||
let totalPremium = new Prisma.Decimal(0);
|
||||
let sentCount = 0;
|
||||
const out = rows.map((r) => {
|
||||
if (r.netPremium) totalPremium = totalPremium.plus(r.netPremium);
|
||||
const notice = r.renewalNotices[0];
|
||||
if (notice?.sentAt) sentCount++;
|
||||
// Legacy coverage columns not modeled as first-class Policy fields —
|
||||
// see docs/RENEWAL_NOTICES.md's column-mapping table. Keys are best-
|
||||
// effort (derived from the source schema, not yet verified against a
|
||||
// live migrated DB) — confirm before relying on them in production.
|
||||
const cov = (r.coveragesJson ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
__kind: "letter",
|
||||
policyId: r.id,
|
||||
policyNumber: r.policyNumber,
|
||||
customerName: nameOf(r.customer),
|
||||
provider: r.insuranceProvider?.name ?? "—",
|
||||
policyTo: r.policyTo ? r.policyTo.toISOString().slice(0, 10) : "—",
|
||||
netPremium: r.netPremium ? r.netPremium.toFixed(2) : null,
|
||||
policyFee: r.policyFee ? r.policyFee.toFixed(2) : null,
|
||||
total: r.total ? r.total.toFixed(2) : null,
|
||||
currency: r.currency,
|
||||
coverageDays: cov.cobertura ?? null,
|
||||
cslLimit: cov.csl_limite ?? null,
|
||||
medicalCoverage: cov.gastos_medico ?? null,
|
||||
propertyDamage: cov.propiedades ?? null,
|
||||
perPersonLiability: cov.personas ?? null,
|
||||
additionalService: cov.servicio_adicional ?? cov.servicio_adiconal ?? null,
|
||||
vehicle: r.vehicles[0]
|
||||
? {
|
||||
make: r.vehicles[0].make,
|
||||
model: r.vehicles[0].model,
|
||||
modelYear: r.vehicles[0].modelYear,
|
||||
bodyType: r.vehicles[0].bodyType,
|
||||
engineNumber: r.vehicles[0].engineNumber,
|
||||
licensePlate: r.vehicles[0].licensePlate,
|
||||
}
|
||||
: null,
|
||||
generation,
|
||||
sentAt: notice?.sentAt
|
||||
? notice.sentAt.toISOString().slice(0, 10)
|
||||
: null,
|
||||
};
|
||||
const letter = toRenewalLetterRow(r, generation);
|
||||
if (letter.sentAt) sentCount++;
|
||||
return letter;
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user