import { Injectable, Logger, OnModuleInit, ServiceUnavailableException, } from "@nestjs/common"; import { Currency, EmailNotificationServicio, EmailNotificationStatus, EmailNotificationType, Prisma, TransactionDomain, } from "@jorgecuadros/database"; import { MailService } from "../mail/mail.service"; import { PrismaService } from "../prisma/prisma.service"; import { SettingsService } from "../settings/settings.service"; import { NotificationLogService } from "./notification-log.service"; import { NotificationScheduleService } from "./notification-schedule.service"; import { DEBUG_RECIPIENT, SendAttempt, NotificationJobKind, NotificationJobResponse, NotificationRunAllJobResult, NotificationRunAllResponse, AttemptStatus, } from "./notification.types"; import { renderOutstanding, renderPaymentConfirm, renderAccountStatus, renderTrustConfirm, } from "./render"; /** * Mass email notifications — the modern replacement for the four PHP * scripts under `email.notifications/send*.php`. One service, four job * methods, identical wire shape to the legacy scripts (so a log scraper * parsing the JSON response keeps working — see * `~/Documents/Claude-Memory/email-notifications-spec.md`). * * ── Recipient selection ──────────────────────────────────────────────────── * Job 1 (Outstanding): customers with at least one Transaction where * `outstanding = true` and the movement is a charge * (amount < 0). Equivalent to legacy * `datosfreak WHERE NOPAGO = 1`. * Job 2 (PaymentConf): customers with a credit (amount > 0) posted in * the last 24 hours. Equivalent to legacy * `pagosemail` view, which the ETL refreshed daily * from the same predicate. * Job 3 (AccountStatus): every customer with a non-null email; per row * the balance = SUM(transactions.amount) per * currency, excluding voided + outstanding — same * arithmetic `BillingService.balances()` uses, so * the yellow/red alert lines up with what the * receivables worklist already shows staff. * Job 4 (TrustConfirm): customers with a `TrustAccount` whose email is * set, where the latest trust-domain credit was * posted in the last 24 hours. The trust-fee ETL * used to push one row per annual fee payment. * * ── Audit log ────────────────────────────────────────────────────────────── * Every send attempt (sent, failed, or skipped) writes one row to * `email_notification_log`. Job 3 additionally writes one row per * threshold hit to `account_status_history`, mirroring the legacy * `utility_dbo.send_account_status_history` table verbatim. * * ── Day-of-week gates (Job 3) ────────────────────────────────────────────── * Yellow: Wed only (or `ignoreDayRestriction`). * Red: Mon/Wed/Fri only (or `ignoreDayRestriction`). * A customer who is red on a Tuesday is skipped (SKIPPED_GATE) until Wed, * when both checks can run on the same row — keeps the on-demand behavior * in lock-step with the legacy script. */ const ONE_HOUR_MS = 60 * 60 * 1000; const RATE_LIMIT_EMAILS = 100; const PAYMENT_LOOKBACK_HOURS = 24; @Injectable() export class NotificationsService implements OnModuleInit { private readonly logger = new Logger(NotificationsService.name); constructor( private readonly prisma: PrismaService, private readonly mail: MailService, private readonly log: NotificationLogService, private readonly settings: SettingsService, private readonly schedule: NotificationScheduleService, ) {} /** The automatic servicios sweep is the same "ejecutar todos" the button * fires. Off by default — see the defaults in `NotificationScheduleService`. */ async onModuleInit(): Promise { await this.schedule.register("servicios", () => this.scheduledRunAll()); } /** * Unattended run of all four jobs. Never debug, and never * `ignoreDayRestriction`: an automatic run on the operator's own cadence is * exactly the case the Mon/Wed/Fri gate was written for, so bypassing it * here would mail the red list every single scheduled day. */ async scheduledRunAll(): Promise { const result = await this.runAll({}); this.logger.log( `Corrida programada de servicios: enviados ${result.sent}, ` + `omitidos ${result.skipped}, fallidos ${result.failed}, ` + `jobs con error ${result.errors}.`, ); } /* ============================================================================ * Public jobs — called by the controller, the schedule, and tests alike. * ========================================================================== */ /** Job 1 — Outstanding payments. */ async runOutstandingPayments(flags: { debug?: boolean; ignoreDayRestriction?: boolean; useEmailLimit?: boolean; }): Promise { const debug = !!flags.debug; const candidates = await this.prisma.customer.findMany({ where: { email: { not: null }, archivedAt: null, transactions: { some: { outstanding: true, amount: { lt: 0 }, voidedAt: null, }, }, }, select: { id: true, name: true, email: true, transactions: { where: { outstanding: true, amount: { lt: 0 }, voidedAt: null }, orderBy: { transactionDate: "asc" }, select: { id: true, transactionDate: true, reference: true, period: true, amount: true, type: { select: { nameEn: true } }, }, }, }, orderBy: { id: "asc" }, }); let sent = 0; let skipped = 0; let failed = 0; for (const c of candidates) { const email = debug ? this.debugEmail() : (c.email ?? "").toLowerCase(); if (!email || !email.includes("@")) { await this.recordAttempt({ notificationType: "OUTSTANDING_PAYMENT", servicio: "CUSTOMERS", customerId: c.id, customerName: c.name, customerEmail: email || "(missing)", subject: "Jorge Cuadros - Outstanding Payments", bodySnapshot: "(skipped: no email)", status: "SKIPPED_NO_EMAIL", debug, }); skipped++; continue; } const total = c.transactions.reduce( (acc, t) => acc.plus(t.amount), new Prisma.Decimal(0), ); let running = new Prisma.Decimal(0); const rows = c.transactions.map((t) => { running = running.plus(t.amount); return { date: t.transactionDate.toISOString().slice(0, 10), reference: t.reference, period: t.period, type: t.type?.nameEn ?? null, amount: t.amount.toFixed(2), balance: running.toFixed(2), }; }); const body = renderOutstanding({ customerId: c.id, customerName: c.name, total: total.abs().toFixed(2), rows, year: new Date().getFullYear(), }); const attempt = await this.deliver({ notificationType: "OUTSTANDING_PAYMENT", servicio: "CUSTOMERS", customerId: c.id, customerName: c.name, customerEmail: email, subject: "Jorge Cuadros - Outstanding Payments", bodySnapshot: body, debug, }); if (attempt === "SENT") sent++; else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++; else failed++; } const response: NotificationJobResponse = { result: "success", notificationType: "sendPaymentConfirmation", reason: `sent confirmation ${sent} emails`, statusCode: 200, sent, skipped, failed, debug, type: "OUTSTANDING_PAYMENT", }; await this.adminSummary("Jorge Cuadros - Outstanding Payments", response); return response; } /** Job 2 — Payment confirmation. One email per customer whose latest * credit (positive amount Transaction) landed in the last 24h. */ async runPaymentConfirmation(flags: { debug?: boolean; ignoreDayRestriction?: boolean; useEmailLimit?: boolean; }): Promise { const debug = !!flags.debug; const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000); // Find customers with a credit in the window. We then pick the most // recent credit per customer; if multiple, we send one summary per // customer (the PHP script also sent one per customer, picking the // row the `pagosemail` view exposed for that NUMid). const credits = await this.prisma.transaction.findMany({ where: { amount: { gt: 0 }, voidedAt: null, transactionDate: { gte: since }, customer: { archivedAt: null }, }, orderBy: { transactionDate: "desc" }, select: { id: true, transactionDate: true, reference: true, amount: true, currency: true, type: { select: { nameEn: true, nameEs: true } }, customer: { select: { id: true, name: true, email: true, preferredCurrency: true }, }, }, }); // One row per customer (most recent credit wins). const byCustomer = new Map< string, (typeof credits)[number] >(); for (const t of credits) { if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t); } const total = byCustomer.size; let sent = 0; let skipped = 0; let failed = 0; for (const [, t] of byCustomer) { const email = debug ? this.debugEmail() : (t.customer.email ?? "").toLowerCase(); if (!email || !email.includes("@")) { await this.recordAttempt({ notificationType: "PAYMENT_CONFIRMATION", servicio: "CUSTOMERS", customerId: t.customer.id, customerName: t.customer.name, customerEmail: email || "(missing)", subject: "Jorge Cuadros - Payment Confirmation", bodySnapshot: "(skipped: no email)", status: "SKIPPED_NO_EMAIL", debug, }); skipped++; continue; } const typeOfTrx = t.type?.nameEn ?? t.type?.nameEs ?? "PAYMENT"; const body = renderPaymentConfirm({ customerId: t.customer.id, customerName: t.customer.name, typeOfTrx, reference: t.reference, amount: t.amount.toFixed(2), year: new Date().getFullYear(), }); // The PHP script stored the per-customer URL on the log row verbatim; // we preserve the convention with a synthetic URL string. This is the // single piece of legacy data the new log carries that does not come // from a real fetch — a tag, not a request. const bodyRequestUrl = `payment-confirmation://${t.customer.id}/${encodeURIComponent( typeOfTrx, )}/${t.id}`; const attempt = await this.deliver({ notificationType: "PAYMENT_CONFIRMATION", servicio: "CUSTOMERS", customerId: t.customer.id, customerName: t.customer.name, customerEmail: email, subject: "Jorge Cuadros - Payment Confirmation", bodySnapshot: body, bodyRequestUrl, debug, }); if (attempt === "SENT") sent++; else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++; else failed++; } const response: NotificationJobResponse = { request: "success", notificationType: "sendPaymentConfirmation", confirmationSent: `${sent} of ${total}`, statusCode: 200, sent, skipped, failed, debug, type: "PAYMENT_CONFIRMATION", }; await this.adminSummary("Jorge Cuadros - Payment Confirmations Sent", response); return response; } /** Job 3 — Account status. Day gates + per-currency balance + dual * threshold (yellow/red). The most complex of the four jobs. */ async runAccountStatus(flags: { debug?: boolean; ignoreDayRestriction?: boolean; useEmailLimit?: boolean; }): Promise { const debug = !!flags.debug; const ignoreDayRestriction = !!flags.ignoreDayRestriction; const useEmailLimit = !!flags.useEmailLimit; const today = new Date() .toLocaleString("en-US", { weekday: "short", timeZone: "America/Tijuana" }) .slice(0, 3) as "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; const redDay = today === "Mon" || today === "Wed" || today === "Fri"; const yellowDay = today === "Wed"; const gateOpen = ignoreDayRestriction || redDay || yellowDay; // Pull every customer with a non-empty email and at least one movement // that contributes to the balance (voided + outstanding excluded, same // as `BillingService.balances()`). const rows = await this.prisma.$queryRaw< { id: string; name: string; email: string; balanceMxn: Prisma.Decimal | null; balanceUsd: Prisma.Decimal | null; }[] >` SELECT c.id, c.name, c.email, SUM(CASE WHEN t.currency = 'MXN' THEN t.amount ELSE 0 END) AS balanceMxn, SUM(CASE WHEN t.currency = 'USD' THEN t.amount ELSE 0 END) AS balanceUsd FROM customers c JOIN transactions t ON t.customerId = c.id WHERE c.archivedAt IS NULL AND c.email IS NOT NULL AND c.email <> '' AND t.voidedAt IS NULL AND t.outstanding = 0 GROUP BY c.id, c.name, c.email `; const total = rows.length; let red = 0; let yellow = 0; let sent = 0; let failed = 0; let skipped = 0; let emailsThisRun = 0; for (const row of rows) { // Skip customers with no email — should never happen because of the // WHERE clause, but defends against a row that gets archived between // the SQL and the loop. const rawEmail = row.email ?? ""; const email = debug ? this.debugEmail() : rawEmail.toLowerCase(); if (!email || !email.includes("@")) { await this.recordAttempt({ notificationType: "ACCOUNT_STATUS", servicio: "CUSTOMERS", customerId: row.id, customerName: row.name, customerEmail: rawEmail || "(missing)", subject: "Jorge Cuadros - Account Status Alert", bodySnapshot: "(skipped: no email)", status: "SKIPPED_NO_EMAIL", debug, }); skipped++; continue; } // The unified ledger is per-currency and never collapsed. The legacy // `TIPO` mapped 1:1 to USD (TIPO=50 / 100 / 200 / 300 / 500 were all // USD thresholds with TIPO/TIPODECAMBIO implied). The simplest port // is: convert every customer's MXN balance to USD using today's // effective rate and apply the USD thresholds; a customer whose // balance is genuinely USD-denominated uses balanceUsd directly. // For the office's actual data this is "report in USD", which is // what `Customer.minimumBalance` was set up for. const mxn = row.balanceMxn ? Number(row.balanceMxn.toString()) : 0; const usd = row.balanceUsd ? Number(row.balanceUsd.toString()) : 0; // Pick the currency that holds the bulk of the debt: USD preferred // because the legacy letter was always USD. const balance = usd !== 0 ? usd : mxn; // Apply both thresholds (yellow + red). The PHP script sent both // on a Wed: yellow + red, two emails, two log rows. We match that. // Customer.minimumBalance is the new-schema replacement for TIPO. const minBalance = await this.customerMinimum(row.id, balance); // RED first: a negative balance is always red. The yellow check // requires balance >= 0, so the two never co-fire on the same row. let firedRed = false; let firedYellow = false; if (balance < 0) { if (!redDay && !ignoreDayRestriction) { await this.recordAttempt({ notificationType: "ACCOUNT_STATUS", level: 1, servicio: "CUSTOMERS", customerId: row.id, customerName: row.name, customerEmail: email, subject: "Jorge Cuadros - Account Status Alert", bodySnapshot: "(skipped: red day gate)", status: "SKIPPED_GATE", debug, }); skipped++; } else { const ok = await this.sendAccountStatusAlert({ customerId: row.id, customerName: row.name, customerEmail: email, balance, level: 1, tipo: balance, // red: "rush this much USD" minBalance, debug, }); if (ok) { red++; sent++; firedRed = true; } else { failed++; } } } if (balance >= 0 && minBalance !== null && balance < minBalance) { if (!yellowDay && !ignoreDayRestriction) { await this.recordAttempt({ notificationType: "ACCOUNT_STATUS", level: 0, servicio: "CUSTOMERS", customerId: row.id, customerName: row.name, customerEmail: email, subject: "Jorge Cuadros - Account Status Alert", bodySnapshot: "(skipped: yellow day gate)", status: "SKIPPED_GATE", debug, }); skipped++; } else { const ok = await this.sendAccountStatusAlert({ customerId: row.id, customerName: row.name, customerEmail: email, balance, level: 0, tipo: minBalance - balance, // yellow: "top up to minimum" minBalance, debug, }); if (ok) { yellow++; sent++; firedYellow = true; } else { failed++; } } } if (!firedRed && !firedYellow) continue; // Vestigial SMTP-era throttle (preserved for parity). Off by default; // the controller wires useEmailLimit=true only when staff opt in. if (useEmailLimit) { emailsThisRun += firedRed ? 1 : 0; emailsThisRun += firedYellow ? 1 : 0; if (emailsThisRun >= RATE_LIMIT_EMAILS) { this.logger.warn("SMTP email send limit reached, sleeping 1 hour."); await new Promise((r) => setTimeout(r, ONE_HOUR_MS)); emailsThisRun = 0; this.logger.warn("Resuming send account status emails!"); } } } const response: NotificationJobResponse = { request: "success", notificationType: "sendAccountStatus", statusSent: `Sent ${red} Red Emails and ${yellow} Yellow Emails of ${total} customers.`, statusReport: "https://cpanel.jorgecuadros.com/show_email_log.php", statusCode: 200, red, yellow, total, sent, skipped, failed, debug, type: "ACCOUNT_STATUS", }; await this.adminSummary("Jorge Cuadros - Account Status Alerts Sent", response); return response; } /** Job 4 — Trust payment confirmation. */ async runTrustConfirmation(flags: { debug?: boolean; ignoreDayRestriction?: boolean; useEmailLimit?: boolean; }): Promise { const debug = !!flags.debug; const since = new Date(Date.now() - PAYMENT_LOOKBACK_HOURS * 3600_000); // Latest credit per TrustAccount-bearing customer in the lookback // window. The PHP script pulled from `TRUSTHFEE` directly; the // equivalent here is: customer owns a property with a trust account // AND has a credit in the trust domain in the last 24h. const credits = await this.prisma.transaction.findMany({ where: { amount: { gt: 0 }, domain: TransactionDomain.TRUST, voidedAt: null, transactionDate: { gte: since }, customer: { archivedAt: null }, }, orderBy: { transactionDate: "desc" }, select: { id: true, amount: true, currency: true, customer: { select: { id: true, name: true, email: true, properties: { where: { archivedAt: null }, select: { trustAccount: { select: { id: true, trustNumber: true } }, }, }, }, }, }, }); // One row per customer (most recent credit wins). Drop customers with // no trust account — they have nothing to confirm against. const byCustomer = new Map< string, (typeof credits)[number] >(); for (const t of credits) { if (!t.customer.properties.some((p) => p.trustAccount)) continue; if (!byCustomer.has(t.customer.id)) byCustomer.set(t.customer.id, t); } const total = byCustomer.size; let sent = 0; let skipped = 0; let failed = 0; for (const [, t] of byCustomer) { const email = debug ? this.debugEmail() : (t.customer.email ?? "").toLowerCase(); if (!email || !email.includes("@")) { await this.recordAttempt({ notificationType: "TRUST_PAYMENT_CONFIRMATION", servicio: "TRUST", customerId: t.customer.id, customerName: t.customer.name, customerEmail: email || "(missing)", subject: "Jorge Cuadros - Trust Payment Confirmation", bodySnapshot: "(skipped: no email)", status: "SKIPPED_NO_EMAIL", debug, }); skipped++; continue; } const body = renderTrustConfirm({ customerId: t.customer.id, customerName: t.customer.name, amount: t.amount.toFixed(2), year: new Date().getFullYear(), }); const attempt = await this.deliver({ notificationType: "TRUST_PAYMENT_CONFIRMATION", servicio: "TRUST", customerId: t.customer.id, customerName: t.customer.name, customerEmail: email, subject: "Jorge Cuadros - Trust Payment Confirmation", bodySnapshot: body, debug, }); if (attempt === "SENT") sent++; else if (attempt === "SKIPPED_NO_EMAIL" || attempt === "SKIPPED_GATE") skipped++; else failed++; } const response: NotificationJobResponse = { request: "success", notificationType: "sendTrustPaymentConfirmation", confirmationSent: `${sent} of ${total}`, statusCode: 200, sent, skipped, failed, debug, type: "TRUST_PAYMENT_CONFIRMATION", }; await this.adminSummary("Jorge Cuadros - Trust Payment Confirmations Sent", response); return response; } /** * "Ejecutar todos" — run all four jobs back to back with one set of flags. * * Sequential on purpose: the jobs share the SES transport and Job 3 can * self-throttle via `useEmailLimit`, so firing them in parallel would both * defeat that pause and interleave `email_notification_log` writes for no * gain. A job that throws is captured and the sweep continues — one bad * query must not swallow the other three envíos. */ async runAll(flags: { debug?: boolean; ignoreDayRestriction?: boolean; useEmailLimit?: boolean; }): Promise { const debug = !!flags.debug; const steps: { kind: NotificationJobKind; run: () => Promise; }[] = [ { kind: "outstanding", run: () => this.runOutstandingPayments(flags) }, { kind: "payment", run: () => this.runPaymentConfirmation(flags) }, { kind: "account", run: () => this.runAccountStatus(flags) }, { kind: "trust", run: () => this.runTrustConfirmation(flags) }, ]; const jobs: NotificationRunAllJobResult[] = []; let sent = 0; let skipped = 0; let failed = 0; let errors = 0; for (const step of steps) { try { const result = await step.run(); sent += result.sent; skipped += result.skipped; failed += result.failed; jobs.push({ kind: step.kind, ok: true, result }); } catch (e) { errors++; const message = e instanceof Error ? e.message : String(e); this.logger.error(`run-all: job ${step.kind} failed — ${message}`); jobs.push({ kind: step.kind, ok: false, error: message }); } } return { request: "success", notificationType: "runAllNotifications", statusCode: 200, debug, sent, skipped, failed, errors, jobs, type: "RUN_ALL", }; } /* ============================================================================ * Log browser — list / drill-down for the UI. * ========================================================================== */ /** Recent notification log rows, newest first, with optional filters. */ async listLog(params: { page: number; pageSize: number; type?: EmailNotificationType; /** Empty/omitted = every servicio. The /notificaciones tabs pass their * own slice (Servicios: CUSTOMERS+TRUST, Pólizas: POLICIES). */ servicio?: EmailNotificationServicio[]; status?: EmailNotificationStatus[]; customerId?: string; }) { const where: Prisma.EmailNotificationLogWhereInput = {}; if (params.type) where.notificationType = params.type; if (params.servicio?.length) where.servicio = { in: params.servicio }; if (params.status?.length) where.status = { in: params.status }; if (params.customerId) where.customerId = params.customerId; const [total, rows] = await this.prisma.$transaction([ this.prisma.emailNotificationLog.count({ where }), this.prisma.emailNotificationLog.findMany({ where, orderBy: { sendDate: "desc" }, skip: (params.page - 1) * params.pageSize, take: params.pageSize, select: { id: true, sendDate: true, notificationType: true, level: true, servicio: true, customerId: true, customerName: true, customerEmail: true, subject: true, debug: true, status: true, providerMessageId: true, error: true, }, }), ]); return { items: rows, total, page: params.page, pageSize: params.pageSize, pageCount: Math.ceil(total / params.pageSize), }; } /** Per-type + per-status counts for the dashboard header. Scoped by * servicio so each /notificaciones tab reports its own totals instead of * the whole platform's. */ async stats(servicio?: EmailNotificationServicio[]) { const where: Prisma.EmailNotificationLogWhereInput = servicio?.length ? { servicio: { in: servicio } } : {}; const [byType, byStatus, byServicio, lastRun] = await Promise.all([ this.prisma.emailNotificationLog.groupBy({ by: ["notificationType", "status"], where, _count: { _all: true }, }), this.prisma.emailNotificationLog.groupBy({ by: ["status"], where, _count: { _all: true }, }), this.prisma.emailNotificationLog.groupBy({ by: ["servicio", "status"], where, _count: { _all: true }, }), this.prisma.emailNotificationLog.findFirst({ where, orderBy: { sendDate: "desc" }, select: { sendDate: true, notificationType: true }, }), ]); return { byType, byStatus, byServicio, lastRun, transport: { available: this.mail.available, devFallback: this.mail.isDevFallback, }, }; } /* ============================================================================ * Internals — send / log / balance helpers. * ========================================================================== */ /** Where debug=1 sends everything. Shared with the renewal sweep so both * halves of /notificaciones divert to the same inbox. */ private debugEmail(): string { return DEBUG_RECIPIENT; } /** The customer's `minimumBalance`, or null if unset. The legacy TIPO * was 50/100/200/300/500; the new schema encodes this as * `Customer.minimumBalance` (the office already sets it per row). */ private async customerMinimum( customerId: string, _balance: number, ): Promise { const c = await this.prisma.customer.findUnique({ where: { id: customerId }, select: { minimumBalance: true }, }); if (!c?.minimumBalance) return null; const m = Number(c.minimumBalance.toString()); return isFinite(m) && m > 0 ? m : null; } /** Send one account-status alert + write the parallel history row. */ private async sendAccountStatusAlert(args: { customerId: string; customerName: string; customerEmail: string; balance: number; tipo: number; level: 0 | 1; minBalance: number | null; debug: boolean; }): Promise { const body = renderAccountStatus({ customerId: args.customerId, customerName: args.customerName, level: args.level, balance: args.balance.toFixed(2), tipo: args.tipo.toFixed(2), year: new Date().getFullYear(), }); const status = await this.deliver({ notificationType: "ACCOUNT_STATUS", level: args.level, servicio: "CUSTOMERS", customerId: args.customerId, customerName: args.customerName, customerEmail: args.customerEmail, subject: "Jorge Cuadros - Account Status Alert", bodySnapshot: body, debug: args.debug, }); if (status === "SENT") { // Mirrors `utility_dbo.send_account_status_history`. The legacy // SOLICITADO formula was `0 - TIPO - BALANCE`; for yellow that // simplifies to `minBalance - balance` (top-up amount), for red to // `|balance|` (rush amount). We preserve the legacy `tipo` column // as the human-readable label so downstream reports keep working. await this.prisma.accountStatusHistory.create({ data: { customerId: args.customerId, customerName: args.customerName, customerEmail: args.customerEmail, tipo: args.level === 0 ? "DEBAJO DEL TIPO" : "EN ROJO", balance: new Prisma.Decimal(args.balance.toFixed(2)), solicitado: new Prisma.Decimal( (args.level === 0 ? args.minBalance! - args.balance : Math.abs(args.balance) ).toFixed(2), ), level: args.level, }, }); return true; } return false; } /** Send one email + write the log row. Returns the final attempt * status (SENT / FAILED / SKIPPED_*) so the caller can update its * counters without re-querying the DB. */ private async deliver(attempt: SendAttempt & { debug: boolean }): Promise { try { const { messageId, response } = await this.mail.send({ to: attempt.customerEmail, toName: attempt.customerName, subject: attempt.subject, html: attempt.bodySnapshot, xTracking: attempt.debug ? "debug" : "1", }); await this.recordAttempt({ ...attempt, providerMessageId: messageId || undefined, providerResponse: response || undefined, status: "SENT", }); return "SENT"; } catch (err) { const message = err instanceof Error ? err.message : String(err); // ServiceUnavailableException means no transport was configured and // we are not in dev — surface that as a clear FAILED row rather than // letting it abort the whole job mid-loop. this.logger.warn( `Notification send failed for ${attempt.customerName} <${attempt.customerEmail}>: ${message}`, ); await this.recordAttempt({ ...attempt, status: "FAILED", error: message.slice(0, 4096), }); return "FAILED"; } } /** Persist one notification log row. Thin pass-through to the shared * writer — the renewal sweep writes the same rows through the same * service, which is what keeps /notificaciones' log complete. */ private async recordAttempt(args: { notificationType: EmailNotificationType; servicio: EmailNotificationServicio; customerId: string | null; customerName: string; customerEmail: string; subject: string; bodySnapshot: string; bodyRequestUrl?: string; level?: 0 | 1; status: AttemptStatus; debug: boolean; providerMessageId?: string; providerResponse?: string; error?: string; }) { await this.log.record(args); } /** Send the admin summary email after every job. The PHP sent one to * each admin address; we do the same. Body = JSON-stringified * response so it matches the legacy format verbatim. */ private async adminSummary(subject: string, response: NotificationJobResponse) { const body = JSON.stringify(response); // Resolved per job, not cached at boot: the recipient list is edited in // the UI while the app is running (SettingsService), and a cached copy // would put us back to needing a restart for it to take effect. const { value: adminEmails } = await this.settings.notificationAdminEmails(); for (const to of adminEmails) { try { const { messageId } = await this.mail.send({ to, toName: "Jorge Cuadros Admin", subject, html: `
${body.replace(
            /[<>&]/g,
            (c) => ({ "<": "<", ">": ">", "&": "&" })[c] ?? c,
          )}
`, xTracking: "admin-summary", }); this.logger.log( `Admin summary sent to ${to}: subject="${subject}" id=${messageId}`, ); } catch (err) { this.logger.warn( `Admin summary to ${to} failed: ${(err as Error).message}`, ); } } } /** Available for tests + future cron to assert before launching a sweep. */ get transportAvailable(): boolean { return this.mail.available; } }