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.