From 36158ae76178949974903e67a1f44f07dc9e2123 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Thu, 20 Aug 2026 07:24:28 -0700 Subject: [PATCH] feat(notificaciones): sweep one aseguradora at a time, and stop the robot quoting a premium MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The office works GMX and ANA as separate batches, so the manual barrido now takes a "Compañía" selection. It filters the pending list as well as the sweep, so what is on screen is exactly what "Ejecutar barrido" will mail, and the confirmation names the carrier — running GMX when ANA was meant is the mistake the filter exists to prevent, and it is not reversible once the mail is out. The carrier is chosen by InsuranceProvider id, from the same /lookups the policy form reads, so a renamed or newly added aseguradora needs no code change here. A carrier-scoped run deliberately does NOT advance `lastSuccessfulAt`. The sweep's catch-up window is computed from it, so advancing after a run that looked at every day but mailed only one carrier would push every OTHER carrier's letters out of tomorrow's window and they would never be sent. Same reasoning that already keeps a debug run from advancing it. Separately, the letter's "Prima" row is now dropped from the unattended scheduled sweep only. A premium can still be re-rated at renewal, and an amount a robot mailed out is one the office has to walk back; every send a person triggers — the manual barrido and the per-row "Enviar aviso" — still quotes it. `recordLog` renders with the same flag, so `bodySnapshot` cannot show the office a letter the customer never received. Co-Authored-By: Claude Opus 5 --- apps/api/src/renewals/renewal-email.spec.ts | 14 +++ apps/api/src/renewals/renewal-email.ts | 17 ++- apps/api/src/renewals/renewals-log.spec.ts | 44 +++++++ apps/api/src/renewals/renewals.controller.ts | 16 ++- apps/api/src/renewals/renewals.service.ts | 65 +++++++++-- .../src/components/NotificacionesPolizas.tsx | 107 ++++++++++++++---- 6 files changed, 230 insertions(+), 33 deletions(-) diff --git a/apps/api/src/renewals/renewal-email.spec.ts b/apps/api/src/renewals/renewal-email.spec.ts index dd3c8c8..8b1d1f1 100644 --- a/apps/api/src/renewals/renewal-email.spec.ts +++ b/apps/api/src/renewals/renewal-email.spec.ts @@ -46,6 +46,20 @@ describe("renderRenewalEmail", () => { expect(result.html).toContain("Calle Uno 123"); }); + it("omits the premium when the sender did not ask for it", () => { + // The unattended sweep quotes no amount: the premium can still be + // re-rated at renewal, and a number a robot mailed out is one the office + // has to walk back. + const result = renderRenewalEmail(letter(), { includePremium: false }); + + expect(result.html).not.toContain("Prima"); + expect(result.html).not.toContain("1,392.00"); + // Everything else the customer needs is still there. + expect(result.html).toContain("POL-123"); + expect(result.html).toContain("01/09/2026"); + expect(result.html).toContain("Ana Pérez"); + }); + it("uses overdue wording for generation three", () => { const result = renderRenewalEmail(letter({ generation: 3 })); diff --git a/apps/api/src/renewals/renewal-email.ts b/apps/api/src/renewals/renewal-email.ts index 95a0dc4..5af8eb5 100644 --- a/apps/api/src/renewals/renewal-email.ts +++ b/apps/api/src/renewals/renewal-email.ts @@ -34,10 +34,23 @@ function row(label: string, value: string): string { return `${escapeHtml(label)}${escapeHtml(value)}`; } -export function renderRenewalEmail(letter: RenewalLetterRow): { +/** + * Render one renewal letter. + * + * `includePremium` decides whether the "Prima" row appears. The unattended + * sweep sends without it — an amount quoted by a robot, on a premium that may + * still be re-rated at renewal, is a number the office has to walk back — and + * every staff-triggered send (the manual barrido and the per-row "Enviar + * aviso") keeps it, because a person chose to quote it. + */ +export function renderRenewalEmail( + letter: RenewalLetterRow, + options: { includePremium?: boolean } = {}, +): { subject: string; html: string; } { + const includePremium = options.includePremium !== false; const expired = letter.generation === 3; const subject = expired ? `Póliza vencida: ${letter.policyNumber}` @@ -51,7 +64,7 @@ export function renderRenewalEmail(letter: RenewalLetterRow): { row("Tipo de póliza", letter.policyType), row("Aseguradora", letter.provider), row("Fecha de vencimiento", displayDate(letter.policyTo)), - row("Prima", money(premium, letter.currency)), + ...(includePremium ? [row("Prima", money(premium, letter.currency))] : []), row("Cliente", letter.customerName), row("Correo", letter.customerEmail ?? "No disponible"), row("Teléfono", phone), diff --git a/apps/api/src/renewals/renewals-log.spec.ts b/apps/api/src/renewals/renewals-log.spec.ts index 825d246..18c2744 100644 --- a/apps/api/src/renewals/renewals-log.spec.ts +++ b/apps/api/src/renewals/renewals-log.spec.ts @@ -183,6 +183,50 @@ describe("renewal notices write the shared notification log", () => { expect(prisma.renewalNotice.upsert).not.toHaveBeenCalled(); }); + it("quotes the premium on a staff-triggered sweep but not the scheduled one", async () => { + const manual = build({}); + await manual.service.sweep("user-1"); + expect(manual.record.mock.calls[0][0].bodySnapshot).toContain("Prima"); + + const automatic = build({}); + await automatic.service.scheduledSweep(); + const body = automatic.record.mock.calls[0][0].bodySnapshot; + // The snapshot has to match the mail that actually went out, or the + // office reads a letter the customer never received. + expect(body).not.toContain("Prima"); + expect(body).toContain("700442181"); + }); + + it("scopes a sweep to one aseguradora without advancing the catch-up window", async () => { + const { service, prisma, send } = build({}); + + const result = await service.sweep("user-1", { providerId: "gmx-id" }); + + expect(result.sent).toBe(1); + expect(result.providerId).toBe("gmx-id"); + expect(prisma.policy.findMany.mock.calls[0][0].where).toMatchObject({ + insuranceProviderId: "gmx-id", + }); + expect(send).toHaveBeenCalledTimes(1); + // Only one carrier was mailed, so the days this run covered are still owed + // to every other carrier: advancing `lastSuccessfulAt` would move them out + // of tomorrow's window and they would never be sent. + const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0]; + expect(release.data.lastSuccessfulAt).toBeUndefined(); + }); + + it("advances the catch-up window on a clean unfiltered sweep", async () => { + const { service, prisma } = build({}); + + await service.sweep("user-1"); + + expect(prisma.policy.findMany.mock.calls[0][0].where).not.toHaveProperty( + "insuranceProviderId", + ); + const release = prisma.scheduledJobState.update.mock.calls.at(-1)?.[0]; + expect(release.data.lastSuccessfulAt).toBeInstanceOf(Date); + }); + it("does not fail a delivered notice when the log write throws", async () => { const { service, record } = build({}); record.mockRejectedValue(new Error("log table gone")); diff --git a/apps/api/src/renewals/renewals.controller.ts b/apps/api/src/renewals/renewals.controller.ts index aa791ae..de7e604 100644 --- a/apps/api/src/renewals/renewals.controller.ts +++ b/apps/api/src/renewals/renewals.controller.ts @@ -25,6 +25,13 @@ class RenewalFlagsDto { debug?: boolean; } +class SweepRenewalsDto extends RenewalFlagsDto { + /** Sweep one aseguradora only (GMX, ANA, …). Omitted = todas. */ + @IsOptional() + @IsString() + providerId?: string; +} + class SendRenewalDto extends RenewalFlagsDto { @IsString() policyId!: string; @@ -43,17 +50,22 @@ export class RenewalsController { constructor(private readonly renewals: RenewalsService) {} @Get("pending") - pending(@Query("days") days?: string) { + pending( + @Query("days") days?: string, + @Query("providerId") providerId?: string, + ) { return this.renewals.pending( Math.min(365, Math.max(1, Number(days) || 30)), + providerId?.trim() || undefined, ); } @Post("sweep") @RequireAbility("renewal:send") - sweep(@Body() dto: RenewalFlagsDto, @Req() req: Request) { + sweep(@Body() dto: SweepRenewalsDto, @Req() req: Request) { return this.renewals.sweep((req.user as { id: string }).id, { debug: dto?.debug, + providerId: dto?.providerId, }); } diff --git a/apps/api/src/renewals/renewals.service.ts b/apps/api/src/renewals/renewals.service.ts index 89c1288..97cca35 100644 --- a/apps/api/src/renewals/renewals.service.ts +++ b/apps/api/src/renewals/renewals.service.ts @@ -84,10 +84,14 @@ export class RenewalsService implements OnModuleInit { /** The unattended run always sends for real: `debug` is a per-click switch * in the UI, never persisted, so the schedule cannot inherit a forgotten - * test toggle and silently stop mailing customers. */ + * test toggle and silently stop mailing customers. + * + * `automatic` is what drops the premium from the letter — see + * `renderRenewalEmail`. It is set here and nowhere else, so every sweep a + * person clicks still quotes the amount. */ async scheduledSweep(): Promise { try { - await this.sweep(); + await this.sweep(undefined, { automatic: true }); } catch (error) { this.logger.error( `Falló el barrido de renovaciones: ${(error as Error).message}`, @@ -95,7 +99,10 @@ export class RenewalsService implements OnModuleInit { } } - async pending(days = 30) { + /** @param providerId Restrict to one aseguradora. The list has to agree + * with what a sweep would send, or the carrier-scoped barrido shows rows it + * will not mail. */ + async pending(days = 30, providerId?: string) { const today = dateInTimeZone(new Date()); const state = await this.prisma.scheduledJobState.findUnique({ where: { name: JOB_NAME }, @@ -111,6 +118,7 @@ export class RenewalsService implements OnModuleInit { item, today, state?.lastSuccessfulAt ?? null, + providerId, ), })), ); @@ -122,8 +130,20 @@ export class RenewalsService implements OnModuleInit { ); } - async sweep(userId?: string, flags: { debug?: boolean } = {}) { + /** + * @param flags.providerId Sweep only one aseguradora. GMX and ANA are worked + * as separate batches by the office, so mixing them in one run is what this + * exists to prevent. + * @param flags.automatic Set only by the scheduler. Drops the premium from + * the letter. + */ + async sweep( + userId?: string, + flags: { debug?: boolean; providerId?: string; automatic?: boolean } = {}, + ) { const debug = !!flags.debug; + const providerId = flags.providerId?.trim() || undefined; + const includePremium = !flags.automatic; const now = new Date(); const state = await this.acquireLock(now); @@ -145,6 +165,7 @@ export class RenewalsService implements OnModuleInit { cadence, today, state.lastSuccessfulAt, + providerId, ); eligible += policies.length; @@ -157,13 +178,17 @@ export class RenewalsService implements OnModuleInit { await this.recordLog(policy, cadence.generation, "", { status: "SKIPPED_NO_EMAIL", debug, + includePremium, }); skipped++; continue; } try { - await this.deliver(policy, cadence.generation, to, userId, debug); + await this.deliver(policy, cadence.generation, to, userId, { + debug, + includePremium, + }); sent++; } catch (error) { failures.push({ @@ -182,11 +207,18 @@ export class RenewalsService implements OnModuleInit { failed: failures.length, failures, debug, + providerId: providerId ?? null, }; // A debug run must not advance `lastSuccessfulAt`: it wrote no // RenewalNotice rows, so the days it "covered" are still owed, and // narrowing tomorrow's window back to a single day would drop them. - await this.releaseLock(!debug && failures.length === 0 ? now : null); + // + // A carrier-scoped run must not advance it either, for the same reason + // one step out: it looked at the whole window but only mailed one + // aseguradora, so every other carrier's letters in those days would fall + // outside tomorrow's window and never be sent at all. + const complete = !debug && !providerId && failures.length === 0; + await this.releaseLock(complete ? now : null); void this.audit.log(userId, "renewalNotice.sweep", result); return result; } catch (error) { @@ -233,12 +265,14 @@ export class RenewalsService implements OnModuleInit { throw new BadRequestException("El cliente no tiene correo registrado."); } + // A person clicked this, so the premium stays in the letter — only the + // scheduler's unattended run omits it. const { sentAt, providerMessageId, addressedTo } = await this.deliver( policy, generation, to, userId, - debug, + { debug, includePremium: true }, ); return { policyId, @@ -270,10 +304,12 @@ export class RenewalsService implements OnModuleInit { generation: number, to: string, userId?: string, - debug = false, + options: { debug?: boolean; includePremium?: boolean } = {}, ) { + const debug = !!options.debug; + const includePremium = options.includePremium !== false; const letter = toRenewalLetterRow(policy, generation); - const message = renderRenewalEmail(letter); + const message = renderRenewalEmail(letter, { includePremium }); const addressedTo = debug ? DEBUG_RECIPIENT : to; let result: Awaited>; @@ -291,6 +327,7 @@ export class RenewalsService implements OnModuleInit { status: "FAILED", error: detail, debug, + includePremium, }); throw error; } @@ -324,6 +361,7 @@ export class RenewalsService implements OnModuleInit { providerResponse: result.response || undefined, sendDate: sentAt, debug, + includePremium, }); void this.audit.log(userId, "renewalNotice.send", { policyId: policy.id, @@ -355,10 +393,15 @@ export class RenewalsService implements OnModuleInit { error?: string; sendDate?: Date; debug?: boolean; + /** Must match what `deliver` rendered, or `bodySnapshot` shows the + * office a letter the customer never received. */ + includePremium?: boolean; }, ): Promise { const letter = toRenewalLetterRow(policy, generation); - const message = renderRenewalEmail(letter); + const message = renderRenewalEmail(letter, { + includePremium: outcome.includePremium, + }); try { await this.notificationLog.record({ notificationType: "RENEWAL_NOTICE", @@ -391,11 +434,13 @@ export class RenewalsService implements OnModuleInit { cadence: (typeof RENEWAL_CADENCE)[number], today: Date, lastSuccessfulAt: Date | null, + providerId?: string, ) { const window = renewalWindow(today, cadence.offsetDays, lastSuccessfulAt); return this.prisma.policy.findMany({ where: { archivedAt: null, + ...(providerId && { insuranceProviderId: providerId }), policyTo: { gte: window.from, lte: window.to }, customer: { archivedAt: null, diff --git a/apps/web/src/components/NotificacionesPolizas.tsx b/apps/web/src/components/NotificacionesPolizas.tsx index dfc6ad5..b73c04b 100644 --- a/apps/web/src/components/NotificacionesPolizas.tsx +++ b/apps/web/src/components/NotificacionesPolizas.tsx @@ -4,7 +4,13 @@ import { useCallback, useEffect, useState } from "react"; import { useCan } from "@/lib/abilities"; import { formatDate, formatMoney } from "@/lib/labels"; import { NotificationLogPanel } from "@/components/NotificationLogPanel"; -import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api"; +import { + apiFetch, + getLookups, + POLIZAS_LOG_SCOPE, + type NotificationFlags, +} from "@/lib/api"; +import type { ProviderRow } from "@/lib/types"; /** * Renewal notices — the "Pólizas" half of /notificaciones. Shows which @@ -22,6 +28,12 @@ import { apiFetch, POLIZAS_LOG_SCOPE, type NotificationFlags } from "@/lib/api"; * thing here as it does for servicios: the mail is diverted to the override * inbox. It additionally does NOT mark the notice as sent, so a test send * leaves the row exactly where it was — pending. + * + * The barrido manual is scoped by aseguradora because the office works GMX and + * ANA as separate batches. The selection filters the pending list too, so what + * is on screen is exactly what "Ejecutar barrido" will mail. A carrier-scoped + * run deliberately does not advance the sweep's catch-up window — it only + * covered one carrier — so the other carriers' letters stay pending. */ export interface RenewalLetter { @@ -46,6 +58,8 @@ export interface RenewalSweepResult { failed: number; failures: { policyId: string; generation: number; error: string }[]; debug: boolean; + /** Echoed back so the confirmation says which carrier actually ran. */ + providerId: string | null; } export interface RenewalSendResult { @@ -68,6 +82,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) { const allowed = useCan("renewal:send"); const debug = !!flags.debug; const [days, setDays] = useState(30); + /** "" = ambas/todas. Holds an InsuranceProvider id, never a name — carriers + * are renamed in the lookups screen and the filter must survive that. */ + const [providerId, setProviderId] = useState(""); + const [providers, setProviders] = useState([]); const [pending, setPending] = useState(null); const [pendingError, setPendingError] = useState(null); const [actionError, setActionError] = useState(null); @@ -81,8 +99,10 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) { const refresh = useCallback(async () => { setPendingError(null); try { + const params = new URLSearchParams({ days: String(days) }); + if (providerId) params.set("providerId", providerId); const data = await apiFetch( - `/renewals/pending?days=${days}`, + `/renewals/pending?${params.toString()}`, ); setPending(data); } catch (e) { @@ -91,18 +111,44 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) { ); setPending([]); } - }, [days]); + }, [days, providerId]); useEffect(() => { if (allowed) refresh(); }, [allowed, refresh]); + // Carriers come from the same lookups the policy form uses, so a new + // aseguradora shows up here without a code change. + useEffect(() => { + if (!allowed) return; + let cancelled = false; + getLookups() + .then((data) => { + if (!cancelled) setProviders(data.providers); + }) + .catch(() => { + // A failed lookup only costs the filter; the unfiltered sweep still + // works, so this must not blank the screen. + if (!cancelled) setProviders([]); + }); + return () => { + cancelled = true; + }; + }, [allowed]); + + const providerLabel = + providers.find((item) => item.id === providerId)?.name ?? "todas las compañías"; + async function handleSweep() { // Only worth confirming when debug is off — that is the case where real // customers receive mail. Mirrors "Ejecutar todos" on the servicios tab. + // The carrier is named in the prompt: running GMX when ANA was meant is + // exactly the mistake this filter exists to prevent, and it is not + // reversible once the mail is out. if (!debug) { const ok = window.confirm( - "debug está desactivado: los avisos irán a los correos reales de los clientes. ¿Ejecutar el barrido?", + `debug está desactivado: los avisos irán a los correos reales de los clientes. ` + + `¿Ejecutar el barrido de ${providerLabel}?`, ); if (!ok) return; } @@ -112,10 +158,11 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) { try { const result = await apiFetch("/renewals/sweep", { method: "POST", - body: JSON.stringify({ debug }), + body: JSON.stringify({ debug, providerId: providerId || undefined }), }); setNotice( - `Enviados ${result.sent} avisos (${result.failed} con error).` + + `Enviados ${result.sent} avisos de ${providerLabel} ` + + `(${result.failed} con error).` + (result.debug ? " Modo debug: fueron al buzón de pruebas y siguen pendientes." : ""), @@ -199,7 +246,9 @@ export function NotificacionesPolizas({ flags }: { flags: NotificationFlags }) {

Barrido manual

Usa la fecha actual del servidor como referencia para seleccionar - avisos vencidos a 30 y 15 días, y vencidos hace 7 días. + avisos vencidos a 30 y 15 días, y vencidos hace 7 días. La + compañía elegida filtra también la lista de abajo: se envía + exactamente lo que está en pantalla.