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.