The "Flags del envío" panel lived inside the Servicios tab and only
governed the four bulk jobs. The pólizas half had no debug at all, so
there was no way to test a renewal notice without mailing a real
customer. The panel now lives in the /notificaciones shell above the
tabs and both halves read it.
`debug` on the renewal path diverts to the same override inbox as the
servicios jobs and deliberately does NOT write the `RenewalNotice` row
or advance the sweep's `lastSuccessfulAt` — the customer was not
notified, so nothing may gate the letter they are still owed.
`ignoreDayRestriction` and `useEmailLimit` stay estado-de-cuenta-only
and are labelled as such.
Both automatic sweeps are now operator-editable. The renewal cadence
was a `@Cron("0 6 * * *")` literal and servicios had no automatic run
at all; both now resolve through `NotificationScheduleService`, which
stores the cadence in `app_settings` and reinstalls the cron job on
save — no redeploy, no restart. Defaults preserve current behaviour:
pólizas 06:00 daily, servicios off. A scheduled run never inherits the
UI flags; it always sends for real.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1008 lines
34 KiB
TypeScript
1008 lines
34 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<NotificationJobResponse> {
|
|
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<NotificationJobResponse> {
|
|
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<NotificationJobResponse> {
|
|
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<NotificationJobResponse> {
|
|
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<NotificationRunAllResponse> {
|
|
const debug = !!flags.debug;
|
|
const steps: {
|
|
kind: NotificationJobKind;
|
|
run: () => Promise<NotificationJobResponse>;
|
|
}[] = [
|
|
{ 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<number | null> {
|
|
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<boolean> {
|
|
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<AttemptStatus> {
|
|
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: `<pre style="font-family:monospace;font-size:12px;">${body.replace(
|
|
/[<>&]/g,
|
|
(c) => ({ "<": "<", ">": ">", "&": "&" })[c] ?? c,
|
|
)}</pre>`,
|
|
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;
|
|
}
|
|
}
|