Merge branch 'massive-email-notification' into master
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m7s

# Conflicts:
#	.env.example
#	apps/api/src/app.module.ts
This commit is contained in:
2026-08-02 02:05:36 -07:00
23 changed files with 2883 additions and 139 deletions
+4
View File
@@ -4,6 +4,7 @@ import { ScheduleModule } from "@nestjs/schedule";
import { PrismaModule } from "./prisma/prisma.module";
import { StorageModule } from "./storage/storage.module";
import { CommonModule } from "./common/common.module";
import { MailModule } from "./mail/mail.module";
import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";
import { CustomersModule } from "./customers/customers.module";
@@ -16,6 +17,7 @@ import { BankModule } from "./bank/bank.module";
import { OpsModule } from "./ops/ops.module";
import { ReportsModule } from "./reports/reports.module";
import { RenewalsModule } from "./renewals/renewals.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { AppController } from "./app.controller";
@Module({
@@ -25,6 +27,7 @@ import { AppController } from "./app.controller";
PrismaModule,
StorageModule,
CommonModule,
MailModule,
UsersModule,
AuthModule,
CustomersModule,
@@ -37,6 +40,7 @@ import { AppController } from "./app.controller";
OpsModule,
ReportsModule,
RenewalsModule,
NotificationsModule,
],
controllers: [AppController],
})
+7 -1
View File
@@ -39,7 +39,8 @@ export type Ability =
| "statement:review"
| "lookup:manage"
| "user:manage"
| "db:manage";
| "db:manage"
| "notification:send";
/** Minimum role required for each ability. */
export const ABILITY_MIN: Record<Ability, Role> = {
@@ -73,6 +74,11 @@ export const ABILITY_MIN: Record<Ability, Role> = {
"lookup:manage": "MANAGER",
"user:manage": "ADMIN",
"db:manage": "ADMIN",
// Mass email notifications — fires mail to customers on the office's
// behalf, with no per-row review. Same trust tier as `renewal:send`:
// a STAFF user typing one customer receipt is fine; a STAFF user firing
// 260 mail merges on the customer base is not.
"notification:send": "MANAGER",
};
export const ALL_ABILITIES = Object.keys(ABILITY_MIN) as Ability[];
+12
View File
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MailService } from "./mail.service";
/** Global so any feature module can inject MailService without re-importing.
* Matches the StorageService pattern: env-driven, null when unconfigured,
* and never blocks API boot. Notifications use it; renewals reuse it. */
@Module({
providers: [{ provide: MailService, useFactory: (c: ConfigService) => new MailService(c) }],
exports: [MailService],
})
export class MailModule {}
+189
View File
@@ -0,0 +1,189 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
SESv2Client,
SendEmailCommand,
SendEmailCommandInput,
SendEmailCommandOutput,
} from "@aws-sdk/client-sesv2";
/**
* Outbound mail transport. Amazon SES — the channel the office already uses
* for bulk notification, per docs/INSURANCE_FEATURES_SPEC.md §1.3 (the
* renewal-notice spec settled on SES for the same reason: established sender
* reputation, existing IAM, negligible incremental cost at our volume).
*
* Mirrors `StorageService` exactly: env-driven config, null client when
* unconfigured, `ServiceUnavailableException` on use, never blocks API boot.
* When the env vars are missing AND we're in dev/test we fall back to a
* console-logging transport so the NotificationsService can be exercised
* end-to-end without SES credentials — a missing mail setup in production
* still throws, so a real deployment can't accidentally no-op its sends.
*
* Env:
* SES_REGION — required when client is configured
* SES_ACCESS_KEY / SES_SECRET_KEY — required
* SES_FROM — verified sending identity (e.g. mail@jorgecuadros.com)
* SES_FROM_NAME — display name, optional
* SES_CONFIGURATION_SET — optional, for bounce/complaint event publishing
*/
export interface SendArgs {
to: string;
/** Optional display name; SES will not display it for "to" but we keep it on
* the log row so customer-facing audit reads naturally. */
toName?: string;
subject: string;
/** HTML body. The four notification jobs all produce HTML. */
html: string;
/** Optional override of the configured From; rare but useful for the
* trust-payment test mail to a different identity. */
from?: string;
fromName?: string;
/** Marker header kept on every send so a downstream mail-log search for
* "X-Tracking: 1" surfaces only this app's outbound traffic. The legacy
* PHP sendEmail() always set it; we keep the convention. */
xTracking?: string;
}
export interface SendResult {
/** SES MessageId (or our mock prefix in dev). Stored verbatim on the
* notification log row so a SES bounce/complaint webhook can be matched
* back to the exact send. */
messageId: string;
/** Truncated SES response payload (or empty in dev). 4k cap matches the
* notification log column width. */
response: string;
}
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private readonly client: SESv2Client | null;
private readonly fromAddress: string | null;
private readonly fromName: string;
private readonly configurationSet: string | undefined;
private readonly devMode: boolean;
constructor(config: ConfigService) {
const region = config.get<string>("SES_REGION");
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
this.fromAddress =
config.get<string>("SES_FROM") ??
config.get<string>("MAIL_FROM") ??
null;
this.fromName =
config.get<string>("SES_FROM_NAME") ??
config.get<string>("MAIL_FROM_NAME") ??
"Information Server";
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET");
// Dev fallback: when nothing is configured, log sends to stdout instead
// of throwing. Lets the API boot in a fresh checkout and lets the
// notifications UI show "0 sent" meaningfully on `debug=1`. Production
// (NODE_ENV !== development) still requires real config.
this.devMode = process.env.NODE_ENV !== "production";
if (!region || !accessKeyId || !secretAccessKey || !this.fromAddress) {
if (!this.devMode) {
this.logger.warn(
"SES not configured (SES_REGION / SES_ACCESS_KEY / SES_SECRET_KEY / SES_FROM). " +
"Outbound mail will throw ServiceUnavailableException.",
);
}
this.client = null;
return;
}
this.client = new SESv2Client({
region,
credentials: { accessKeyId, secretAccessKey },
});
this.logger.log(
`SES mail client configured (region=${region}, from=${this.fromAddress}).`,
);
}
/** Whether the deployment has a real mail transport. Callers use this to
* refuse work up front — a mass-notification job that throws on its
* first send half-completes and the log is unrecoverable, so we fail
* fast at the controller. */
get available(): boolean {
return this.client !== null || this.devMode;
}
/** True when the underlying transport is the dev console-log fallback. */
get isDevFallback(): boolean {
return this.client === null && this.devMode;
}
private require(): SESv2Client {
if (!this.client) {
throw new ServiceUnavailableException(
"El envío de correo no está configurado.",
);
}
return this.client;
}
/**
* Send a single HTML email. The dev fallback logs to stdout and returns a
* synthetic `dev-<timestamp>` message id; the real transport talks to SES
* and returns the SES MessageId.
*
* Throws `ServiceUnavailableException` when no transport is configured and
* we are not in dev — the caller (NotificationsService) catches and records
* it on the log row so a failed sweep produces a coherent audit trail
* instead of an aborted one.
*/
async send(args: SendArgs): Promise<SendResult> {
const from = `${args.fromName ?? this.fromName} <${
args.from ?? this.fromAddress ?? ""
}>`.trim();
if (!this.client) {
if (!this.devMode) this.require();
const fakeId = `dev-${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`;
this.logger.log(
`[dev-mail] to=${args.to} subject="${args.subject}" id=${fakeId} ` +
`len=${args.html.length}`,
);
return { messageId: fakeId, response: "" };
}
const input: SendEmailCommandInput = {
FromEmailAddress: from,
Destination: { ToAddresses: [args.to] },
Content: {
Simple: {
Subject: { Data: args.subject, Charset: "UTF-8" },
Body: { Html: { Data: args.html, Charset: "UTF-8" } },
},
},
...(this.configurationSet
? { ConfigurationSetName: this.configurationSet }
: {}),
...(args.xTracking
? {
EmailTags: [
{ Name: "X-Tracking", Value: args.xTracking },
],
}
: {}),
};
const out: SendEmailCommandOutput = await this.client.send(
new SendEmailCommand(input),
);
return {
messageId: out.MessageId ?? "",
response: JSON.stringify({ MessageId: out.MessageId ?? null }).slice(0, 4096),
};
}
}
@@ -0,0 +1,126 @@
import {
EmailNotificationServicio,
EmailNotificationType,
} from "@jorgecuadros/database";
import { IsBoolean, IsEnum, IsOptional } from "class-validator";
/**
* Shared flags for the four notification jobs. Every endpoint takes the
* same shape so the UI can be uniform; each flag is documented inline so
* the per-job semantics are obvious in one place.
*
* `debug` — replace every recipient with the admin override
* address so a real customer never receives mail
* during a test run. Logged on every row.
* `ignoreDayRestriction` — Job 3 only: bypass the Mon/Wed/Fri (red) and
* Wed-only (yellow) day gates. Off by default so
* the on-demand sweep behaves like the legacy
* script.
* `useEmailLimit` — Job 3 only: pause the sweep 1 hour after 100
* sends (a vestigial SMTP-era throttling limit).
* Off by default; SES does not need it.
*/
export class NotificationFlagsDto {
@IsOptional()
@IsBoolean()
debug?: boolean;
@IsOptional()
@IsBoolean()
ignoreDayRestriction?: boolean;
@IsOptional()
@IsBoolean()
useEmailLimit?: boolean;
}
/**
* What we know at job-end and put on the wire. Field names match the
* legacy PHP scripts' `echo json_encode(...)` so a downstream log scraper
* that already parses `notificationType: "sendPaymentConfirmation"`
* keeps working — see `~/Documents/Claude-Memory/email-notifications-spec.md`
* for the verbatim PHP shapes. Specifically: Job 1 reports
* `notificationType: "sendPaymentConfirmation"` (the legacy literal), and
* uses field `result` instead of `request`; the other three use
* `notificationType` matching the script's purpose.
*
* Every variant carries `sent/skipped/failed/debug` for the audit log;
* the legacy fields stay where they were so the response shape is
* exactly backward-compatible.
*/
export type NotificationJobResponse =
| {
// Job 1
result: "success";
notificationType: "sendPaymentConfirmation";
reason: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "OUTSTANDING_PAYMENT";
}
| {
// Job 2
request: "success";
notificationType: "sendPaymentConfirmation";
confirmationSent: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "PAYMENT_CONFIRMATION";
}
| {
// Job 3 — sent/skipped/failed included so the audit log can record
// totals without depending on (red+yellow) alone.
request: "success";
notificationType: "sendAccountStatus";
statusSent: string;
statusReport: string;
statusCode: 200;
red: number;
yellow: number;
total: number;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "ACCOUNT_STATUS";
}
| {
// Job 4
request: "success";
notificationType: "sendTrustPaymentConfirmation";
confirmationSent: string;
statusCode: 200;
sent: number;
skipped: number;
failed: number;
debug: boolean;
type: "TRUST_PAYMENT_CONFIRMATION";
};
/** Normalized record for a single send attempt, fed by all four jobs. */
export interface SendAttempt {
notificationType: EmailNotificationType;
servicio: EmailNotificationServicio;
customerId: string | null;
customerName: string;
customerEmail: string;
subject: string;
bodySnapshot: string;
bodyRequestUrl?: string;
/** Account-status-only — 0 yellow / 1 red. Null on the other three jobs. */
level?: 0 | 1;
/** Account-status-only — DEBAJO DEL TIPO / EN ROJO. */
historyTipo?: string;
historyBalance?: string;
historyTCambio?: string;
historySolicitado?: string;
}
/** Status enum values, mirrored from `EmailNotificationStatus`. */
export type AttemptStatus = "SENT" | "FAILED" | "SKIPPED_NO_EMAIL" | "SKIPPED_GATE";
@@ -0,0 +1,174 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Query,
Req,
UseGuards,
} from "@nestjs/common";
import { Request } from "express";
import {
EmailNotificationServicio,
EmailNotificationStatus,
EmailNotificationType,
} from "@jorgecuadros/database";
import { Transform, Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, Max, Min } from "class-validator";
import { AuthenticatedGuard } from "../auth/authenticated.guard";
import { AbilityGuard } from "../auth/ability.guard";
import { RequireAbility } from "../auth/require-ability.decorator";
import { AuditService } from "../common/audit.service";
import { NotificationFlagsDto } from "./notification.types";
import { NotificationsService } from "./notifications.service";
/** Same flags for every job, query-string OR body (the PHP scripts took
* both via STDIN vs HTTP-CGI — we accept either for parity). */
class RunJobDto extends NotificationFlagsDto {}
class ListLogDto {
@IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) pageSize?: number;
@IsOptional() @IsEnum(EmailNotificationType) type?: EmailNotificationType;
@IsOptional() @IsEnum(EmailNotificationServicio) servicio?: EmailNotificationServicio;
@IsOptional() @IsEnum(EmailNotificationStatus) status?: EmailNotificationStatus;
@IsOptional() @IsEnum(["sent", "failed", "skipped", "all"]) view?: "sent" | "failed" | "skipped" | "all";
}
function actingId(req: Request): string {
return (req.user as { id: string }).id;
}
/**
* HTTP surface for the mass-notification jobs. Four trigger endpoints +
* two read endpoints (list log, stats). All mutations gated by the
* `notification:send` ability so a STAFF user can't accidentally fire a
* 260-mail sweep.
*/
@UseGuards(AuthenticatedGuard, AbilityGuard)
@Controller("notifications")
export class NotificationsController {
constructor(
private readonly svc: NotificationsService,
private readonly audit: AuditService,
) {}
/* -------------------------------------------------------------- triggers */
@Post("outstanding-payments")
@RequireAbility("notification:send")
@HttpCode(200)
async runOutstanding(
@Body() body: RunJobDto,
@Query() query: RunJobDto,
@Req() req: Request,
) {
const flags = { ...query, ...body };
const result = await this.svc.runOutstandingPayments(flags);
void this.audit.log(actingId(req), "notification.outstanding.run", {
debug: !!flags.debug,
sent: result.sent,
skipped: result.skipped,
failed: result.failed,
});
return result;
}
@Post("payment-confirmation")
@RequireAbility("notification:send")
@HttpCode(200)
async runPaymentConfirm(
@Body() body: RunJobDto,
@Query() query: RunJobDto,
@Req() req: Request,
) {
const flags = { ...query, ...body };
const result = await this.svc.runPaymentConfirmation(flags);
void this.audit.log(actingId(req), "notification.payment-confirm.run", {
debug: !!flags.debug,
sent: result.sent,
skipped: result.skipped,
failed: result.failed,
});
return result;
}
@Post("account-status")
@RequireAbility("notification:send")
@HttpCode(200)
async runAccountStatus(
@Body() body: RunJobDto,
@Query() query: RunJobDto,
@Req() req: Request,
) {
const flags = { ...query, ...body };
const result = await this.svc.runAccountStatus(flags);
// Narrow the discriminated union to the ACCOUNT_STATUS variant before
// pulling red/yellow/total — TS can't follow this through `await` alone.
if (result.type === "ACCOUNT_STATUS") {
void this.audit.log(actingId(req), "notification.account-status.run", {
debug: !!flags.debug,
red: result.red,
yellow: result.yellow,
total: result.total,
sent: result.sent,
skipped: result.skipped,
failed: result.failed,
});
}
return result;
}
@Post("trust-payment-confirmation")
@RequireAbility("notification:send")
@HttpCode(200)
async runTrustConfirm(
@Body() body: RunJobDto,
@Query() query: RunJobDto,
@Req() req: Request,
) {
const flags = { ...query, ...body };
const result = await this.svc.runTrustConfirmation(flags);
void this.audit.log(actingId(req), "notification.trust-confirm.run", {
debug: !!flags.debug,
sent: result.sent,
skipped: result.skipped,
failed: result.failed,
});
return result;
}
/* ----------------------------------------------------------- read views */
@Get("log")
listLog(@Query() q: ListLogDto) {
const page = q.page ?? 1;
const pageSize = q.pageSize ?? 50;
return this.svc.listLog({
page,
pageSize,
type: q.type,
servicio: q.servicio,
status: this.mapViewStatus(q.view, q.status),
customerId: undefined,
});
}
@Get("stats")
stats() {
return this.svc.stats();
}
private mapViewStatus(
view: ListLogDto["view"],
status: ListLogDto["status"],
): EmailNotificationStatus | undefined {
if (status) return status;
if (!view || view === "all") return undefined;
if (view === "sent") return EmailNotificationStatus.SENT;
if (view === "failed") return EmailNotificationStatus.FAILED;
if (view === "skipped") return undefined; // both SKIPPED_* variants
return undefined;
}
}
@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { NotificationsController } from "./notifications.controller";
import { NotificationsService } from "./notifications.service";
/**
* Mass email notifications. MailModule is global (registered in AppModule),
* so this module needs no MailService import — it picks it up by injection.
*
* Cron sweeps (a future `@nestjs/schedule` trigger of these four methods on
* the legacy Mon/Wed/Fri cadence) belong in this module; the per-job
* service methods are already the entry points they would call.
*/
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}
@@ -0,0 +1,926 @@
import { Injectable, Logger, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
Currency,
EmailNotificationServicio,
EmailNotificationStatus,
EmailNotificationType,
Prisma,
TransactionDomain,
} from "@jorgecuadros/database";
import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service";
import {
SendAttempt,
NotificationJobResponse,
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 {
private readonly logger = new Logger(NotificationsService.name);
/** Override addresses — comma-separated in env. Falls back to the
* legacy defaults so a fresh deploy still has somewhere to send. */
private readonly adminEmails: string[];
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
config: ConfigService,
) {
const csv = config.get<string>("NOTIFICATION_ADMIN_EMAILS");
if (csv && csv.trim()) {
this.adminEmails = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
} else {
this.adminEmails = ["rmancinas@freakma.net", "mpulido@freakma.net"];
}
}
/* ============================================================================
* Public jobs — called by the controller and by future cron sweeps 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;
}
/* ============================================================================
* 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;
servicio?: EmailNotificationServicio;
status?: EmailNotificationStatus;
customerId?: string;
}) {
const where: Prisma.EmailNotificationLogWhereInput = {};
if (params.type) where.notificationType = params.type;
if (params.servicio) where.servicio = params.servicio;
if (params.status) where.status = 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. */
async stats() {
const [byType, byStatus, byServicio, lastRun] = await Promise.all([
this.prisma.emailNotificationLog.groupBy({
by: ["notificationType", "status"],
_count: { _all: true },
}),
this.prisma.emailNotificationLog.groupBy({
by: ["status"],
_count: { _all: true },
}),
this.prisma.emailNotificationLog.groupBy({
by: ["servicio", "status"],
_count: { _all: true },
}),
this.prisma.emailNotificationLog.findFirst({
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. The PHP used
* `rmancinas@freakma.net`; same here. */
private debugEmail(): string {
return "rmancinas@freakma.net";
}
/** 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. */
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.prisma.emailNotificationLog.create({
data: {
notificationType: args.notificationType,
servicio: args.servicio,
level: args.level ?? null,
customerId: args.customerId,
customerName: args.customerName,
customerEmail: args.customerEmail,
subject: args.subject,
bodySnapshot: args.bodySnapshot,
bodyRequestUrl: args.bodyRequestUrl ?? null,
debug: args.debug,
providerMessageId: args.providerMessageId ?? null,
providerResponse: args.providerResponse ?? null,
status: args.status as EmailNotificationStatus,
error: args.error ?? null,
},
});
}
/** 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);
for (const to of this.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) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[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;
}
}
+125
View File
@@ -0,0 +1,125 @@
import {
renderAccountStatus,
renderOutstanding,
renderPaymentConfirm,
renderTrustConfirm,
} from "./render";
/**
* Render-level tests. The legacy PHP scripts fetched these bodies by URL;
* we render server-side and inline. The tests assert the *shape* of each
* body — account id, name, subject, balance/tipo, color band — because
* the customer base has been seeing these letters for years and a visual
* regression costs trust faster than any backend change does.
*/
describe("renderOutstanding", () => {
it("includes the customer id, name, total, and per-row table", () => {
const html = renderOutstanding({
customerId: "C-001",
customerName: "Acme & Co.",
total: "1234.50",
rows: [
{
date: "2026-07-01",
reference: "INV-1",
period: "Jul-26",
type: "CHECK",
amount: "-500.00",
balance: "-500.00",
},
{
date: "2026-07-15",
reference: "INV-2",
period: "Jul-26",
type: "CASH",
amount: "-734.50",
balance: "-1234.50",
},
],
year: 2026,
});
expect(html).toContain("Acme &amp; Co.");
expect(html).toContain("ACCOUNT #C-001");
expect(html).toContain("$ 1,234.50");
expect(html).toContain("INV-1");
expect(html).toContain("CHECK");
expect(html).toContain("IF YOU ALREADY SENT THE CHECK");
});
it("escapes HTML in the customer name", () => {
const html = renderOutstanding({
customerId: "x",
customerName: "<script>alert(1)</script>",
total: "0.00",
rows: [],
year: 2026,
});
expect(html).not.toContain("<script>alert(1)</script>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
});
});
describe("renderPaymentConfirm", () => {
it("uses the transaction type in the heading and the amount in the body", () => {
const html = renderPaymentConfirm({
customerId: "C-002",
customerName: "Bob",
typeOfTrx: "CHECK DEPOSIT",
reference: "DEP-99",
amount: "500.00",
year: 2026,
});
expect(html).toContain("CHECK DEPOSIT CONFIRMATION");
expect(html).toContain("HI, Bob");
expect(html).toContain("REFER# DEP-99");
expect(html).toContain("$ 500.00");
});
});
describe("renderAccountStatus", () => {
it("uses the yellow band and the under-minimum phrasing for level=0", () => {
const html = renderAccountStatus({
customerId: "C-003",
customerName: "Carol",
level: 0,
balance: "10.00",
tipo: "40.00",
year: 2026,
});
expect(html).toContain("#88D5EE");
expect(html).toContain("under our minimum");
expect(html).toContain("Carol");
expect(html).toContain("$ 10.00");
expect(html).toContain("$ 40.00");
});
it("uses the red band and the rush phrasing for level=1", () => {
const html = renderAccountStatus({
customerId: "C-003",
customerName: "Carol",
level: 1,
balance: "-25.50",
tipo: "25.50",
year: 2026,
});
expect(html).toContain("#FF8D71");
expect(html).toContain("overdrawn");
expect(html).toContain("reactivate your payments");
expect(html).toContain("$ 25.50");
});
});
describe("renderTrustConfirm", () => {
it("labels the trust annual fee and quotes the amount", () => {
const html = renderTrustConfirm({
customerId: "C-004",
customerName: "Dan",
amount: "350.00",
year: 2026,
});
expect(html).toContain("Annual Bank Fee Payment Confirmation");
expect(html).toContain("$ 350.00");
expect(html).toContain("Most banks always request");
});
});
+254
View File
@@ -0,0 +1,254 @@
/**
* HTML body renderers for the four notification jobs. These are the modern
* in-process equivalent of the legacy `getXxxForEmail.php` files the PHP
* scripts `fetch()`ed by URL. Rendering server-side and inlining the body
* in the response keeps a single SES MessageId tied to one frozen HTML
* snapshot (vs. the legacy flow, where the URL kept re-rendering with
* whatever the database looked like at click time).
*
* The visual style mirrors the legacy PHP templates where it makes sense
* (the office's customer base has been seeing these letters for years;
* gratuitous redesign costs trust). The body shell, table layout and the
* canonical contact block are preserved verbatim. English copy because the
* legacy letters were English; switching to Spanish is a future decision
* (see INSURANCE_FEATURES_SPEC §1.6 "Spanish or English body?").
*/
const HEAD = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{title}</title>
</head>`;
const FOOT_CONTACT = `<p>If you have any questions regarding this notice please contact us at:
Tel. 011 52 (661) 612 - 1295 &nbsp; Fax. (661) 612 - 1285 &nbsp;
For any type of a 24 Hrs. emergencies: please dial 52 (664) 304 - 7778 |
<a href="mailto:jorge@jorgecuadros.com">jorge@jorgecuadros.com</a> |
<a href="https://www.jorgecuadros.com/contactus.php">Contact Us Form</a></p>`;
const SIGNED = (year: number) => `<center><span class="small">This message has been generated by the Jorge Cuadros &amp; Assoc. Information Server.<br />Copyright ${year}&nbsp;<a href="http://www.freakma.net/">Developed by FreaKmA.Net</a></span></center>`;
const esc = (s: string | null | undefined): string =>
String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
const usd = (n: number | string | null | undefined): string => {
if (n === null || n === undefined) return "$ 0.00";
const v = typeof n === "string" ? Number(n) : n;
if (!isFinite(v)) return "$ 0.00";
return `$ ${v.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
};
/** Shared shell: a 2-column table that matches the PHP output layout. */
function shell(opts: {
title: string;
bg: string;
heading: string;
accountId: string | number;
accountName: string;
body: string;
note?: string;
statementLink?: string;
year: number;
}): string {
const { title, bg, heading, accountId, accountName, body, note, statementLink, year } = opts;
const stmt = statementLink ?? "https://my.jorgecuadros.com/";
return `${HEAD.replace("{title}", esc(title))}
<body style="background-color:${bg};color:#333;font-family:'Courier New', Courier, monospace;">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td width="43%" style="font-size:20px;font-weight:bold;">${esc(heading)}</td>
<td width="57%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros &amp; Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php">Customer Support</a></td>
</tr>
<tr>
<td><strong>${esc(accountName)}<br />ACCOUNT #${esc(String(accountId))}</strong></td>
<td><div align="center"><a href="${esc(stmt)}" target="_blank" style="color:#006699;font-weight:bold">Click Here to View Your Account Statement</a></div></td>
</tr>
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="2">${body}</td></tr>
<tr><td colspan="2">&nbsp;</td></tr>
${
note
? `<tr><td colspan="2"><h4>${esc(note)}</h4>${FOOT_CONTACT}</td></tr>`
: `<tr><td colspan="2">${FOOT_CONTACT}</td></tr>`
}
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="2">${SIGNED(year)}</td></tr>
</table>
</body>
</html>`;
}
/* -------------------------------------------------------------------------- */
/* Outstanding payments — Job 1 */
/* -------------------------------------------------------------------------- */
export interface OutstandingRow {
date: Date | string;
reference: string | null;
period: string | null;
type: string | null;
/** Signed amount (negative for charges). */
amount: number | string;
/** Running balance in the customer's currency, after this row. */
balance: number | string;
}
export function renderOutstanding(args: {
customerId: string;
customerName: string;
total: number | string;
rows: OutstandingRow[];
year: number;
}): string {
const rows = args.rows
.map(
(r) => `<tr>
<td>${esc(String(r.date))}</td>
<td>${esc(r.reference ?? "")}</td>
<td>${esc(r.period ?? "")}</td>
<td>${esc(r.type ?? "")}</td>
<td align="right">${esc(usd(r.amount))}</td>
<td align="right">${esc(usd(r.balance))}</td>
</tr>`,
)
.join("\n");
const body = `<p>This needs your prompt attention in order to avoid any disruption(s):</p>
<p align="center"><strong><font color="#FF0000">TOTAL OF OUTSTANDING BILLS: ${esc(
usd(args.total),
)} PESOS.</font></strong></p>
<table width="100%" border="0" cellpadding="0" cellspacing="0">
<tr><th>DATE</th><th>REFER</th><th>PERIOD</th><th>TYPEOFTRX</th><th>CHARGECREDIT</th><th>BALANCE</th></tr>
${rows}
</table>`;
return shell({
title: "Outstanding Payments",
bg: "#9CC",
heading: "Outstanding Payments",
accountId: args.customerId,
accountName: args.customerName,
body,
note: "NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL",
year: args.year,
});
}
/* -------------------------------------------------------------------------- */
/* Payment confirmation — Job 2 */
/* -------------------------------------------------------------------------- */
export function renderPaymentConfirm(args: {
customerId: string;
customerName: string;
typeOfTrx: string;
reference: string | null;
/** The deposited amount (positive number — credits are positive in the
* unified ledger). */
amount: number | string;
year: number;
}): string {
const body = `<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td width="48%" style="font-size:20px;font-weight:bold;">${esc(
args.typeOfTrx,
)} CONFIRMATION</td>
<td width="52%" style="font-size:12px;">Please do not reply to this message. For any Jorge Cuadros &amp; Assoc. customer service inquiries, visit: <a href="https://www.jorgecuadros.com/contactus.php" target="_blank">Customer Support</a></td>
</tr>
<tr>
<td>
<strong>HI, ${esc(args.customerName)}</strong><br/>
<strong>ACCOUNT #${esc(args.customerId)}</strong><br/>
<strong>REFER# ${esc(args.reference ?? "")}</strong>
</td>
<td>
<div align="center" style="padding:20px;">
<a href="https://my.jorgecuadros.com/" target="_blank" style="color:#006699;font-weight:bold"><em>Click Here to View Your Account Statement</em></a>
</div>
</td>
</tr>
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="2">
<p>Your account is now current to keep paying your future obligations. If for any reason your next bill is more than what's available; our system will email you our automatic alert requesting more funds. Thank You,</p>
<p align="center" style="color:#006600;font-weight:bold;">Your deposit was for ${esc(
usd(args.amount),
)} PESOS.</p>
</td></tr>
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="2"><h4>NOTE : IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL</h4>${FOOT_CONTACT}</td></tr>
<tr><td colspan="2">&nbsp;</td></tr>
<tr><td colspan="2">${SIGNED(args.year)}</td></tr>
</table>`;
return `${HEAD.replace("{title}", "Payment Confirmation")}<body>${body}</body></html>`;
}
/* -------------------------------------------------------------------------- */
/* Account status — Job 3 (yellow + red) */
/* -------------------------------------------------------------------------- */
export function renderAccountStatus(args: {
customerId: string;
customerName: string;
level: 0 | 1; // 0 = yellow (DEBAJO DEL TIPO), 1 = red (EN ROJO)
balance: number | string;
/** Amount the customer needs to deposit to clear the threshold. */
tipo: number | string;
year: number;
}): string {
const isYellow = args.level === 0;
const body = isYellow
? `<p>In order to avoid any disruptions please mail or bring ${esc(
usd(args.tipo),
)} USD ASAP. As your current Balance ${esc(
usd(args.balance),
)} is under our minimum required to run this account.</p>`
: `<p>Sorry Account is overdrawn and all utility bills are on hold please rush ${esc(
usd(args.tipo),
)} USD these funds must be on hand ASAP to reactivate your payments.</p>`;
return shell({
title: "Account Alert",
bg: isYellow ? "#88D5EE" : "#FF8D71",
heading: "Account Alert",
accountId: args.customerId,
accountName: args.customerName,
body,
note: "NOTE : PLEASE MAKE YOUR CHECK PAYABLE TO UMC AND ASSOCIATES. IF YOU ALREADY SENT THE CHECK, PLEASE DISREGARD THIS EMAIL.",
year: args.year,
});
}
/* -------------------------------------------------------------------------- */
/* Trust payment confirmation — Job 4 */
/* -------------------------------------------------------------------------- */
export function renderTrustConfirm(args: {
customerId: string;
customerName: string;
/** Annual fee amount posted (positive, in MXN per the PHP). */
amount: number | string;
year: number;
}): string {
const body = `<p>This automatic notice is to confirm, that your Annual Bank Fee has been paid by, and posted in your account. Thank You,</p>
<p align="center"><strong>The annual fee was posted for the amount of <font color="#FF0000">${esc(
usd(args.amount),
)} PESOS.</font></strong></p>`;
return shell({
title: "Trust Payment Confirmation",
bg: "#C0BEA0",
heading: "Annual Bank Fee Payment Confirmation",
accountId: args.customerId,
accountName: args.customerName,
body,
note: "NOTE : Most banks always request to make such payment in advance.",
statementLink: "https://my.jorgecuadros.com/",
year: args.year,
});
}
@@ -1,27 +0,0 @@
import { ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { MailService } from "./mail.service";
function config(values: Record<string, string>): ConfigService {
return { get: (key: string) => values[key] } as ConfigService;
}
describe("MailService", () => {
it("uses a no-op provider when SES is absent in development", async () => {
const service = new MailService(config({ NODE_ENV: "development" }));
expect(service.available).toBe(true);
await expect(
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
).resolves.toEqual({ providerId: expect.stringMatching(/^dev-/) });
});
it("keeps production bootable but refuses sends when SES is absent", async () => {
const service = new MailService(config({ NODE_ENV: "production" }));
expect(service.available).toBe(false);
await expect(
service.send({ to: "test@example.com", subject: "Test", html: "<p>Test</p>" }),
).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
-102
View File
@@ -1,102 +0,0 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
SendEmailCommand,
SESv2Client,
} from "@aws-sdk/client-sesv2";
import { randomUUID } from "node:crypto";
export interface MailMessage {
to: string;
subject: string;
html: string;
}
export interface MailProvider {
send(message: MailMessage): Promise<{ providerId: string }>;
}
@Injectable()
export class MailService implements MailProvider {
private readonly logger = new Logger(MailService.name);
private readonly client: SESv2Client | null;
private readonly from: string | null;
private readonly configurationSet: string | undefined;
private readonly developmentNoop: boolean;
constructor(config: ConfigService) {
const region = config.get<string>("SES_REGION");
const from = config.get<string>("SES_FROM");
const accessKeyId = config.get<string>("SES_ACCESS_KEY");
const secretAccessKey = config.get<string>("SES_SECRET_KEY");
this.configurationSet = config.get<string>("SES_CONFIGURATION_SET") || undefined;
this.developmentNoop =
config.get<string>("NODE_ENV") !== "production" &&
!region &&
!from &&
!accessKeyId &&
!secretAccessKey;
if (this.developmentNoop) {
this.logger.warn("SES no configurado; los correos se registrarán sin enviarse.");
this.client = null;
this.from = null;
return;
}
if (!region || !from || !accessKeyId || !secretAccessKey) {
this.logger.warn("SES no configurado; las notificaciones por correo están deshabilitadas.");
this.client = null;
this.from = null;
return;
}
this.client = new SESv2Client({
region,
credentials: { accessKeyId, secretAccessKey },
});
this.from = from;
}
get available(): boolean {
return this.developmentNoop || (this.client !== null && this.from !== null);
}
async send(message: MailMessage): Promise<{ providerId: string }> {
if (this.developmentNoop) {
const providerId = `dev-${randomUUID()}`;
this.logger.log(`Correo de renovación simulado (${providerId}).`);
return { providerId };
}
if (!this.client || !this.from) {
throw new ServiceUnavailableException(
"El servicio de correo no está configurado.",
);
}
const result = await this.client.send(
new SendEmailCommand({
FromEmailAddress: this.from,
Destination: { ToAddresses: [message.to] },
Content: {
Simple: {
Subject: { Data: message.subject, Charset: "UTF-8" },
Body: { Html: { Data: message.html, Charset: "UTF-8" } },
},
},
ConfigurationSetName: this.configurationSet,
}),
);
if (!result.MessageId) {
throw new Error("SES no devolvió identificador de mensaje.");
}
return { providerId: result.MessageId };
}
}
+1 -2
View File
@@ -1,10 +1,9 @@
import { Module } from "@nestjs/common";
import { RenewalsController } from "./renewals.controller";
import { MailService } from "./mail.service";
import { RenewalsService } from "./renewals.service";
@Module({
controllers: [RenewalsController],
providers: [MailService, RenewalsService],
providers: [RenewalsService],
})
export class RenewalsModule {}
+10 -5
View File
@@ -6,12 +6,12 @@ import {
} from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { AuditService } from "../common/audit.service";
import { MailService } from "../mail/mail.service";
import { PrismaService } from "../prisma/prisma.service";
import {
renewalLetterSelect,
toRenewalLetterRow,
} from "../reports/renewal-letter";
import { MailService } from "./mail.service";
import { renderRenewalEmail } from "./renewal-email";
export const RENEWAL_CADENCE = [
@@ -135,7 +135,12 @@ export class RenewalsService {
try {
const letter = toRenewalLetterRow(policy, cadence.generation);
const message = renderRenewalEmail(letter);
const result = await this.mail.send({ to, ...message });
const result = await this.mail.send({
to,
subject: message.subject,
html: message.html,
xTracking: "renewals",
});
const sentAt = new Date();
await this.prisma.renewalNotice.upsert({
@@ -151,20 +156,20 @@ export class RenewalsService {
channel: "EMAIL",
sentAt,
sentById: userId,
providerMessageId: result.providerId,
providerMessageId: result.messageId,
},
update: {
channel: "EMAIL",
sentAt,
sentById: userId,
providerMessageId: result.providerId,
providerMessageId: result.messageId,
},
});
sent++;
void this.audit.log(userId, "renewalNotice.send", {
policyId: policy.id,
generation: cadence.generation,
providerMessageId: result.providerId,
providerMessageId: result.messageId,
});
} catch (error) {
failures.push({